← 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).

John Lauer ·9cf30e331f ·27d ago ·parent dd74488
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)),             }         }-    }+    }) }