← Commit history
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")); }