app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
v2.0.734 (insiders): pup keeps its own browser on the current Chrome Stable, silently (John, 2026-10-25: 'New Chrome av…
f278a89
16d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
//! The remaining Phase 2 verbs: pup_screenshot_element, pup_screenshot_full_res, pup_download,
//! the 5-minute identity read-back audit (ab desktop_get_window_identity, adom-bridge#147),
//! and the small lifecycle verbs (pup_launch, pup_deps, pup_install_toolbar, pup_reap_profiles,
//! pup_uninstall, pup_move_to_workspace placeholder).
use crate::input::resolve_tab;
use crate::{touch, Ctx};
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
const T: Duration = Duration::from_secs(10);
// pup-bridge#109: downloads in flight, surfaced in pup_readiness.active[] so the poll hint is TRUE. A
// synchronous pup_download used to block up to 90 s, longer than the caller's request window, so the caller
// saw a relay timeout while nothing was pollable. Now a slow download is registered here and finished by a
// background watcher; the caller polls pup_readiness until it clears.
static ACTIVE_DOWNLOADS: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
pub fn active_download_rows() -> Vec<Value> { ACTIVE_DOWNLOADS.lock().unwrap().clone() }
fn dl_put(row: Value) { let id = row["id"].clone(); let mut m = ACTIVE_DOWNLOADS.lock().unwrap(); m.retain(|r| r["id"] != id); m.push(row); }
fn dl_done(id: &str) { ACTIVE_DOWNLOADS.lock().unwrap().retain(|r| r["id"] != json!(id)); }
pub fn screenshot_element(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let (sel, txt, closest) = (args["selector"].as_str(), args["text"].as_str(), args["closest"].as_str());
if sel.is_none() && txt.is_none() { return err("args_required", "pup_screenshot_element requires a selector OR text", Some("e.g. {\"selector\":\".download-card\"} or {\"text\":\"Download\"}")); }
let js = format!(r#"(() => {{ const sel = {}, txt = {}, closest = {};
const deep = (root, out) => {{ for (const n of root.querySelectorAll('*')) {{ out.push(n); if (n.shadowRoot) deep(n.shadowRoot, out); }} }};
let el = null;
if (sel) {{ try {{ el = document.querySelector(sel); }} catch (e) {{ return {{ error: 'bad selector: ' + e.message }}; }} if (!el) {{ const all = []; deep(document, all); el = all.find(n => {{ try {{ return n.matches(sel); }} catch (e) {{ return false; }} }}) || null; }} }}
if (!el && txt) {{ const all = []; deep(document, all); const t = txt.toLowerCase(); const marks = all.filter(n => n.children.length < 8 && (n.innerText || '').trim().toLowerCase().includes(t)); el = marks[0] || null; if (closest) {{ for (const m of marks) {{ const c = m.closest(closest); if (c) {{ el = c; break; }} }} }} }}
else if (el && closest) {{ el = el.closest(closest) || el; }}
if (!el) return null;
el.scrollIntoView({{ block: 'center', inline: 'center' }});
const r = el.getBoundingClientRect(); return {{ x: r.x, y: r.y, w: r.width, h: r.height, tag: el.tagName }}; }})()"#, sel.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()), txt.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()), closest.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()));
let rect = match b.cdp.evaluate(&t.cdp_session, &js, T) { Ok(v) if v.is_null() => return err("element_not_found", format!("pup_screenshot_element: no element matched {}", sel.map(|x| format!("selector \"{x}\"")).unwrap_or_else(|| format!("text \"{}\"", txt.unwrap_or("")))), Some("pup_eval document.querySelectorAll(...) to check the selector; shadow roots are searched, iframes are not.")), Ok(v) if v.get("error").is_some() => return err("bad_selector", v["error"].as_str().unwrap_or("bad selector"), None), Ok(v) => v, Err(e) => return err("eval_failed", e.to_string(), None) };
std::thread::sleep(Duration::from_millis(120));
let pad = args["padding"].as_f64().unwrap_or(0.0).max(0.0);
let clip = json!({ "x": (rect["x"].as_f64().unwrap_or(0.0) - pad).max(0.0), "y": (rect["y"].as_f64().unwrap_or(0.0) - pad).max(0.0), "width": (rect["w"].as_f64().unwrap_or(1.0) + pad * 2.0).max(1.0), "height": (rect["h"].as_f64().unwrap_or(1.0) + pad * 2.0).max(1.0), "scale": 1 });
let r = match crate::transport::with_frames(|| b.cdp.send(Some(&t.cdp_session), "Page.captureScreenshot", json!({ "format": "png", "captureBeyondViewport": true, "clip": clip }), Duration::from_secs(15))) { Ok(r) => r, Err(e) => return err("screenshot_failed", e.to_string(), None) };
let png = match r["data"].as_str().and_then(|d| pup_cdp::base64_decode(d).ok()) { Some(p) => p, None => return err("screenshot_failed", "no image data", None) };
let (buf, w, h, ow, oh, resized) = match crate::image::fit_png(&png, crate::MAX_SCREENSHOT_DIM) { Ok(x) => x, Err(e) => return err("screenshot_decode_failed", e, None) };
let path = ctx.shots_dir().join(format!("pup-el-{}.png", pup_core::now_ms()));
if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }
touch(ctx, &s.id, None);
ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "matchedBy": if sel.is_some() { "selector" } else { "text" }, "base64": pup_identity::base64_encode(&buf), "resized": resized, "sizeKB": (buf.len() / 1024) as u64, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "element": rect, "_hint": "PNG of ONE element (scrolled into view first). READ the image before asserting what it shows." }))
}
pub fn screenshot_full_res(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let full = args["fullPage"].as_bool().unwrap_or(false);
let png = match b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(25)) { Ok(p) => p, Err(e) => return err("screenshot_failed", e.to_string(), None) };
let (w, h) = pup_identity::png_size(&png).unwrap_or((0, 0));
let path = ctx.shots_dir().join(format!("pup-full-{}.png", pup_core::now_ms()));
if let Err(e) = std::fs::write(&path, &png) { return err("screenshot_write_failed", e.to_string(), None); }
ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": w, "origHeight": h, "resized": false, "savedTo": path.to_string_lossy().replace('\\', "/"), "_warning": if w > 4000 || h > 4000 { json!(format!("This is the FULL-resolution image ({w}x{h}); large images can exceed a vision model's limit. pup_screenshot returns a Claude-optimal resized copy.")) } else { Value::Null }, "sizeKB": png.len() / 1024, "fullPage": full, "resized": false, "_hint": "FULL-resolution PNG, NOT downscaled: for saving/handing to the user, not for Read (it can exceed the image limit). Use pup_screenshot for an AI-readable copy." }))
}
/// pup_download: click a real download control or an in-page a[download]; Chrome saves it.
pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
if let Ok(s0) = crate::resolve(ctx, caller, args) {
if crate::transport::is_ext(&s0) {
let Some(url) = args["url"].as_str().filter(|u| !u.is_empty()) else { return err("url_required", "pup_download on the extension lane needs {url}", Some("Read the link's address first, for example pup_eval {expr:\"document.querySelector('a.download').href\"}, then pup_download {url}. pup windows drive the DevTools protocol through pup's own extension: pup_devtools {method, params} sends any command (DOM, Runtime, Input, Page, Network...) to the window with no debug port. Do not stop, and do not move to another machine, because the CDP lane is off.")) };
let lane = match crate::transport::lane_for(ctx, &s0) { Ok(l) => l, Err(e) => return e };
let wait = args["waitMs"].as_u64().unwrap_or(30000);
return match crate::transport::download(&lane, url, args["saveAs"].as_str(), wait) {
Ok(r) => { touch(ctx, &s0.id, None); ok(json!({ "success": true, "sessionId": s0.id, "window": s0.id, "aiThread": s0.owner, "savedTo": r["savedTo"], "fileName": r["filename"], "bytes": r["bytes"], "output": json!({ "ok": true, "savedTo": r["savedTo"], "filename": r["filename"], "bytes": r["bytes"], "url": url, "transport": "ext" }) })) }
Err(e) => e,
};
}
}
if crate::resolve(ctx, caller, args).map(|s| crate::transport::is_ext(&s)).unwrap_or(false) {
return err("ext_lane_use_url", "on this window pass the download's address: pup_download {url}", Some("Get the address with pup_eval (a link's href), then pup_download {url}; recording uses pup_record_start. pup windows drive the DevTools protocol through pup's own extension: pup_devtools {method, params} sends any command (DOM, Runtime, Input, Page, Network...) to the window with no debug port. Do not stop, and do not move to another machine, because the CDP lane is off."));
}
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let (sel, url) = (args["selector"].as_str(), args["url"].as_str());
if sel.is_none() && url.is_none() { return err("args_required", "pup_download needs {selector} (a real download control to click) OR {url} (downloaded via an in-page a[download] click)", None); }
let dir = args["saveDir"].as_str().map(|d| d.to_string()).unwrap_or_else(|| ctx.host.home().join("Downloads").to_string_lossy().to_string());
let _ = std::fs::create_dir_all(&dir);
if let Err(e) = b.cdp.send(None, "Browser.setDownloadBehavior", json!({ "behavior": "allow", "downloadPath": dir, "eventsEnabled": true }), T) { return err("download_setup_failed", e.to_string(), None); }
let began = b.cdp.subscribe(None, Some("Browser.downloadWillBegin"));
let progress = b.cdp.subscribe(None, Some("Browser.downloadProgress"));
let triggered = if let Some(sel) = sel { crate::input::click(ctx, caller, &json!({ "window": s.id, "selector": sel, "takeover": true }))["ok"] == json!(true) }
else { b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const a = document.createElement('a'); a.href = {}; {} a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000); return true; }})()", serde_json::to_string(url.unwrap()).unwrap(), args["saveAs"].as_str().map(|n| format!("a.download = {};", serde_json::to_string(n).unwrap())).unwrap_or_else(|| "a.setAttribute('download', '');".into())), T).ok() == Some(json!(true)) };
if !triggered { return err("download_trigger_failed", "the download control could not be clicked", Some("Check the selector with pup_eval; the button may need a hover menu open first.")); }
// pup-bridge#109: fail FAST if Chrome never starts a download (wrong control, blocked, not a real file),
// and cap the SYNCHRONOUS wait under the caller's request window (a longer download hands off to a
// background watcher and is polled via pup_readiness.active[], instead of the caller seeing a relay timeout).
let sync_cap = Duration::from_millis(args["waitMs"].as_u64().unwrap_or(45_000).min(45_000));
let begin_deadline = Duration::from_secs(12);
let t0 = std::time::Instant::now();
let mut suggested: Option<String> = None; let mut done: Option<Value> = None; let mut began_seen = false; let mut guid = String::new(); let mut last_pct: Option<u64> = None;
loop {
if let Ok(ev) = began.try_recv() { began_seen = true; suggested = ev.params["suggestedFilename"].as_str().map(|x| x.to_string()); guid = ev.params["guid"].as_str().unwrap_or("").to_string(); }
if let Ok(ev) = progress.recv_timeout(Duration::from_millis(400)) {
began_seen = true;
let (rec, tot) = (ev.params["receivedBytes"].as_f64().unwrap_or(0.0), ev.params["totalBytes"].as_f64().unwrap_or(0.0));
last_pct = if tot > 0.0 { Some(((rec / tot) * 100.0).round() as u64) } else { None };
if guid.is_empty() { guid = ev.params["guid"].as_str().unwrap_or("").to_string(); }
if ev.params["state"] == json!("completed") { done = Some(ev.params.clone()); break; }
if ev.params["state"] == json!("canceled") { dl_done(&guid); return err("download_canceled", "the browser canceled the download", None); }
}
if !began_seen && t0.elapsed() > begin_deadline {
return err("download_did_not_start", format!("the control fired but Chrome began no download within {}s", begin_deadline.as_secs()), Some("The selector may be the wrong element, the file may be behind a bot wall (vendor sites often block pup's Chrome for Testing), or the URL is not a real file. Try nbrowser_fetch_url to pull it through the user's real Chrome, or check the control with pup_eval."));
}
if t0.elapsed() > sync_cap { break; }
}
if done.is_none() && began_seen {
// Hand a slow download off to a background watcher; register it so pup_readiness.active[] is truthful.
let id = if guid.is_empty() { format!("dl-{}", pup_core::now_ms()) } else { guid.clone() };
let fname = suggested.clone().unwrap_or_else(|| "download".into());
dl_put(json!({ "id": id, "verb": "pup_download", "phase": "download", "stepLabel": format!("Downloading {fname}"), "percent": last_pct, "sessionId": s.id, "startedMs": pup_core::now_ms(), "state": "running" }));
let (bg, dir2, id2, sess2) = (b.clone(), dir.clone(), id.clone(), s.id.clone());
std::thread::Builder::new().name("pup-download-watch".into()).spawn(move || {
let prog = bg.cdp.subscribe(None, Some("Browser.downloadProgress"));
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(600) {
if let Ok(ev) = prog.recv_timeout(Duration::from_millis(1000)) {
if ev.params["guid"].as_str().map(|g| g == id2).unwrap_or(true) {
if ev.params["state"] == json!("completed") { pup_core::log(format!("[download] \"{sess2}\" finished {id2} -> {}", dir2)); break; }
if ev.params["state"] == json!("canceled") { pup_core::log(format!("[download] \"{sess2}\" {id2} canceled")); break; }
}
}
}
dl_done(&id2);
}).ok();
return pup_core::merge(ok(json!({ "success": true, "downloading": true, "guid": id, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "saveDir": dir.replace('\\', "/") })),
json!({ "statusVerb": "pup_readiness", "_hint": format!("The download started and is still running after {}s; it is registered in pup_readiness.active[]. Poll pup_readiness until it clears, then the file is in {} (pull it with adom-bridge pull_file if you need the bytes).", sync_cap.as_secs(), dir.replace('\\', "/")) }));
}
let Some(ev) = done else { return err("download_did_not_start", format!("the control fired but Chrome began no download within {}s", begin_deadline.as_secs()), Some("The selector may be the wrong element, the file may be behind a bot wall, or the URL is not a real file. Try nbrowser_fetch_url to pull it through the user's real Chrome.")) };
if !guid.is_empty() { dl_done(&guid); }
let fname = suggested.clone().or_else(|| args["saveAs"].as_str().map(|x| x.to_string())).unwrap_or_else(|| format!("download-{}", ev["guid"].as_str().unwrap_or("file")));
let mut abs = std::path::PathBuf::from(&dir).join(&fname);
if !abs.is_file() { if let Ok(rd) = std::fs::read_dir(&dir) { if let Some(newest) = rd.flatten().filter(|e| e.path().is_file()).filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|m| (m, e.path()))).filter(|(m, _)| m.elapsed().map(|d| d < Duration::from_secs(120)).unwrap_or(false)).max_by_key(|(m, _)| *m) { abs = newest.1; } } }
let size = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);
touch(ctx, &s.id, None);
ok(json!({ "success": true, "output": json!({ "ok": true, "savedTo": abs.to_string_lossy().replace('\\', "/"), "filename": abs.file_name().map(|f| f.to_string_lossy().to_string()), "bytes": ev["totalBytes"].as_u64().or(ev["receivedBytes"].as_u64()).unwrap_or(size), "sizeOnDisk": size, "triggeredBy": sel.map(|x| format!("click {x}")).unwrap_or_else(|| format!("a[download] {}", url.unwrap_or(""))) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "savedTo": abs.to_string_lossy().replace('\\', "/"), "_hint": "Downloaded through the browser with the user's real session and cookies. Pull it to the container with adom-bridge pull_file {filePaths:[...]} if you need the bytes." }))
}
/// Every 5 min: read back what the OS says each window is stamped with (ab desktop_get_window_identity).
/// Once a day: strip the on-device model dirs and reap non-protected profiles idle >= 14 days, so a
/// box that opens many windows over months never piles up 300 stale jars + a 4 GB model each (#97).
pub fn start_profile_janitor(ctx: Arc<Ctx>) {
std::thread::Builder::new().name("profile-janitor".into()).spawn(move || {
std::thread::sleep(Duration::from_secs(120)); // let boot recovery settle first
loop {
let _t0 = std::time::Instant::now();
let _ret = ctx.retention_sweep(); // screenshots 14 d / 1 GB, recordings 30 d / 3 GB (pup-bridge#98)
let r = reap_profiles(&ctx, &json!({ "isoOnly": true }));
if let Some(o) = r.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) {
if o["removed"].as_u64().unwrap_or(0) > 0 || o["onDeviceModelBytesFreed"].as_u64().unwrap_or(0) > 0 {
pup_core::log(format!("[janitor] reaped {} stale profile(s), freed {:.1} GB of on-device model", o["removed"], o["onDeviceModelBytesFreed"].as_u64().unwrap_or(0) as f64 / 1e9));
}
}
pup_core::loops::tick("profile-janitor", 86_400_000, _t0.elapsed().as_millis() as u64);
std::thread::sleep(Duration::from_secs(86400));
}
}).ok();
}
pub fn start_identity_audit(ctx: Arc<Ctx>) {
// DETECTOR ONLY (John, 2026-09-06: "i hate sweeps"): every 5 minutes, ask the OS what identity each
// window carries and compare with what pup stamped. A mismatch is LOGGED and shown red on the
// dashboard (last_audit.ok=false) so the birth stamp gets fixed; nothing is silently re-stamped.
std::thread::Builder::new().name("identity-audit".into()).spawn(move || loop {
std::thread::sleep(Duration::from_secs(300));
let _t0 = std::time::Instant::now();
let _ltick = crate::LoopTick("identity-audit", 300_000, _t0);
let wins: Vec<(String, i64, Option<String>)> = ctx.sessions.all().into_iter().filter_map(|s| s.hwnd.map(|h| (s.id.clone(), h, s.app_id.clone()))).collect();
if wins.is_empty() { continue; }
let caller = crate::identity::internal_caller();
let Ok(r) = ctx.ab.call("desktop_get_window_identity", json!({ "hwnds": wins.iter().map(|w| w.1).collect::<Vec<_>>() }), &caller, Duration::from_secs(10)) else { continue };
// 2.0.669: ab answers {output:"{..., data:{windows:[...]}}"}; reading only the top level found no rows, so
// every window was reported "the OS says None" every 5 minutes while Windows held the right identity
// (AdomLapper, 2026-09-21). Accept every shape, and treat a reply with no rows as no reading at all.
let body = match &r["output"] { Value::String(t) => serde_json::from_str::<Value>(t).unwrap_or(Value::Null), v if v.is_object() => v.clone(), _ => r.clone() };
let pick = |v: &Value| v["rows"].as_array().or(v["windows"].as_array()).or(v["data"]["windows"].as_array()).or(v["data"]["rows"].as_array()).cloned();
let rows = pick(&body).or_else(|| pick(&r)).unwrap_or_default();
if rows.is_empty() { pup_core::log("[identity-audit] ab returned no rows; no reading this tick (not a mismatch)"); continue; }
let mut mismatched = Vec::new();
for (sid, hwnd, want) in wins {
let actual = rows.iter().find(|x| x["hwnd"].as_i64() == Some(hwnd)).and_then(|x| x["appId"].as_str().map(|s| s.to_string()));
if want.is_some() && actual != want { pup_core::log(format!("[identity-audit] \"{sid}\" stamped {:?} but the OS says {:?} — NOT re-stamped (detector only); the birth stamp is the thing to fix", want, actual)); mismatched.push(json!({ "sessionId": sid, "stamped": want, "os": actual })); }
}
ctx.last_audit.lock().unwrap().replace(json!({ "at": pup_core::now_ms(), "checked": rows.len(), "repaired": 0, "mismatched": mismatched, "ok": mismatched.is_empty() }));
}).ok();
}
pub fn launch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut a = args.clone(); if a.get("url").is_none() { a["url"] = json!("about:blank"); } crate::open_window(ctx, caller, &a) }
pub fn deps(_ctx: &Ctx) -> Value { ok(json!({ "success": true, "output": json!({ "ok": true, "depsPresent": true, "selfInstalledThisBoot": false, "lastError": Value::Null, "dir": Value::Null, "shell": "rust", "deps": "none: this build is a single exe (no Node, no npm, no native modules)" }).to_string(), "_hint": "There is nothing to install: the Rust pup exe carries everything but Chrome for Testing, which pup_readiness / pup_prewarm manage." })) }
pub fn install_toolbar(ctx: &Ctx) -> Value {
let dirs = pup_browser::stage_extensions(&*ctx.host, ctx.port, "adom-you", true);
let driving = pup_browser::read_default(&*ctx.host).and_then(|d| d.get("kind").and_then(|k| k.as_str()).map(|s| s.to_string())).unwrap_or_else(|| "chrome-for-testing".into());
ok(json!({ "success": true, "staged": dirs.iter().map(|d| d.to_string_lossy().replace('\\', "/")).collect::<Vec<_>>(), "output": json!({ "ok": true, "mode": "launch-flag", "stagedAt": dirs.first().map(|d| d.to_string_lossy().replace('\\', "/")), "driving": driving, "active": false }).to_string(), "_hint": "The Adom toolbar extensions are staged and ride every Chrome for Testing launch (--load-extension, pinned). A warm Chrome never reloads them: pup_quit_idle_browsers then reopen the window to pick up new extension code." }))
}
pub const PROTECTED_PROFILES: [&str; 4] = ["adom-you", "adom-wiki-authed", "pup-dashboard", "_recorder"];
/// The Chrome on-device AI model (OptGuideOnDeviceModel/.../weights.bin) is ~4 GB and pup never uses
/// it. New launches carry --disable-features=OptimizationGuideModelDownloading so it never re-downloads;
/// this strips any copy an older launch already wrote, inside every profile. Returns bytes freed.
pub fn strip_ondevice_model(root: &std::path::Path) -> u64 {
let mut freed = 0u64;
if let Ok(rd) = std::fs::read_dir(root) { for e in rd.flatten() {
let d = e.path().join("OptGuideOnDeviceModel");
if d.is_dir() { freed += dir_size(&d); let _ = std::fs::remove_dir_all(&d); }
} }
freed
}
fn dir_size(p: &std::path::Path) -> u64 {
let mut n = 0; if let Ok(rd) = std::fs::read_dir(p) { for e in rd.flatten() { match e.metadata() { Ok(m) if m.is_dir() => n += dir_size(&e.path()), Ok(m) => n += m.len(), _ => {} } } } n
}
pub fn reap_profiles(ctx: &Ctx, args: &Value) -> Value {
let root = pup_browser::profiles_dir(&*ctx.host);
let live: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();
let dry = args["dryRun"].as_bool() == Some(true);
let all = args["all"].as_bool() == Some(true);
let iso_only = args["isoOnly"].as_bool() == Some(true);
let stale_days = args["staleDays"].as_u64().unwrap_or(14);
let stale = std::time::Duration::from_secs(stale_days * 86400);
let (mut removed, mut spared, mut fresh, mut held) = (Vec::new(), 0u64, 0u64, 0u64);
// profiles a session file still names must never be reaped (they can be recovered)
let named: std::collections::HashSet<String> = ctx.sessions.read_files().iter().filter_map(|f| f["profile"].as_str().map(|s| s.to_string())).chain(ctx.sessions.all().iter().map(|s| s.profile.clone())).collect();
if let Ok(rd) = std::fs::read_dir(&root) { for e in rd.flatten() {
if !e.path().is_dir() { continue; }
let name = e.file_name().to_string_lossy().to_string();
if PROTECTED_PROFILES.contains(&name.as_str()) || live.contains(&name) || named.contains(&name) { spared += 1; continue; }
if pup_browser::existing_port(&e.path()).is_some() { held += 1; continue; }
let idle_ok = e.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).map(|el| el >= stale).unwrap_or(false);
// iso-* leaked jars go once idle (always safe). A NAMED durable profile is removed ONLY on an
// explicit call (isoOnly=false) — never by the auto janitor, so a profile the user deliberately
// named and its logins are never deleted behind their back (John #97).
let should = if iso_only { name.starts_with("iso-") && idle_ok } else { all || idle_ok };
if !should { fresh += 1; continue; }
if dry { removed.push(name); } else if std::fs::remove_dir_all(e.path()).is_ok() { removed.push(name); }
} }
let freed = if dry { 0 } else { strip_ondevice_model(&root) };
ok(json!({ "success": true, "output": json!({ "ok": true, "dryRun": dry, "removed": if dry { 0 } else { removed.len() }, "wouldRemove": if dry { removed.len() } else { 0 }, "sampleNames": removed.iter().take(12).collect::<Vec<_>>(), "spared": spared, "tooFresh": fresh, "inUse": held, "onDeviceModelBytesFreed": freed, "staleDays": stale_days,
"_hint": format!("{} profile(s) {} ({}). Protected: {}. On-device AI model dirs (~4 GB each) stripped from remaining profiles, freeing {:.1} GB; new launches disable its download (issue #97).", removed.len(), if dry { "would be removed" } else { "removed" }, if iso_only { "leaked iso-* jars only — the auto janitor NEVER deletes a named durable profile".to_string() } else { format!("idle >= {stale_days}d, not protected/live/recovered{}", if all { ", all:true forced non-idle too" } else { "" }) }, PROTECTED_PROFILES.join(", "), freed as f64 / 1e9) }).to_string() }))
}
pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {
if args["confirm"].as_bool() != Some(true) { return err("confirm_required", "pup_uninstall REMOVES pup's footprint from this machine (profiles, sessions, staged extensions, icons). Pass confirm:true.", None); }
let mut removed = Vec::new();
for d in [pup_browser::profiles_dir(&*ctx.host), pup_browser::sessions_dir(&*ctx.host), ctx.host.temp_dir().join("pup-thread-tiles"), ctx.host.temp_dir().join("pup-icons"), ctx.host.temp_dir().join("pup-badge-debug")] { if std::fs::remove_dir_all(&d).is_ok() { removed.push(d.to_string_lossy().replace('\\', "/")); } }
for (_, dst, _) in pup_browser::ext_assets::EXTENSIONS { let d = ctx.host.state_dir().join(dst); if std::fs::remove_dir_all(&d).is_ok() { removed.push(d.to_string_lossy().replace('\\', "/")); } }
for s in ctx.sessions.all() { if let Some(a) = &s.app_id { let _ = ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": a, "anchorFolder": crate::identity::ANCHOR_FOLDER }), &crate::identity::internal_caller(), Duration::from_secs(5)); } }
ok(json!({ "success": true, "removed": removed, "_hint": "pup's on-disk footprint is gone (the vault in the OS keychain is kept unless credential_delete is called per host). ab's bridge_uninstall removes the bridge itself." }))
}
pub fn move_to_workspace(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
let url = s.active().map(|t| t.url.clone()).unwrap_or_default();
if url.is_empty() || url.starts_with("about:") || url.starts_with("chrome:") || url.starts_with("edge:") || url.starts_with("devtools:") {
return err("no_movable_url", format!("This window's active tab has no movable URL ({}).", if url.is_empty() { "none" } else { &url }), None);
}
// Hand the URL to Hydrogen; its Browser Picker routes it (webview by default, honoring the user's per-domain choice). Node: hd_api POST /open-url.
let hd = ctx.ab.call("hd_api", json!({ "method": "POST", "path": "/open-url", "body": { "url": url } }), caller, std::time::Duration::from_secs(12));
let ok_hd = hd.as_ref().map(|v| v.get("success") != Some(&json!(false)) && v["body"]["error"].is_null()).unwrap_or(false);
if !ok_hd {
let why = hd.as_ref().ok().and_then(|v| v["body"]["error"].as_str().or(v["error"].as_str())).map(|s| s.to_string()).or_else(|| hd.as_ref().err().map(|e| e.to_string())).unwrap_or_else(|| "unknown error".into());
return err("hydrogen_unreachable", format!("Hydrogen did not accept the URL: {why}"), Some("Moving a pup page to a workspace needs Hydrogen (ah) running on this desktop. The pup window was left open."));
}
let mut closed = false;
if args.get("keepWindow").and_then(|b| b.as_bool()) != Some(true) {
let r = crate::close_window(ctx, caller, &json!({ "window": s.id, "takeover": true }));
closed = r.get("ok") != Some(&json!(false));
}
pup_core::log(format!("[move-to-workspace] \"{}\" -> Hydrogen ({}) — windowClosed={closed}", s.id, url.chars().take(80).collect::<String>()));
ok(json!({ "success": true, "output": json!({ "ok": true, "movedUrl": url, "windowClosed": closed, "sessionId": s.id,
"_hint": if closed { "The page was handed to Hydrogen (its Browser Picker routes it — webview by default) and this pup window closed. The user picks the surface if the picker prompts." } else { "The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true)." } }).to_string() }))
}
/// pup_restart_browser {profile}: the remedy for a Chrome whose BROWSER process has wedged (its main thread
/// spinning, so the wheel and the tab strip stop responding while a scrollbar still drags). Every window's
/// tabs are written to its session file first, the browser process is ended, and recovery brings the windows
/// and ALL their tabs back. Added 2.0.461 after John lost an afternoon to exactly this, with me killing the
/// process by hand and retyping 20 tab URLs out of a log.
pub fn restart_browser(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you").to_string();
crate::recovery::mark_browser_restarted(&profile); // 2.0.717: windows Chrome brings back itself get the media guard too
let sessions = ctx.sessions.for_profile(&profile);
if sessions.is_empty() && crate::browser_for(ctx, &profile).is_none() {
return err("no_browser", format!("no browser is running for profile \"{profile}\""), Some("pup_status lists the live profiles."));
}
// 2.0.472: let any in-flight relaunch finish first. Two restarts back to back (or the wedge watchdog firing
// while a restore runs) otherwise killed a window whose tabs the previous restore was still reopening, and the
// freshly relaunched window then adopted the stale tabs (triple-restart drill: "back with 6 of 3", 0 live).
crate::recovery::wait_for_relaunches_to_drain(Duration::from_secs(60));
let sessions = ctx.sessions.for_profile(&profile);
let mut saved = Vec::new();
for s in &sessions { ctx.sessions.save_file(s); saved.push(json!({ "window": s.id, "tabs": s.tabs.len(), "owner": s.owner })); }
let pid = crate::browser_for(ctx, &profile).map(|b| b.launched.pid).unwrap_or(0);
let tabs_total: usize = sessions.iter().map(|s| s.tabs.len()).sum();
pup_core::log(format!("[restart] pup_restart_browser: profile \"{profile}\" pid {pid}, {} window(s) and {tabs_total} tab(s) saved; ending the browser so recovery can bring them back (asked by {})", sessions.len(), caller.thread));
let killed = if pid > 0 { ctx.host.kill(pid) } else { false };
ctx.browsers.lock().unwrap().remove(&profile);
// 2.0.464: WAIT for the profile to be free before returning. Chrome refuses to start a second instance on
// a locked user-data-dir and exits during launch, so an immediate next open failed with
// "Target.createTarget: Failed to open" or fell through to system Chrome (measured while surveying
// component pages, 2026-09-11: four opens in a row broken after a restart).
let dir = pup_browser::profiles_dir(&*ctx.host).join(&profile);
let port_file = dir.join("DevToolsActivePort");
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(12) {
let dead = pid == 0 || !ctx.host.alive(pid);
if dead && !port_file.exists() { break; }
if dead && port_file.exists() { let _ = std::fs::remove_file(&port_file); }
std::thread::sleep(Duration::from_millis(250));
}
let waited = t0.elapsed().as_millis() as u64;
let _ = std::fs::remove_file(&port_file);
pup_core::log(format!("[restart] profile \"{profile}\" released after {waited} ms (pid alive: {})", pid > 0 && ctx.host.alive(pid)));
// 2.0.686: the EXTENSION lane has no CDP browser handle (pid 0 above), so this verb ended nothing on it, and the
// next open then force-killed the still-running browser as "stale" and started another one second later: John
// got Chrome's "Something went wrong when opening your profile" (AdomLapper, 2026-10-03, asked by buck-repro).
// End the lane browser here, after reserving its windows, and wait until no process holds the profile.
if sessions.iter().any(|s| crate::transport::is_ext(s)) {
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
crate::recovery::reserve(&ids); crate::recovery::mark_lost(&ids);
crate::extdrive::enter_profile(&profile);
let n = crate::kill_extdrive(ctx);
crate::extdrive::leave_profile();
pup_core::log(format!("[restart] profile \"{profile}\": ended its extension-lane browser ({n} process(es)) and waited for the profile to be free"));
}
// 2.0.465: bring the windows back HERE, synchronously. Removing the browser handle above also removed the
// only thing the health loop watches, so the sessions sat in memory pointing at a dead browser and nothing
// relaunched them: pup_list_windows still listed them with their tabs, and every verb on them answered
// "the browser behind this window is not connected" (arav-rog drill, 2026-09-11). Each session is dropped
// from memory (its file stays, with every tab) and relaunched from that file before the verb returns.
// 2.0.466: the relaunch runs on a helper thread and the verb returns at once. Done inline (2.0.465) it took
// longer than ab's request window with two windows of three tabs, so the caller saw a timeout while the work
// completed (arav-rog drill). Each session is dropped from memory (its file stays, with every tab) and
// relaunched from that file; pup_list_windows shows the windows as they come back.
let files: Vec<(String, usize, Value)> = sessions.iter().map(|s| (s.id.clone(), s.tabs.len(), s.file_json())).collect();
// 2.0.468: reserve every id BEFORE dropping it from memory, so the health tick never re-attaches or relaunches
// one of these files while the restore thread is still working on an earlier window.
crate::recovery::reserve(&files.iter().map(|(id, _, _)| id.clone()).collect::<Vec<_>>());
crate::recovery::mark_lost(&files.iter().map(|(id, _, _)| id.clone()).collect::<Vec<_>>()); // 2.0.682: pup restarts this browser itself
for (id, _, _) in &files { ctx.sessions.remove(id); }
let restoring: Vec<Value> = files.iter().map(|(id, n, _)| json!({ "window": id, "tabs": n })).collect();
if let Some(a) = ctx.arc() {
std::thread::Builder::new().name("restart-restore".into()).spawn(move || {
for (id, n, f) in files {
crate::recovery::relaunch_from_file(&a, &f);
let back = a.sessions.get(&id).map(|x| x.tabs.len()).unwrap_or(0);
pup_core::log(format!("[restart] \"{id}\" back with {back} of {n} tab(s)"));
}
}).ok();
}
ok(json!({ "profile": profile, "pid": pid, "killed": killed, "windowsSaved": saved, "tabsSaved": tabs_total, "profileReleasedMs": waited, "restoring": restoring,
"_hint": if killed { format!("Browser ended. {} window(s) with {tabs_total} tab(s) are being relaunched from their session files right now (about 8 s per window); poll pup_list_windows until the tab counts match `restoring`, then verbs on them work.", restoring.len()) } else { "Could not end the browser process; end it from Task Manager and pup will recover the windows and their tabs the same way.".to_string() } }))
}
/// 2.0.677 (pup-bridge#125): the browser download cache (~/.cache/puppeteer) only ever grew: every build bump
/// kept the one it replaced (~410 MB each), and the Node era left chrome-headless-shell builds and `*-dl`
/// download folders the Rust pup never uses (1.63 GB on arav-rog, 2026-09-22). Kept: the pinned build, the newest
/// complete build when the pinned one is missing (it is what drives an offline box), and any build a running
/// process or the saved default browser points into. Everything else goes. Runs a minute after boot and on demand.
pub fn cache_prune(ctx: &Ctx, dry: bool) -> Value {
fn size_of(p: &std::path::Path) -> u64 {
let mut n = 0u64;
if let Ok(rd) = std::fs::read_dir(p) { for e in rd.flatten() { let q = e.path(); if q.is_dir() { n += size_of(&q); } else if let Ok(m) = e.metadata() { n += m.len(); } } }
n
}
// 2.0.680: never prune while a browser install is in flight, and never touch a directory written in the last
// 5 minutes. The installer unpacks into "<build>.partial" and renames at the end; on a fresh box the boot prune
// (60 s) would have deleted that folder mid-unpack on any connection slower than the test VM's (measured there:
// download+unpack finished at 20 s, so the race was invisible).
if !dry && ctx.installer.installing() {
pup_core::log("[cache] prune skipped: a browser install is in flight");
return ok(json!({ "success": true, "skipped": "install_in_flight", "removed": [], "freedMB": 0, "_hint": "A browser download is unpacking right now; pruning waits for it. pup retries at the next boot, or call this again when pup_readiness says installing:false." }));
}
let root = ctx.host.cft_cache_dir();
let norm = |p: &std::path::Path| p.to_string_lossy().replace('\\', "/").to_lowercase();
let fresh = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).map(|t| t.elapsed().map(|e| e.as_secs() < 300).unwrap_or(false)).unwrap_or(false);
// what must survive: running browser executables and the saved default
// 2026-10-13: the running-browser query is the host's (the PowerShell form answered nothing on macOS and the
// pruner removed every build but the one it had just installed, the Node pup's included).
let mut keep_paths: Vec<String> = ctx.host.running_browser_executables().iter().map(|p| p.to_string_lossy().replace('\\', "/").to_lowercase()).collect();
if let Some(d) = pup_browser::read_default(&*ctx.host) { if let Some(p) = d.get("executablePath").and_then(|v| v.as_str()) { keep_paths.push(p.replace('\\', "/").to_lowercase()); } }
let pinned = pup_browser::cft_versioned_dir(&*ctx.host, pup_browser::CFT_BUILD_ID);
let pinned_ok = pup_browser::cft_exe_path(&*ctx.host, pup_browser::CFT_BUILD_ID).is_file();
let fallback = if pinned_ok { None } else { pup_browser::detect_cft(&*ctx.host).and_then(|c| c.executable_path.ancestors().find(|a| a.parent().map(|pp| pp.ends_with("chrome")).unwrap_or(false)).map(|a| a.to_path_buf())) };
let newest = pup_browser::detect_cft(&*ctx.host).and_then(|c| c.executable_path.ancestors().find(|a| a.parent().map(|pp| pp.ends_with("chrome")).unwrap_or(false)).map(|a| a.to_path_buf()));
let mut removed = Vec::new(); let mut kept = Vec::new(); let mut freed = 0u64; let mut failed = Vec::new();
let Ok(top) = std::fs::read_dir(&root) else { return ok(json!({ "success": true, "cacheDir": norm(&root), "removed": [], "freedMB": 0, "_hint": "No browser download cache on this box." })) };
for product in top.flatten() {
let pname = product.file_name().to_string_lossy().to_string();
let ppath = product.path();
if !ppath.is_dir() { continue; }
let builds: Vec<std::path::PathBuf> = if pname == "chrome" { std::fs::read_dir(&ppath).map(|r| r.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect()).unwrap_or_default() } else { vec![ppath.clone()] };
for b in builds {
let bn = norm(&b);
let in_use = keep_paths.iter().any(|k| k.starts_with(&(bn.clone() + "/")));
// 2.0.734: keep the build pup launches (the newest complete one), not a pinned build it has moved past
let is_keep = pname == "chrome" && (newest.as_ref() == Some(&b) || (b == pinned && pinned_ok && newest.is_none()) || fallback.as_ref() == Some(&b));
let label = b.strip_prefix(&root).map(|x| x.to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| bn.clone());
if fresh(&b) { kept.push(json!({ "path": label, "why": "written in the last 5 minutes (an install may be unpacking into it)" })); continue; }
if is_keep || in_use { kept.push(json!({ "path": label, "why": if in_use { "a running process or the default browser uses it" } else if b == pinned { "the pinned build" } else { "the newest complete build (the pinned one is missing)" } })); continue; }
let sz = size_of(&b);
if dry { removed.push(json!({ "path": label, "mb": sz / 1_048_576 })); freed += sz; continue; }
match std::fs::remove_dir_all(&b) { Ok(_) => { removed.push(json!({ "path": label, "mb": sz / 1_048_576 })); freed += sz; } Err(e) => failed.push(json!({ "path": label, "error": e.to_string() })) }
}
if pname != "chrome" { continue; }
}
let mb = freed / 1_048_576;
if !dry && (!removed.is_empty() || !failed.is_empty()) { pup_core::log(format!("[cache] pruned the browser download cache: {} entr{} removed, {mb} MB freed{}", removed.len(), if removed.len() == 1 { "y" } else { "ies" }, if failed.is_empty() { String::new() } else { format!(", {} could not be removed", failed.len()) })); }
ok(json!({ "success": true, "dryRun": dry, "cacheDir": norm(&root), "removed": removed, "kept": kept, "failed": failed, "freedMB": mb,
"_hint": format!("{} {} MB of superseded browser builds and leftover downloads{}. Kept the build pup drives and anything in use. pup runs this a minute after every boot (2.0.677).", if dry { "Would free" } else { "Freed" }, mb, if dry { " (dry run: pass dryRun:false to delete)" } else { "" }) }))
}