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
Update 11 file(s)
8 files changed
+139−18
CHANGELOG.md+19@@ -1,5 +1,24 @@ # Changelog — Puppeteer Bridge +## 2.0.420 — 2026-09-06 — runtime data under ADOM_BRIDGE_DATA_DIR with retention; jump-list anchor mapped to ab 2.1.49++pup-bridge#98 / adom-bridge#153: profiles, sessions, screenshots and recordings now resolve under the+`ADOM_BRIDGE_DATA_DIR` ab passes every bridge (`%LOCALAPPDATA%\Adom Bridge\bridge-data\puppeteer`),+so runtime data no longer sits beside the code and survives cache swaps and version bumps. The first+boot with the variable set MOVES the legacy dirs over (`~/.adom/pup-profiles`, `~/.adom/pup-sessions`,+the exe-side `screenshots/` and `recordings/`), so the adom-you logins carry; a failed move keeps the+legacy path. Without the variable every path stays where it was. Retention: the daily janitor now also+sweeps screenshots (14 days / 1 GB) and recordings (30 days / 3 GB), oldest first, never a file touched+in the last 10 minutes; pup_status reports `dataDirs` and `retention`.++ab#155 (closed by ab, measured): the first-right-click jump-list flash is cured only by a VISIBLE Start+Menu shortcut carrying the AUMID, which ab 2.1.49 offers as opt-in `anchor:true`. `aumidShortcut` now+maps onto it (`anchor:true, anchorFolder:"Adom Pup"`; unregister passes the same folder) instead of the+old exe-targeted `shortcut:true`. Default stays off: clean Start Menu, the first right-click flashes and+the second stays. Also: the demo driver take-2 fixes (mouse parked, background birth then a real click+on the taskbar button, recorder HUD minimized for the closing line) and pup-bridge#99 (Windows 11 25H2+paints no taskbar progress for any app; the taskbar skills say so).+ ## 2.0.419 — 2026-09-05 — the pup demo tour, consent toast metadata, honest recorder routing The dashboard "Play the demo" prompt is now the eight-beat pup tour John asked for (window, taskbar
rust/bridge/src/main.rs+1−1@@ -40,7 +40,7 @@ fn main() { 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()), tabstate: Ctx::new_tabstate(),+ sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(), bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()), vault: pup_vault::Vault::new(&state_dir), cards: pup_vault::Cards::new(&state_dir), consent: pup_verbs::consent::Registry::new(), tab_recs: Default::default(), screen_rec: Default::default(), highfps: Default::default(), last_audit: Mutex::new(None), });
rust/browser/src/lib.rs+11−1@@ -375,7 +375,17 @@ pub fn stage_extensions(host: &dyn Host, port: u16, profile: &str, toolbar_on: b ["pup-annotate-extension", "pup-extension", identity].iter().filter_map(|k| staged.get(k).cloned()).collect() } -pub fn profiles_dir(host: &dyn Host) -> PathBuf { host.state_dir().join("pup-profiles") }+/// Profiles: `<ADOM_BRIDGE_DATA_DIR>/profiles` when ab passes the data dir (pup-bridge#98), else the+/// legacy `~/.adom/pup-profiles`; the legacy dir is moved over once so logins carry (resolved once).+pub fn profiles_dir(host: &dyn Host) -> PathBuf {+ static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();+ DIR.get_or_init(|| pup_core::data_subdir("profiles", host.state_dir().join("pup-profiles"))).clone()+}+/// Session files, same rule (`<data>/sessions`, legacy `~/.adom/pup-sessions`).+pub fn sessions_dir(host: &dyn Host) -> PathBuf {+ static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();+ DIR.get_or_init(|| pup_core::data_subdir("sessions", host.state_dir().join("pup-sessions"))).clone()+} /// 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, extensions: &[PathBuf]) -> Vec<String> {
rust/core/src/lib.rs+77@@ -85,6 +85,9 @@ pub enum AbError { pub trait AbClient: Send + Sync { fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError>;+ /// Like `call`, but routed to another desktop of the same account (ab cross-desktop egress). The+ /// default ignores `target` (single-box / NoAb). Used by pup_vault_sync to reach the peer's pup.+ fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> { let _ = target; self.call(verb, args, caller, timeout) } /// The ab version this bridge is talking to, if known (bridge_info). fn version(&self) -> Option<String> { None } }@@ -144,6 +147,20 @@ impl AbClient for Loopback { } Ok(v) }+ fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {+ // The direct API reads a top-level `target` and egresses to that peer's ab over the relay/mesh.+ let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(2))).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.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }+ let resp = req.send_json(json!({ "command": verb, "args": args, "target": target })).map_err(|e| AbError::Unreachable(e.to_string()))?;+ let v = unwrap_output(resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?);+ if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {+ return Err(AbError::Refused { verb: verb.into(), code: v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string(), message: v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string() });+ }+ Ok(v)+ } } /// No ab at all (hand-launched exe): every desktop verb reports unreachable, nothing panics.@@ -257,3 +274,63 @@ mod tests { } #[test] fn iso() { assert!(iso_now().ends_with("Z")); assert_eq!(iso_now().len(), 24); } }+++// ── Runtime data dir (pup-bridge#98 / adom-bridge#153) ──────────────────────────────────────────+/// ab passes every bridge `ADOM_BRIDGE_DATA_DIR` (%LOCALAPPDATA%\Adom Bridge\bridge-data\<bridge>): a+/// dir that survives cache swaps and version bumps. Runtime data (profiles, sessions, screenshots,+/// recordings) lives there when it is set; without it (an older ab, a hand-launched exe) every+/// resolver falls back to its legacy location beside the code / under ~/.adom.+pub fn data_dir() -> Option<std::path::PathBuf> {+ let v = std::env::var("ADOM_BRIDGE_DATA_DIR").ok()?.trim().to_string();+ if v.is_empty() { return None; }+ let p = std::path::PathBuf::from(v);+ std::fs::create_dir_all(&p).ok()?;+ Some(p)+}++/// `<data>/<kind>` when the data dir is set, else `legacy`. The first time the new dir is resolved and+/// the legacy one exists, the legacy dir is MOVED (rename, same volume) so the user's logged-in+/// profiles and session files carry over; a failed rename keeps the legacy path (never two copies).+pub fn data_subdir(kind: &str, legacy: std::path::PathBuf) -> std::path::PathBuf {+ let Some(root) = data_dir() else { return legacy };+ let new = root.join(kind);+ if !new.exists() && legacy.exists() {+ match std::fs::rename(&legacy, &new) {+ Ok(()) => log(format!("[data] moved {} -> {} (ADOM_BRIDGE_DATA_DIR, pup-bridge#98)", legacy.display(), new.display())),+ Err(e) => { log(format!("[data] could not move {} -> {}: {e}; staying on the legacy path", legacy.display(), new.display())); return legacy; }+ }+ }+ let _ = std::fs::create_dir_all(&new);+ new+}++/// Retention for ephemeral output dirs (screenshots, recordings): delete files older than `max_age_days`,+/// then oldest-first until the dir is under `max_bytes`. Files touched in the last 10 minutes are never+/// removed (a recording still being written). Returns (files removed, bytes freed).+pub fn retention_sweep(dir: &std::path::Path, max_age_days: u64, max_bytes: u64) -> (usize, u64) {+ fn walk(d: &std::path::Path, out: &mut Vec<(std::path::PathBuf, u64, std::time::SystemTime)>) {+ let Ok(rd) = std::fs::read_dir(d) else { return };+ for e in rd.flatten() {+ let p = e.path();+ let Ok(md) = e.metadata() else { continue };+ if md.is_dir() { walk(&p, out); } else { out.push((p, md.len(), md.modified().unwrap_or(std::time::UNIX_EPOCH))); }+ }+ }+ let mut files = Vec::new(); walk(dir, &mut files);+ files.sort_by_key(|f| f.2);+ let now = std::time::SystemTime::now();+ let mut total: u64 = files.iter().map(|f| f.1).sum();+ let (mut removed, mut freed) = (0usize, 0u64);+ let recent = std::time::Duration::from_secs(600);+ for (p, len, m) in &files {+ let age = now.duration_since(*m).unwrap_or_default();+ if age < recent { continue; }+ let too_old = age.as_secs() > max_age_days * 86_400;+ if !too_old && total <= max_bytes { break; }+ if std::fs::remove_file(p).is_ok() { removed += 1; freed += len; total = total.saturating_sub(*len); }+ }+ // drop now-empty frame dirs left behind by tab recordings+ if let Ok(rd) = std::fs::read_dir(dir) { for e in rd.flatten() { if e.path().is_dir() { let _ = std::fs::remove_dir(e.path()); } } }+ (removed, freed)+}
rust/verbs/src/extras.rs+3−2@@ -85,6 +85,7 @@ pub fn start_profile_janitor(ctx: Arc<Ctx>) { 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 {@@ -175,9 +176,9 @@ pub fn reap_profiles(ctx: &Ctx, args: &Value) -> Value { 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), ctx.host.state_dir().join("pup-sessions"), 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 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 }), &crate::identity::internal_caller(), Duration::from_secs(5)); } }+ 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." })) }
rust/verbs/src/identity.rs+9−11@@ -24,18 +24,16 @@ fn local_app_data(ctx: &Ctx) -> PathBuf { std::env::var("LOCALAPPDATA").map(Path fn icons_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-icons").join(id::ICON_GEN) } fn tiles_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-thread-tiles").join(id::TILE_GEN) } -/// Boot: icons on disk, the jump-list launcher, the port-discovery file, the base AUMID.-/// The register_app_identity args. Registry-only by default (no Start Menu clutter). With the-/// `aumidShortcut` setting on, ab also writes the Start Menu .lnk carrying the AUMID: the anchor-/// Windows reads to resolve a custom jump list on the FIRST right-click (ab#155: without it the-/// menu paints, re-resolves and dismisses, and only the second click sticks).+/// The Start Menu anchor for the jump list (ab#155, measured by ab on 2026-09-05): a registry-only+/// AUMID makes the first right-click flyout flash; only a VISIBLE Start Menu shortcut carrying the AUMID+/// keeps it up, and there is no invisible anchor (hidden .lnk, hidden folder and SHAddToRecentDocs all+/// measured not to work). ab 2.1.49 makes that anchor opt-in (`anchor:true`, one collapsed Start Menu+/// folder). pup maps its `aumidShortcut` setting onto it: off (default) = clean Start Menu, first+/// right-click flashes; on = first-click flyout plus one "Adom Pup" folder entry per identity.+pub const ANCHOR_FOLDER: &str = "Adom Pup"; fn register_args(ctx: &Ctx, app_id: &str, display: &str, icon: &str) -> Value {- if ctx.settings.get("aumidShortcut", "off") == "on" {- let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();- json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": true, "target": exe, "shortcutName": display })- } else {- json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": false })- }+ let anchor = ctx.settings.get("aumidShortcut", "off") == "on";+ json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": false, "anchor": anchor, "anchorFolder": ANCHOR_FOLDER }) } pub fn boot(ctx: &Ctx) {
rust/verbs/src/lib.rs+18−2@@ -136,11 +136,27 @@ pub struct Ctx { pub last_audit: Mutex<Option<Value>>, } +pub const SHOT_KEEP_DAYS: u64 = 14; pub const SHOT_KEEP_BYTES: u64 = 1_000_000_000;+pub const REC_KEEP_DAYS: u64 = 30; pub const REC_KEEP_BYTES: u64 = 3_000_000_000;+ impl Ctx { pub fn arc(&self) -> Option<Arc<Ctx>> { self.self_ref.lock().unwrap().upgrade() } pub fn new_tabstate() -> events::TabStates { Arc::new(Mutex::new(HashMap::new())) } 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 }+ /// Screenshots: `<ADOM_BRIDGE_DATA_DIR>/screenshots` when ab passes the data dir (pup-bridge#98), else+ /// beside the exe as before. Retention: see `retention_sweep` (janitor).+ pub fn shots_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("screenshots", self.bridge_dir.join("screenshots"))).clone(); let _ = std::fs::create_dir_all(&d); d }+ pub fn recordings_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("recordings", self.bridge_dir.join("recordings"))).clone(); let _ = std::fs::create_dir_all(&d); d }+ /// One retention pass over screenshots (14 d / 1 GB) and recordings (30 d / 3 GB); returns the report.+ pub fn retention_sweep(&self) -> Value {+ let (sn, sb) = pup_core::retention_sweep(&self.shots_dir(), SHOT_KEEP_DAYS, SHOT_KEEP_BYTES);+ let (rn, rb) = pup_core::retention_sweep(&self.recordings_dir(), REC_KEEP_DAYS, REC_KEEP_BYTES);+ if sn + rn > 0 { pup_core::log(format!("[retention] screenshots: {sn} removed ({:.1} MB); recordings: {rn} removed ({:.1} MB)", sb as f64 / 1e6, rb as f64 / 1e6)); }+ json!({ "screenshots": { "removed": sn, "freedBytes": sb, "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "removed": rn, "freedBytes": rb, "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES }, "ts": pup_core::now_ms() })+ }+ pub fn data_dirs(&self) -> Value {+ json!({ "dataDir": pup_core::data_dir().map(|p| p.to_string_lossy().replace('\\', "/")), "profiles": pup_browser::profiles_dir(&*self.host).to_string_lossy().replace('\\', "/"), "sessions": pup_browser::sessions_dir(&*self.host).to_string_lossy().replace('\\', "/"), "screenshots": self.shots_dir().to_string_lossy().replace('\\', "/"), "recordings": self.recordings_dir().to_string_lossy().replace('\\', "/") })+ } } /// The walls only a human can pass after a login submit (Node: detectHumanChallenge).@@ -557,7 +573,7 @@ pub fn session_row(ctx: &Ctx, s: &Session) -> Value { 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(),+ "version": pup_core::version(), "shell": "rust", "uptimeSec": ctx.started.elapsed().as_secs(), "dataDirs": ctx.data_dirs(), "retention": { "screenshots": { "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES } }, "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": pup_core::loops::report(), "pupshotUrl": Value::Null, "iconAudit": ctx.last_audit.lock().unwrap().clone(),
rust/verbs/src/record.rs+1−1@@ -18,7 +18,7 @@ use std::time::{Duration, Instant}; pub struct TabRec { pub id: String, pub session: String, pub tab_id: String, pub cdp_session: String, pub frames_dir: PathBuf, pub file: PathBuf, pub fps: u32, pub started: Instant, pub frames: u32, pub stamps: Vec<f64>, pub sizes: Vec<(u32, u32)> } pub type TabRecs = Arc<Mutex<HashMap<String, TabRec>>>; // by session id -pub fn recordings_dir(ctx: &Ctx) -> PathBuf { let d = ctx.bridge_dir.join("recordings"); let _ = std::fs::create_dir_all(&d); d }+pub fn recordings_dir(ctx: &Ctx) -> PathBuf { ctx.recordings_dir() } pub fn ffmpeg_path() -> Option<PathBuf> { if std::process::Command::new("ffmpeg").arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().map(|s| s.success()).unwrap_or(false) { return Some(PathBuf::from("ffmpeg")); }