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
v2.0.722 (insiders): Mac groundwork, part two (Kyle's pull request #4): a capture guard so a screenshot of a pup window parked on a Mac for a while still answers. A no-op on Windows. Includes 2.0.721 (the dashboard no longer blinks). Gates on arav-rog and ConfRoomROG: smoke all pass, foreground pass, parity 55/56, jump list 11/14 (baseline).
9 files changed
+70−19
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-browser", "pup-cdp",@@ -781,7 +781,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-core", "pup-host",@@ -793,7 +793,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.721"+version = "2.0.722" dependencies = [ "serde_json", "thiserror",@@ -803,7 +803,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.721"+version = "2.0.722" dependencies = [ "serde", "serde_json",@@ -813,7 +813,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-cdp", "pup-core",@@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.721"+version = "2.0.722" dependencies = [ "serde", "serde_json",@@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.721"+version = "2.0.722" dependencies = [ "core-foundation", "core-graphics",@@ -851,7 +851,7 @@ dependencies = [ [[package]] name = "pup-host-win"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-core", "pup-host",@@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.721"+version = "2.0.722" dependencies = [ "fontdue", "image",@@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-core", "serde",@@ -881,7 +881,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.721"+version = "2.0.722" dependencies = [ "pup-core", "pup-host",@@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.721"+version = "2.0.722" dependencies = [ "base64", "png 0.17.16",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.721"+version = "2.0.722" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+2@@ -66,6 +66,7 @@ fn main() { pup_core::set_log_file(pup_core::data_subdir("logs", state_dir.join("pup-logs")).join("pup.log")); pup_core::log(format!("[pup] {} starting; log file {}", pup_core::version(), pup_core::log_file().map(|p| p.display().to_string()).unwrap_or_default())); let ab: Arc<dyn pup_core::AbClient> = match Loopback::from_env() { Some(l) => Arc::new(l), None => { pup_core::log("[ab] ADOM_DIRECT_API_URL not set: desktop verbs degrade (hand-launched exe?)"); Arc::new(NoAb) } };+ let _ = pup_verbs::CAPTURE_HOST.set(h.clone()); let ctx = Arc::new(Ctx { host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()), sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), focus_emul: Mutex::new(Default::default()), pending_press: Mutex::new(Default::default()), browser_stall: Mutex::new(Default::default()), browser_idle_since: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),@@ -213,3 +214,4 @@ fn main() { }); } }+
rust/host-mac/src/lib.rs+25@@ -298,6 +298,12 @@ fn process_table() -> Vec<(u32, u32, u64, String)> { }).collect() } +/// Apps capture_frames_begin unhid, for capture_frames_end to park again.+fn capture_unhidden() -> &'static Mutex<Vec<u32>> {+ static U: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();+ U.get_or_init(|| Mutex::new(Vec::new()))+}+ /// Is the whole app that owns `pid` hidden (Cmd+H state)? None when the pid has no app. fn app_hidden(pid: u32) -> Option<bool> { imp::app_hidden(pid) } /// The user is using this app right now: it is the frontmost application.@@ -391,6 +397,25 @@ impl HostWindows for MacHost { // Chrome on macOS destroys a closed window's NSWindow on a later UI turn; the window-server entry can // outlive the close for minutes while the app idles hidden in the background. fn closed_window_may_linger(&self) -> bool { true }+ // Unhide (no activation) every hidden app among pup's own browser processes, remember them, and hide them+ // again in capture_frames_end. The user's own Chrome is never touched (pup-profiles marker), and an app the+ // user has frontmost is left alone in both directions.+ fn capture_frames_begin(&self) -> bool {+ let mut apps: Vec<u32> = Vec::new();+ for (pid, _) in self.pup_browser_processes() {+ if app_hidden(pid) == Some(true) && !app_is_frontmost(pid) && !apps.contains(&pid) { apps.push(pid); }+ }+ if apps.is_empty() { return false; }+ for pid in &apps { imp::set_app_hidden(*pid, false); }+ // one compositor turn so the first capture after the unhide finds a frame+ std::thread::sleep(std::time::Duration::from_millis(150));+ *capture_unhidden().lock().unwrap() = apps;+ true+ }+ fn capture_frames_end(&self) {+ let apps = std::mem::take(&mut *capture_unhidden().lock().unwrap());+ for pid in apps { if !app_is_frontmost(pid) { imp::set_app_hidden(pid, true); } }+ } // Windows: FlashWindowEx(FLASHW_STOP). No taskbar flash on macOS; nothing to stop. fn stop_flash(&self, _hwnd: i64) -> bool { false } // A GRANTED raise (the verbs gate it on foregroundReason): unhide + activate the owning app, then
rust/host/src/lib.rs+9@@ -92,6 +92,15 @@ pub trait HostWindows: Send + Sync { /// the lane browser sat idle in the background, and vanished at the next windows.create). When true, the /// close path takes the lane's own word that the window is gone instead of waiting for the handle to die. fn closed_window_may_linger(&self) -> bool { false }+ /// Make pup's PARKED browsers produce frames for a capture. Windows: a bottomed window keeps painting+ /// (occlusion calculation is off), nothing to do. macOS: a window of a hidden app stops rendering after a+ /// while and Page.captureScreenshot / captureVisibleTab then hang (measured 2026-10-14: every capture of a+ /// window hidden for a minute timed out; the same window answered in under a second once its app was+ /// unhidden behind the user's app). So the host unhides pup's own hidden browsers WITHOUT activating them+ /// (they come back at their old z positions, behind the user's work), and `capture_frames_end` hides them+ /// again. Returns true when something was unhidden, so the caller knows to call end.+ fn capture_frames_begin(&self) -> bool { false }+ fn capture_frames_end(&self) {} } pub trait HostProcess: Send + Sync {
rust/verbs/src/extdrive.rs+2−1@@ -451,7 +451,7 @@ fn navigate_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value { let mut c = json!({ "action": "navigate", "url": args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank") }); aim(&mut c); if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }- match run(c, Duration::from_secs(60)) {+ match crate::transport::with_frames(|| run(c, Duration::from_secs(60))) { Ok(r) => { pin(&r); ok(json!({ "url": r["url"], "title": r["title"], "tabId": r["tabId"], "_hint": "Navigated through the extension (no CDP). Read it with pup_eval." })) } Err(e) => e, }@@ -913,3 +913,4 @@ mod tests { assert!(!super::is_blank_or_birth("https://example.com/birth.html")); assert!(!super::is_blank_or_birth("https://wiki.adom.inc/adom/pup-bridge")); } }+
rust/verbs/src/extras.rs+1−1@@ -38,7 +38,7 @@ pub fn screenshot_element(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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 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 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()));
rust/verbs/src/lib.rs+3@@ -524,6 +524,9 @@ pub const USER_CALLERS: [&str; 4] = ["user-taskbar-menu", "user-toolbar", "user- 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; +/// The host, reachable from code that has no Ctx (the transport's capture paths). Set once by the binary.+pub static CAPTURE_HOST: std::sync::OnceLock<Arc<dyn Host>> = std::sync::OnceLock::new();+ 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())) }
rust/verbs/src/transport.rs+15−4@@ -112,8 +112,19 @@ pub fn evaluate(lane: &Lane, t: &Tab, expr: &str) -> Result<Value, Value> { } } +/// Run a capture with pup's parked browsers producing frames (Host::capture_frames_begin/end; a no-op on+/// Windows, an unhide-behind-the-user on macOS). Every Page.captureScreenshot / captureVisibleTab goes through+/// here so a window parked for a while still answers.+pub fn with_frames<T>(f: impl FnOnce() -> T) -> T {+ let host = crate::CAPTURE_HOST.get().cloned();+ let unhid = host.as_ref().map(|h| h.capture_frames_begin()).unwrap_or(false);+ let r = f();+ if unhid { if let Some(h) = host { h.capture_frames_end(); } }+ r+}+ pub fn screenshot_png(lane: &Lane, t: &Tab, full: bool) -> Result<Vec<u8>, Value> {- match lane {+ with_frames(|| match lane { Lane::Cdp(b) => b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(20)).map_err(|e| err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState."))), Lane::Ext => { let r = run(json!({ "action": "screenshot", "tabId": ext_id(t) }), 60)?;@@ -121,7 +132,7 @@ pub fn screenshot_png(lane: &Lane, t: &Tab, full: bool) -> Result<Vec<u8>, Value let b64 = r["dataUrl"].as_str().and_then(|d| d.split(',').nth(1)).unwrap_or(""); pup_cdp::base64_decode(b64).map_err(|e| err("screenshot_decode_failed", e.to_string(), None)) }- }+ }) } /// Open a tab in the window. Ext: in the SAME Chrome window (windowId) the session was born with.@@ -296,7 +307,7 @@ pub fn read_window(lane: &Lane, t: &Tab, name: &str, clear: bool) -> Result<(Val /// A PNG of the viewport, optionally clipped to a CSS-pixel rect. CDP clips in the capture; the /// extension lane captures the visible tab (device pixels) and the clip is applied in the binary. pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f64, f64)>, dpr: f64) -> Result<Vec<u8>, Value> {- match lane {+ with_frames(|| match lane { Lane::Cdp(b) => { let mut p = json!({ "format": "png", "captureBeyondViewport": false }); if let Some((x, y, w, h)) = clip_css { p["clip"] = json!({ "x": x.max(0.0), "y": y.max(0.0), "width": w.max(1.0), "height": h.max(1.0), "scale": 1 }); }@@ -312,6 +323,6 @@ pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f6 Some((x, y, w, h)) => pup_identity::badge::crop_png(&bytes, (x.max(0.0) * dpr).round() as u32, (y.max(0.0) * dpr).round() as u32, ((w.max(1.0) * dpr).round() as u32).max(1), ((h.max(1.0) * dpr).round() as u32).max(1)).ok_or_else(|| err("capture_failed", "could not decode the captured viewport".to_string(), None)), } }- }+ }) }