← Commit history

v2.0.720 (insiders): Groundwork for the macOS build of the Rust pup (Kyle's pull request #3, pup-bridge#128): the Mac host, per-platform process, browser and raise hooks with the Windows behavior kept as the default, and a bounded tab capture that falls back to the debugger frame when the window is not painting. No change in behavior on Windows. Gates on arav-rog and ConfRoomROG, both beside other threads' windows: smoke all pass, foreground pass, parity 55/56, jump list 11/14 (baseline).

John Lauer ·87f50518b9 ·27d ago ·parent 9de1f93
20 files changed +1060−73
rust/Cargo.lock+170−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-browser",  "pup-cdp",@@ -136,6 +136,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]]+name = "core-foundation"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"+dependencies = [+ "core-foundation-sys",+ "libc",+]++[[package]]+name = "core-foundation-sys"+version = "0.8.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"++[[package]]+name = "core-graphics"+version = "0.24.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "core-graphics-types",+ "foreign-types",+ "libc",+]++[[package]]+name = "core-graphics-types"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "libc",+]+ [[package]] name = "core_maths" version = "0.1.1"@@ -195,6 +235,16 @@ dependencies = [  "crypto-common", ] +[[package]]+name = "dispatch2"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"+dependencies = [+ "bitflags 2.13.2",+ "objc2",+]+ [[package]] name = "displaydoc" version = "0.2.7"@@ -269,6 +319,33 @@ dependencies = [  "ttf-parser", ] +[[package]]+name = "foreign-types"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"+dependencies = [+ "foreign-types-macros",+ "foreign-types-shared",+]++[[package]]+name = "foreign-types-macros"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "foreign-types-shared"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"+ [[package]] name = "form_urlencoded" version = "1.2.2"@@ -582,6 +659,55 @@ dependencies = [  "autocfg", ] +[[package]]+name = "objc2"+version = "0.6.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"+dependencies = [+ "objc2-encode",+]++[[package]]+name = "objc2-app-kit"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"+dependencies = [+ "bitflags 2.13.2",+ "libc",+ "objc2",+ "objc2-foundation",+]++[[package]]+name = "objc2-core-foundation"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"+dependencies = [+ "bitflags 2.13.2",+ "dispatch2",+ "objc2",+]++[[package]]+name = "objc2-encode"+version = "4.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"++[[package]]+name = "objc2-foundation"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"+dependencies = [+ "bitflags 2.13.2",+ "objc2",+ "objc2-core-foundation",+]+ [[package]] name = "once_cell" version = "1.21.4"@@ -655,7 +781,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-core",  "pup-host",@@ -667,7 +793,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.719"+version = "2.0.720" dependencies = [  "serde_json",  "thiserror",@@ -677,7 +803,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.719"+version = "2.0.720" dependencies = [  "serde",  "serde_json",@@ -687,7 +813,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-cdp",  "pup-core",@@ -700,7 +826,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.719"+version = "2.0.720" dependencies = [  "serde",  "serde_json",@@ -709,14 +835,23 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.719"+version = "2.0.720" dependencies = [+ "core-foundation",+ "core-graphics",+ "libc",+ "objc2",+ "objc2-app-kit",+ "objc2-foundation",+ "pup-core",  "pup-host",+ "security-framework",+ "serde_json", ]  [[package]] name = "pup-host-win"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-core",  "pup-host",@@ -726,7 +861,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.719"+version = "2.0.720" dependencies = [  "fontdue",  "image",@@ -736,7 +871,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-core",  "serde",@@ -746,7 +881,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.719"+version = "2.0.720" dependencies = [  "pup-core",  "pup-host",@@ -757,7 +892,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.719"+version = "2.0.720" dependencies = [  "base64",  "png 0.17.16",@@ -901,6 +1036,29 @@ dependencies = [  "untrusted", ] +[[package]]+name = "security-framework"+version = "3.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "core-foundation-sys",+ "libc",+ "security-framework-sys",+]++[[package]]+name = "security-framework-sys"+version = "2.17.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"+dependencies = [+ "core-foundation-sys",+ "libc",+]+ [[package]] name = "serde" version = "1.0.229"
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.719"+version = "2.0.720" edition = "2021" license = "MIT" 
rust/browser/src/lib.rs+5−9
@@ -38,16 +38,11 @@ impl Candidate { // ── detection (Node: listSystemBrowsers / detectChrome / launchCandidates) ─────────────  pub fn list_system_browsers(host: &dyn Host) -> Vec<Candidate> {-    let mut out = Vec::new();-    let roots = host.browser_roots();-    let chrome: Vec<PathBuf> = roots.iter().map(|r| r.join("Google").join("Chrome").join("Application").join("chrome.exe")).collect();-    let edge: Vec<PathBuf> = roots.iter().map(|r| r.join("Microsoft").join("Edge").join("Application").join("msedge.exe")).collect();-    if let Some(p) = chrome.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "chrome".into(), source: "system".into(), executable_path: p, build_id: None }); }-    if let Some(p) = edge.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "edge".into(), source: "system".into(), executable_path: p, build_id: None }); }-    out+    // The per-OS install layout is the host's (Program Files on Windows, /Applications bundles on macOS).+    host.system_browsers().into_iter().map(|(kind, p)| Candidate { kind, source: "system".into(), executable_path: p, build_id: None }).collect() } -pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_platform(), build_id)) }+pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_cache_platform(), build_id)) } pub fn cft_exe_path(host: &dyn Host, build_id: &str) -> PathBuf { cft_versioned_dir(host, build_id).join(host.cft_exe_relative()) }  pub fn detect_cft(host: &dyn Host) -> Option<Candidate> {@@ -56,7 +51,7 @@ pub fn detect_cft(host: &dyn Host) -> Option<Candidate> {     // any other complete build in the cache (newest wins), so an older cache still drives     let root = host.cft_cache_dir().join("chrome");     let mut builds: Vec<(String, PathBuf)> = fs::read_dir(&root).ok()?.flatten()-        .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) })+        .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_cache_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) })         // 2.0.679: a build id is a plain version. Folders like "<build>.partial" (an unpack in progress) or a renamed         // copy matched the prefix and could be launched half-written (found testing ab's prewarm on CRR, 2026-09-22).         .filter(|(b, p)| !b.is_empty() && b.chars().all(|c| c.is_ascii_digit() || c == '.') && p.is_file()).collect();@@ -828,3 +823,4 @@ mod tests {         let _ = fs::remove_dir_all(&tmp);     } }+
rust/host-mac/Cargo.toml+13−1
@@ -3,7 +3,19 @@ name = "pup-host-mac" version.workspace = true edition.workspace = true license.workspace = true-description = "The macOS implementation of pup-host. Kyle's crate: every method is a todo!() naming the Windows counterpart."+description = "The macOS implementation of pup-host: CoreGraphics window list, AppKit hide/activate for park, Accessibility for bounds, Keychain secrets, non-activating detached launch."  [dependencies] pup-host = { path = "../host" }+pup-core = { path = "../core" }+serde_json.workspace = true+libc = "0.2"++[target.'cfg(target_os = "macos")'.dependencies]+core-foundation = "0.10"+core-graphics = "0.24"+security-framework = "3"+objc2 = "0.6"+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSRunningApplication", "NSWorkspace", "NSScreen", "NSResponder", "NSApplication"] }+
rust/host-mac/examples/fg_probe.rsadded+10
@@ -0,0 +1,10 @@+use pup_host::HostWindows;+fn main() {+    let h = pup_host_mac::MacHost;+    let fg = h.foreground();+    println!("foreground()={fg}; NSWorkspace frontmost pid={:?}", pup_host_mac::frontmost_pid());+    if fg != 0 { let r = h.windows_for_pids(&[], None).into_iter().find(|w| w.hwnd == fg); println!("foreground window: {:?}", r.map(|w| (w.pid, w.class, w.rect))); }+    let rows = h.windows_for_pids(&[], None);+    println!("{} layer-0 windows; first 6: {:?}", rows.len(), rows.iter().take(6).map(|w| (w.hwnd, w.pid, w.class.clone(), w.visible)).collect::<Vec<_>>());+}+
rust/host-mac/examples/hide_ctl.rsadded+9
@@ -0,0 +1,9 @@+//! hide_ctl <hwnd> show|hide|raise — drive one window's owning app through the trait, for live tests.+use pup_host::HostWindows;+fn main() {+    let a: Vec<String> = std::env::args().collect();+    let h = pup_host_mac::MacHost; let hwnd: i64 = a[1].parse().unwrap();+    let r = match a[2].as_str() { "show" => h.set_visible_no_activate(hwnd, true), "hide" => h.send_to_bottom_no_activate(hwnd), "raise" => h.raise_window(hwnd), _ => false };+    println!("{} {hwnd}: {r}; foreground now {}", a[2], h.foreground());+}+
rust/host-mac/examples/live_probe.rsadded+37
@@ -0,0 +1,37 @@+//! Live probe of the mac host against Chrome for Testing: non-activating launch, pid resolution,+//! window discovery by pid, park (hide), unpark, kill. Run: cargo run -p pup-host-mac --example live_probe <cft-exe> <profile-dir>+use pup_host::{HostProcess, HostWindows};+use pup_host_mac::MacHost;+use std::path::Path;+use std::time::{Duration, Instant};+fn main() {+    let a: Vec<String> = std::env::args().collect();+    let exe = Path::new(&a[1]); let profile = &a[2];+    let h = MacHost;+    let front_before = h.foreground();+    let t0 = Instant::now();+    let args = vec![format!("--user-data-dir={profile}"), "--remote-debugging-port=0".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--window-size=900,600".into(), "about:blank".into()];+    let pid = h.spawn_detached(exe, &args, None).expect("spawn");+    println!("spawned pid {pid} in {:?}; alive={}", t0.elapsed(), h.alive(pid));+    let mut wins = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(15);+    while Instant::now() < deadline { wins = h.windows_for_pids(&[pid], Some("Chrome_WidgetWin_1")); if !wins.is_empty() { break; } std::thread::sleep(Duration::from_millis(100)); }+    println!("windows for pid after {:?}: {:?}", t0.elapsed(), wins.iter().map(|w| (w.hwnd, &w.class, w.rect, w.visible)).collect::<Vec<_>>());+    let front_after = h.foreground();+    println!("foreground before={front_before} after={front_after} (stolen={})", wins.iter().any(|w| w.hwnd == front_after));+    let Some(w) = wins.first() else { h.kill(pid); return; };+    println!("tag: {} has_tag: {}", h.tag(w.hwnd, "AdomPupSid=probe"), h.has_tag(w.hwnd, "AdomPupSid=probe"));+    println!("park (hide): {}", h.send_to_bottom_no_activate(w.hwnd));+    std::thread::sleep(Duration::from_millis(300));+    let hidden_rows = h.windows_for_pids(&[pid], Some("Chrome_WidgetWin_1"));+    println!("after hide: found={} visible={:?} is_alive={}", hidden_rows.len(), hidden_rows.iter().map(|w| w.visible).collect::<Vec<_>>(), h.is_alive(w.hwnd));+    println!("set_bounds (AX): {}", h.set_bounds_no_activate(w.hwnd, 40, 60, 800, 500));+    println!("unhide (no activate): {}", h.set_visible_no_activate(w.hwnd, true));+    std::thread::sleep(Duration::from_millis(300));+    println!("foreground now={} (ours={})", h.foreground(), h.foreground() == w.hwnd);+    println!("idle_ms={:?} work_area={:?}", h.idle_ms(), h.work_area());+    println!("kill: {}", h.kill(pid));+    std::thread::sleep(Duration::from_millis(500));+    println!("alive after kill: {} window alive: {}", h.alive(pid), h.is_alive(w.hwnd));+}+
rust/host-mac/examples/procs.rsadded+3
@@ -0,0 +1,3 @@+use pup_host::HostProcess;+fn main() { let h = pup_host_mac::MacHost; println!("running browsers: {:?}", h.running_browser_executables()); println!("pup browsers by profile: {:?}", h.pup_browser_processes().iter().take(3).collect::<Vec<_>>()); }+
rust/host-mac/examples/title_probe.rsadded+9
@@ -0,0 +1,9 @@+//! Do CGWindowList rows carry titles from this process (needs Screen Recording for other apps)?+use pup_host::HostWindows;+fn main() {+    let h = pup_host_mac::MacHost;+    let rows = h.windows_for_pids(&[], None);+    let titled = rows.iter().filter(|w| !w.title.is_empty()).count();+    println!("{} windows, {} with titles; chromium: {:?}", rows.len(), titled, rows.iter().filter(|w| w.class.to_lowercase().contains("chrome")).map(|w| (w.pid, w.title.chars().take(30).collect::<String>())).take(5).collect::<Vec<_>>());+}+
rust/host-mac/examples/wins.rsadded+3
@@ -0,0 +1,3 @@+use pup_host::HostWindows;+fn main() { let pid: u32 = std::env::args().nth(1).unwrap().parse().unwrap(); for w in pup_host_mac::MacHost.windows_for_pids(&[pid], None) { println!("{:?}", w); } }+
rust/host-mac/src/lib.rs+699−24
@@ -1,48 +1,723 @@-//! pup-host-mac: the macOS host, for Kyle to implement. Same traits as pup-host-win; every-//! `todo!()` names what the Windows crate does for that method, so the port is a translation,-//! not a rediscovery. `cargo check --target aarch64-apple-darwin -p pup-host-mac` runs every ship.+//! pup-host-mac: the macOS host. Same traits as pup-host-win; each method names the Windows+//! counterpart it mirrors. The ONLY crate in the workspace that may name CoreGraphics, AppKit,+//! Accessibility or the Keychain (tools/check-borders.sh). //!-//! What macOS does NOT need to implement: anything taskbar-shaped (Dock identity, badges, jump-//! lists) — that is ab's job through `AbClient`. pup only needs windows found, kept out of the-//! user's way (the Windows "bottom of the z-order, never activated" is `NSApp hide` / ordering-//! the window back on macOS), and the browser spawned detached.+//! The mac model, carried over from the Node build's darwin work (pup 1.9.74+):+//!   - a "window handle" is a CGWindowID (CGWindowListCopyWindowInfo), owned by the browser pid;+//!   - "bottom of the z-order, never activated" has no per-window equivalent for ANOTHER app's+//!     window without Accessibility, so park = app-level hide of the browser (the Cmd+H state,+//!     `NSRunningApplication.hide`). Hidden windows stay drivable and screenshottable over CDP.+//!     One session = one browser process, so the hide is per session.+//!   - the browser is launched NON-activating: `open -g -n -a <bundle> --args …` when the+//!     executable lives inside a .app bundle, so the first window is born behind the user's+//!     frontmost app instead of stealing focus (a directly spawned Chrome activates itself on its+//!     first window). The pid is resolved from the process table by the `--user-data-dir=` arg.+//!   - window tags: macOS has no per-window property store (SetProp), so tags live in+//!     `~/.adom/pup-window-tags.json` keyed by CGWindowID, pruned of dead ids on every write.+//!   - taskbar-shaped work (Dock identity, badges, jump lists) is ab's job through `AbClient`;+//!     nothing here invents a Dock equivalent.+//!+//! Permissions: window enumeration, hide/activate, idle time and the Keychain need nothing.+//! Window TITLES from CGWindowList need Screen Recording; without it `title` is empty, which the+//! verbs tolerate (they match by pid + tag). `set_bounds_no_activate` needs Accessibility and+//! returns false without it (the birth path moves windows over CDP first anyway).++// Unix only: the workspace build for the Windows exe compiles every member, and this crate's process code is POSIX.+#![cfg(unix)]  use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows};+use std::collections::HashMap; use std::path::{Path, PathBuf};+use std::sync::Mutex;  pub struct MacHost; +/// The Windows class name the verbs crate asks for; on macOS it means "any Chromium window".+const CHROME_CLASS: &str = "Chrome_WidgetWin_1";++fn owner_is_chromium(owner: &str) -> bool {+    let o = owner.to_ascii_lowercase();+    o.contains("chrome") || o.contains("chromium") || o.contains("edge")+}++/// Does a CGWindowList owner name satisfy the caller's class filter?+fn class_matches(owner: &str, class_filter: Option<&str>) -> bool {+    match class_filter {+        None => true,+        Some(CHROME_CLASS) => owner_is_chromium(owner),+        Some(c) => owner.eq_ignore_ascii_case(c) || owner_is_chromium(owner) && owner_is_chromium(c),+    }+}++// ───────────────────────── window tags (SetProp replacement) ─────────────────────────++fn tags_file() -> PathBuf { MacHost.state_dir().join("pup-window-tags.json") }++fn tags_store() -> &'static Mutex<HashMap<i64, Vec<String>>> {+    static M: std::sync::OnceLock<Mutex<HashMap<i64, Vec<String>>>> = std::sync::OnceLock::new();+    M.get_or_init(|| {+        let loaded: HashMap<i64, Vec<String>> = std::fs::read_to_string(tags_file()).ok()+            .and_then(|s| serde_json::from_str::<HashMap<String, Vec<String>>>(&s).ok())+            .map(|m| m.into_iter().filter_map(|(k, v)| k.parse::<i64>().ok().map(|k| (k, v))).collect())+            .unwrap_or_default();+        Mutex::new(loaded)+    })+}++fn tags_persist(map: &HashMap<i64, Vec<String>>) {+    let as_str: HashMap<String, &Vec<String>> = map.iter().map(|(k, v)| (k.to_string(), v)).collect();+    if let Some(d) = tags_file().parent() { let _ = std::fs::create_dir_all(d); }+    if let Ok(txt) = serde_json::to_string_pretty(&as_str) { let _ = std::fs::write(tags_file(), txt); }+}++// ───────────────────────── platform implementation ─────────────────────────++#[cfg(target_os = "macos")]+mod imp {+    use core_foundation::array::CFArray;+    use core_foundation::base::{CFType, TCFType};+    use core_foundation::dictionary::CFDictionary;+    use core_foundation::number::CFNumber;+    use core_foundation::string::CFString;+    use core_graphics::display::{CGWindowListCopyWindowInfo, kCGNullWindowID, kCGWindowListExcludeDesktopElements, kCGWindowListOptionAll, kCGWindowListOptionOnScreenOnly};+    use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication, NSScreen, NSWorkspace};++    /// One row of CGWindowListCopyWindowInfo, the fields pup needs.+    pub(super) struct Row { pub id: i64, pub pid: u32, pub owner: String, pub title: String, pub layer: i64, pub onscreen: bool, pub bounds: (i32, i32, i32, i32) }++    fn dict_i64(d: &CFDictionary<CFString, CFType>, key: &str) -> Option<i64> {+        d.find(CFString::new(key)).and_then(|v| v.downcast::<CFNumber>()).and_then(|n| n.to_i64())+    }+    fn dict_str(d: &CFDictionary<CFString, CFType>, key: &str) -> String {+        d.find(CFString::new(key)).and_then(|v| v.downcast::<CFString>()).map(|s| s.to_string()).unwrap_or_default()+    }++    pub(super) fn window_rows(all: bool) -> Vec<Row> {+        use core_foundation::dictionary::CFDictionaryRef;+        let opts = if all { kCGWindowListOptionAll | kCGWindowListExcludeDesktopElements } else { kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements };+        let raw = unsafe { CGWindowListCopyWindowInfo(opts, kCGNullWindowID) };+        if raw.is_null() { return Vec::new(); }+        let arr: CFArray<CFType> = unsafe { CFArray::wrap_under_create_rule(raw) };+        let mut out = Vec::new();+        for item in arr.iter() {+            let d: CFDictionary<CFString, CFType> = unsafe { CFDictionary::wrap_under_get_rule(item.as_CFTypeRef() as CFDictionaryRef) };+            let id = dict_i64(&d, "kCGWindowNumber").unwrap_or(0);+            let pid = dict_i64(&d, "kCGWindowOwnerPID").unwrap_or(0) as u32;+            let layer = dict_i64(&d, "kCGWindowLayer").unwrap_or(0);+            // kCGWindowIsOnscreen is a CFBoolean (not a CFNumber) and is only present when true.+            let onscreen = !all || d.find(CFString::new("kCGWindowIsOnscreen")).and_then(|v| v.downcast::<core_foundation::boolean::CFBoolean>()).map(|b| bool::from(b)).unwrap_or(false);+            let bounds = d.find(CFString::new("kCGWindowBounds")).map(|v| {+                let b: CFDictionary<CFString, CFType> = unsafe { CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as CFDictionaryRef) };+                let g = |k: &str| b.find(CFString::new(k)).and_then(|v| v.downcast::<CFNumber>()).and_then(|n| n.to_f64()).unwrap_or(0.0);+                let (x, y, w, h) = (g("X"), g("Y"), g("Width"), g("Height"));+                (x as i32, y as i32, (x + w) as i32, (y + h) as i32)+            }).unwrap_or((0, 0, 0, 0));+            out.push(Row { id, pid, owner: dict_str(&d, "kCGWindowOwnerName"), title: dict_str(&d, "kCGWindowName"), layer, onscreen, bounds });+        }+        out+    }++    pub(super) fn running_app(pid: u32) -> Option<objc2::rc::Retained<NSRunningApplication>> {+        NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)+    }++    /// Node: macSetAppHiddenByPid (NSRunningApplication via JXA, never System Events).+    pub(super) fn set_app_hidden(pid: u32, hidden: bool) -> bool {+        let Some(app) = running_app(pid) else { return false };+        if hidden { app.hide(); } else { app.unhide(); }+        // hide/unhide are asynchronous; judge by the state a beat later.+        std::thread::sleep(std::time::Duration::from_millis(60));+        app.isHidden() == hidden+    }++    /// Node: macActivateByPid. Only used for a GRANTED raise (never at birth).+    pub fn activate(pid: u32) -> bool {+        let Some(app) = running_app(pid) else { return false };+        app.unhide();+        app.activateWithOptions(NSApplicationActivationOptions::empty())+    }++    pub(super) fn frontmost_pid() -> Option<u32> {+        // A process that has connected to the window server but runs no NSApplication (this+        // bridge, its tests) is reported as its own frontmost app; that answer is worthless.+        NSWorkspace::sharedWorkspace().frontmostApplication().map(|a| a.processIdentifier() as u32).filter(|p| *p != std::process::id())+    }++    pub(super) fn app_hidden(pid: u32) -> Option<bool> { running_app(pid).map(|a| a.isHidden()) }++    /// The main screen's visible frame in TOP-LEFT (CG) coordinates: (x, y, w, h).+    pub(super) fn visible_work_area() -> Option<(i32, i32, i32, i32)> {+        // NSScreen geometry is read-only and safe off the main thread; the marker is a type-level+        // requirement of the binding, not a runtime one, for this call.+        let mtm = unsafe { objc2::MainThreadMarker::new_unchecked() };+        let screen = NSScreen::mainScreen(mtm)?;+        let frame = screen.frame();+        let vis = screen.visibleFrame();+        let top = frame.size.height - (vis.origin.y + vis.size.height);+        Some((vis.origin.x as i32, top as i32, vis.size.width as i32, vis.size.height as i32))+    }++    pub(super) fn idle_ms() -> Option<u64> {+        extern "C" { fn CGEventSourceSecondsSinceLastEventType(state: i32, event_type: u32) -> f64; }+        // kCGEventSourceStateCombinedSessionState = 0, kCGAnyInputEventType = !0+        let s = unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) };+        if s.is_finite() && s >= 0.0 { Some((s * 1000.0) as u64) } else { None }+    }++    // ── Accessibility: move/resize a window by CGWindowID without activating it ──+    #[repr(C)] struct AXOpaque { _p: [u8; 0] }+    type AXUIElementRef = *const AXOpaque;+    type AXValueRef = *const AXOpaque;+    #[repr(C)] #[derive(Clone, Copy)] struct CGPointC { x: f64, y: f64 }+    #[repr(C)] #[derive(Clone, Copy)] struct CGSizeC { w: f64, h: f64 }+    #[link(name = "ApplicationServices", kind = "framework")]+    extern "C" {+        fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;+        fn AXUIElementCopyAttributeValue(el: AXUIElementRef, attr: core_foundation::string::CFStringRef, out: *mut core_foundation::base::CFTypeRef) -> i32;+        fn AXUIElementSetAttributeValue(el: AXUIElementRef, attr: core_foundation::string::CFStringRef, value: core_foundation::base::CFTypeRef) -> i32;+        fn AXValueCreate(kind: u32, value: *const std::ffi::c_void) -> AXValueRef;+        fn _AXUIElementGetWindow(el: AXUIElementRef, out: *mut u32) -> i32;+        fn AXIsProcessTrusted() -> bool;+    }+    const K_AX_VALUE_CGPOINT: u32 = 1;+    const K_AX_VALUE_CGSIZE: u32 = 2;++    pub(super) fn ax_set_bounds(pid: u32, window_id: i64, l: i32, t: i32, w: i32, h: i32) -> bool {+        unsafe {+            if !AXIsProcessTrusted() { return false; }+            let app = AXUIElementCreateApplication(pid as i32);+            if app.is_null() { return false; }+            let mut wins: core_foundation::base::CFTypeRef = std::ptr::null();+            let attr = CFString::new("AXWindows");+            if AXUIElementCopyAttributeValue(app, attr.as_concrete_TypeRef(), &mut wins) != 0 || wins.is_null() { core_foundation::base::CFRelease(app as _); return false; }+            let arr: CFArray<CFType> = CFArray::wrap_under_create_rule(wins as _);+            let mut ok = false;+            for el in arr.iter() {+                let el_ref = el.as_CFTypeRef() as AXUIElementRef;+                let mut wid: u32 = 0;+                if _AXUIElementGetWindow(el_ref, &mut wid) != 0 || wid as i64 != window_id { continue; }+                let p = CGPointC { x: l as f64, y: t as f64 };+                let s = CGSizeC { w: w as f64, h: h as f64 };+                let pv = AXValueCreate(K_AX_VALUE_CGPOINT, &p as *const _ as *const _);+                let sv = AXValueCreate(K_AX_VALUE_CGSIZE, &s as *const _ as *const _);+                let pa = CFString::new("AXPosition"); let sa = CFString::new("AXSize");+                let r1 = AXUIElementSetAttributeValue(el_ref, pa.as_concrete_TypeRef(), pv as _);+                let r2 = AXUIElementSetAttributeValue(el_ref, sa.as_concrete_TypeRef(), sv as _);+                if !pv.is_null() { core_foundation::base::CFRelease(pv as _); }+                if !sv.is_null() { core_foundation::base::CFRelease(sv as _); }+                ok = r1 == 0 && r2 == 0;+                break;+            }+            core_foundation::base::CFRelease(app as _);+            ok+        }+    }++    /// AXRaise one window of an app (after the app is active, this puts THAT window in front of the+    /// app's other windows). Needs Accessibility; false without it, which leaves the app's own order.+    pub(super) fn ax_raise(pid: u32, window_id: i64) -> bool {+        unsafe {+            if !AXIsProcessTrusted() { return false; }+            extern "C" { fn AXUIElementPerformAction(el: AXUIElementRef, action: core_foundation::string::CFStringRef) -> i32; }+            let app = AXUIElementCreateApplication(pid as i32);+            if app.is_null() { return false; }+            let mut wins: core_foundation::base::CFTypeRef = std::ptr::null();+            let attr = CFString::new("AXWindows");+            if AXUIElementCopyAttributeValue(app, attr.as_concrete_TypeRef(), &mut wins) != 0 || wins.is_null() { core_foundation::base::CFRelease(app as _); return false; }+            let arr: CFArray<CFType> = CFArray::wrap_under_create_rule(wins as _);+            let mut ok = false;+            for el in arr.iter() {+                let el_ref = el.as_CFTypeRef() as AXUIElementRef;+                let mut wid: u32 = 0;+                if _AXUIElementGetWindow(el_ref, &mut wid) != 0 || wid as i64 != window_id { continue; }+                let act = CFString::new("AXRaise");+                ok = AXUIElementPerformAction(el_ref, act.as_concrete_TypeRef()) == 0;+                break;+            }+            core_foundation::base::CFRelease(app as _);+            ok+        }+    }++    // ── Keychain: keytar's generic-password layout (service + account) ──+    pub(super) fn secret_get(service: &str, account: &str) -> Option<String> {+        security_framework::passwords::get_generic_password(service, account).ok().and_then(|b| String::from_utf8(b).ok())+    }+    pub(super) fn secret_set(service: &str, account: &str, secret: &str) -> Result<(), String> {+        security_framework::passwords::set_generic_password(service, account, secret.as_bytes()).map_err(|e| format!("Keychain write failed: {e}"))+    }+    pub(super) fn secret_delete(service: &str, account: &str) -> bool {+        security_framework::passwords::delete_generic_password(service, account).is_ok()+    }+}++#[cfg(not(target_os = "macos"))]+mod imp {+    // Cross-compiled checks on Linux/Windows: the mac impl is cfg'd out; these stubs let the+    // crate build on a dev box. The release binary is always the cfg(target_os = "macos") side.+    use super::*;+    pub(super) struct Row { pub id: i64, pub pid: u32, pub owner: String, pub title: String, pub layer: i64, pub onscreen: bool, pub bounds: (i32, i32, i32, i32) }+    pub(super) fn window_rows(_all: bool) -> Vec<Row> { Vec::new() }+    pub(super) fn set_app_hidden(_pid: u32, _hidden: bool) -> bool { false }+    pub fn activate(_pid: u32) -> bool { false }+    pub(super) fn frontmost_pid() -> Option<u32> { None }+    pub(super) fn app_hidden(_pid: u32) -> Option<bool> { None }+    pub(super) fn ax_raise(_pid: u32, _id: i64) -> bool { false }+    pub(super) fn visible_work_area() -> Option<(i32, i32, i32, i32)> { None }+    pub(super) fn idle_ms() -> Option<u64> { None }+    pub(super) fn ax_set_bounds(_pid: u32, _id: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { false }+    pub(super) fn secret_get(_s: &str, _a: &str) -> Option<String> { None }+    pub(super) fn secret_set(_s: &str, _a: &str, _v: &str) -> Result<(), String> { Err("no Keychain on this dev box".into()) }+    pub(super) fn secret_delete(_s: &str, _a: &str) -> bool { false }+}++/// Raise a parked (hidden) browser: the mac side of a GRANTED `pup_raise_os_window` (Node: macActivateByPid).+pub fn activate_pid(pid: u32) -> bool { imp::activate(pid) }++/// Pids pup itself ended through `Host::kill` (Windows: the same KILLED list), so recovery can tell a+/// deliberate end from a crash or the user quitting the browser.+fn killed() -> &'static Mutex<Vec<u32>> {+    static K: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();+    K.get_or_init(|| Mutex::new(Vec::new()))+}++/// (pid, ppid, rss KB, executable path) for every process, one `ps` call. `comm` is the executable+/// path WITHOUT arguments, so a path with spaces ("Google Chrome for Testing") survives intact.+fn process_table() -> Vec<(u32, u32, u64, String)> {+    let Ok(out) = std::process::Command::new("/bin/ps").args(["-axo", "pid=,ppid=,rss=,comm="]).output() else { return Vec::new() };+    String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| {+        let l = l.trim_start();+        let mut it = l.splitn(4, char::is_whitespace).filter(|x| !x.is_empty());+        let pid = it.next()?.parse::<u32>().ok()?;+        let rest = l[l.find(char::is_whitespace)?..].trim_start();+        let ppid = rest.split_whitespace().next()?.parse::<u32>().ok()?;+        let rest = rest[rest.find(char::is_whitespace)?..].trim_start();+        let rss = rest.split_whitespace().next()?.parse::<u64>().ok()?;+        let comm = rest[rest.find(char::is_whitespace)?..].trim().to_string();+        Some((pid, ppid, rss, comm))+    }).collect()+}++/// 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.+fn app_is_frontmost(pid: u32) -> bool { imp::frontmost_pid() == Some(pid) }++/// The frontmost application's pid as NSWorkspace reports it (None outside an AppKit process).+pub fn frontmost_pid() -> Option<u32> { imp::frontmost_pid() }++/// The owning pid of a CGWindowID, from the full window list (hidden windows included).+fn owner_pid_of(window_id: i64) -> Option<u32> { imp::window_rows(true).into_iter().find(|r| r.id == window_id).map(|r| r.pid) }++// ───────────────────────── HostWindows ─────────────────────────+ impl HostWindows for MacHost {-    fn windows_for_pids(&self, _pids: &[u32], _class_filter: Option<&str>) -> Vec<HostWindow> {-        todo!("CGWindowListCopyWindowInfo filtered by kCGWindowOwnerPID (Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1)")+    // Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1.+    // Hidden (parked) windows are included: pup must keep finding a window it parked.+    fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {+        imp::window_rows(true).into_iter()+            .filter(|r| r.layer == 0 && r.id > 0)+            .filter(|r| pids.is_empty() || pids.contains(&r.pid))+            .filter(|r| class_matches(&r.owner, class_filter))+            .filter(|r| r.bounds.2 - r.bounds.0 > 50 && r.bounds.3 - r.bounds.1 > 50)   // skip Chrome's 1-px helper windows+            .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.owner, rect: r.bounds, visible: r.onscreen })+            .collect()+    }+    // Windows: EnumWindows over unowned captioned frames, visible or not. macOS: every layer-0 window+    // of a Chromium owner from the FULL list (hidden apps' windows included), tiny helper windows dropped.+    fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> { self.windows_for_pids(&[], class_filter) }+    // Windows: IsWindow.+    fn is_alive(&self, hwnd: i64) -> bool { hwnd > 0 && imp::window_rows(true).iter().any(|r| r.id == hwnd) }+    // Windows: SetProp "AdomPupSid=<sid>". No per-window property store here: a persisted map.+    fn tag(&self, hwnd: i64, tag: &str) -> bool {+        if hwnd <= 0 { return false; }+        let mut m = tags_store().lock().unwrap();+        let alive: std::collections::HashSet<i64> = imp::window_rows(true).into_iter().map(|r| r.id).collect();+        m.retain(|id, _| alive.contains(id) || *id == hwnd);+        let v = m.entry(hwnd).or_default();+        if !v.iter().any(|t| t == tag) { v.push(tag.to_string()); }+        tags_persist(&m);+        true+    }+    // Windows: GetProp.+    fn has_tag(&self, hwnd: i64, tag: &str) -> bool { tags_store().lock().unwrap().get(&hwnd).map(|v| v.iter().any(|t| t == tag)).unwrap_or(false) }+    // Windows: powershell -WindowStyle Hidden -File <script> <tail>. Here: /bin/sh <script> <tail>.+    fn script_launch(&self, script: &Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+        let q = if quote_for_relaunch { "\\\"" } else { "\"" };+        ("/bin/sh".into(), format!("{q}{}{q} {tail}", script.display()))+    }+    // Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE. macOS has no per-window "order back" for+    // another app's window, so park = hide the owning app (the Cmd+H state; the Node mac build's design,+    // 1.9.74). Windows stay drivable and screenshottable over CDP / the extension while hidden. The lane+    // browser holds many threads' windows, so NEVER hide it while the user has it frontmost: that would+    // yank a window they are using (pup-user-foreground rule). Already hidden counts as parked.+    fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool {+        let Some(pid) = owner_pid_of(hwnd) else { return false };+        if app_is_frontmost(pid) { return false; }+        if app_hidden(pid) == Some(true) { return true; }+        imp::set_app_hidden(pid, true)+    }+    // Windows: ShowWindow SW_HIDE / SW_SHOWNOACTIVATE. macOS: hide / unhide the owning app (unhide never+    // activates; the windows come back at their old z positions, never in front of the user's work).+    fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool {+        match owner_pid_of(hwnd) { Some(pid) => if visible { imp::set_app_hidden(pid, false) } else { self.send_to_bottom_no_activate(hwnd) }, None => false }     }-    fn is_alive(&self, _hwnd: i64) -> bool { todo!("window id still present in CGWindowListCopyWindowInfo (Windows: IsWindow)") }-    fn tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("no per-window property store on macOS: keep a pid->session map in the state dir instead (Windows: SetProp \"AdomPupSid=<sid>\")") }-    fn script_launch(&self, _script: &std::path::Path, _tail: &str, _quote: bool) -> (String, String) { todo!("launch a helper script hidden (Windows: powershell.exe -WindowStyle Hidden -File <script> <tail>); macOS: /bin/sh or osascript") }-    fn has_tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("see tag() (Windows: GetProp)") }-    fn send_to_bottom_no_activate(&self, _hwnd: i64) -> bool { todo!("order the window below without key/main (NSWindow orderBack: via the app's accessibility API, or hide the browser app: the Cmd+H state) (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE)") }-    fn set_bounds_no_activate(&self, _hwnd: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { todo!("AXUIElementSetAttributeValue kAXPosition/kAXSize (Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER)") }-    fn foreground(&self) -> i64 { todo!("frontmost app's key window via NSWorkspace + AX (Windows: GetForegroundWindow)") }-    fn work_area(&self) -> Option<(i32, i32, i32, i32)> { todo!("NSScreen.main.visibleFrame (Windows: SystemParametersInfo SPI_GETWORKAREA)") }+    // Windows: SetWindowPos(HWND_BOTTOM, SHOWWINDOW | NOACTIVATE) in one call. macOS: a window born into a+    // hidden app is born invisible (parked by construction); a window born into a visible, non-frontmost+    // app sits behind the user's active app already, and the app is hidden so it is fully out of the way;+    // a window born while the user is IN the lane browser is left exactly where Chrome put it.+    fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool {+        let Some(pid) = owner_pid_of(hwnd) else { return false };+        if app_is_frontmost(pid) { return true; }+        if app_hidden(pid) == Some(true) { return true; }+        imp::set_app_hidden(pid, true)+    }+    // Windows: a sub-ms spin that bottoms the window the instant it first shows. macOS: a window of a hidden+    // app never shows, so it is parked by construction (Some(0)); otherwise wait for it to come on screen and+    // hide the app then, unless the user is in it.+    fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> {+        let pid = owner_pid_of(hwnd)?;+        let t0 = std::time::Instant::now();+        if app_hidden(pid) == Some(true) { return Some(0); }+        loop {+            if imp::window_rows(false).iter().any(|r| r.id == hwnd) {+                if !app_is_frontmost(pid) { imp::set_app_hidden(pid, true); }+                return Some(t0.elapsed().as_millis() as u64);+            }+            if t0.elapsed().as_millis() as u64 > timeout_ms { return None; }+            std::thread::sleep(std::time::Duration::from_millis(5));+        }+    }+    // 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 }+    // 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+    // AXRaise this window above the app's other windows when Accessibility allows it.+    fn raise_window(&self, hwnd: i64) -> bool {+        let Some(pid) = owner_pid_of(hwnd) else { return false };+        let ok = imp::activate(pid);+        if ok { std::thread::sleep(std::time::Duration::from_millis(80)); let _ = imp::ax_raise(pid, hwnd); }+        ok+    }+    // Windows: ITaskbarList DeleteTab/AddTab. No taskbar; the Dock tile is the app's, untouched.+    fn taskbar_tab(&self, _hwnd: i64, _present: bool) -> bool { false }+    // Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER. macOS: Accessibility, if trusted.+    fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool {+        match owner_pid_of(hwnd) { Some(pid) => imp::ax_set_bounds(pid, hwnd, left, top, width, height), None => false }+    }+    // Windows: GetForegroundWindow. macOS: the frontmost app's frontmost layer-0 window. When+    // NSWorkspace has no answer (no NSApplication in this process), the on-screen window list is+    // ordered front to back, so its first layer-0 row IS the frontmost window.+    fn foreground(&self) -> i64 {+        let rows = imp::window_rows(false);+        if let Some(pid) = imp::frontmost_pid() {+            if let Some(r) = rows.iter().find(|r| r.pid == pid && r.layer == 0 && r.onscreen) { return r.id; }+        }+        rows.into_iter().find(|r| r.layer == 0 && r.onscreen && r.bounds.2 - r.bounds.0 > 50 && r.bounds.3 - r.bounds.1 > 50).map(|r| r.id).unwrap_or(0)+    }+    // Windows: GetLastInputInfo. macOS: CGEventSourceSecondsSinceLastEventType.+    fn idle_ms(&self) -> Option<u64> { imp::idle_ms() }+    // Windows: SystemParametersInfo SPI_GETWORKAREA. macOS: NSScreen.main.visibleFrame (top-left coords).+    fn work_area(&self) -> Option<(i32, i32, i32, i32)> {+        imp::visible_work_area().filter(|(_, _, w, h)| *w > 400 && *h > 300)+    }+    // CDP and CGWindowList both speak points on macOS: no scaling to undo.+    fn dpi_scale(&self, _hwnd: i64) -> f64 { 1.0 }+}++// ───────────────────────── HostProcess ─────────────────────────++/// `<bundle>.app` that owns `exe`, when the executable lives at Contents/MacOS inside one.+fn bundle_for_executable(exe: &Path) -> Option<PathBuf> {+    let macos = exe.parent()?;+    if macos.file_name()?.to_str()? != "MacOS" { return None; }+    let contents = macos.parent()?;+    if contents.file_name()?.to_str()? != "Contents" { return None; }+    let app = contents.parent()?;+    if app.extension()?.to_str()? != "app" { return None; }+    Some(app.to_path_buf())+}++/// The pid of the BROWSER process (no `--type=`) whose command line carries `marker`.+fn find_pid_by_arg(marker: &str) -> Option<u32> {+    let out = std::process::Command::new("/bin/ps").args(["-axo", "pid=,command="]).output().ok()?;+    for line in String::from_utf8_lossy(&out.stdout).lines() {+        if line.contains(marker) && !line.contains("--type=") {+            if let Some(pid) = line.trim().split_whitespace().next().and_then(|p| p.parse::<u32>().ok()) { return Some(pid); }+        }+    }+    None }  impl HostProcess for MacHost {-    fn alive(&self, _pid: u32) -> bool { todo!("kill(pid, 0) == 0 (Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE)") }-    fn spawn_detached(&self, _exe: &Path, _args: &[String], _cwd: Option<&Path>) -> std::io::Result<u32> { todo!("posix_spawn with setsid so the browser survives a bridge restart (Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)") }-    fn kill(&self, _pid: u32) -> bool { todo!("kill(pid, SIGKILL) (Windows: TerminateProcess)") }+    // Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE.+    fn alive(&self, pid: u32) -> bool {+        if pid == 0 { return false; }+        let r = unsafe { libc::kill(pid as i32, 0) };+        r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)+    }+    // Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. macOS: when the exe is inside a .app+    // and the args name a profile, launch NON-activating through LaunchServices (`open -g -n`)+    // so the first window is born behind the user; the pid comes from the process table. Any+    // other executable is spawned directly in its own session (setsid) so it outlives the bridge.+    fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+        let marker = args.iter().find(|a| a.starts_with("--user-data-dir=")).cloned();+        if let (Some(bundle), Some(marker)) = (bundle_for_executable(exe), marker.as_deref()) {+            if find_pid_by_arg(marker).is_none() {+                let mut c = std::process::Command::new("/usr/bin/open");+                c.arg("-g").arg("-n").arg("-a").arg(&bundle).arg("--args").args(args)+                    .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+                if let Some(d) = cwd { c.current_dir(d); }+                let status = c.status()?;+                if status.success() {+                    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);+                    while std::time::Instant::now() < deadline {+                        if let Some(pid) = find_pid_by_arg(marker) { return Ok(pid); }+                        std::thread::sleep(std::time::Duration::from_millis(50));+                    }+                }+                // LaunchServices refused or the process never appeared: fall through to a direct spawn.+            }+        }+        use std::os::unix::process::CommandExt;+        let mut c = std::process::Command::new(exe);+        c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+        if let Some(d) = cwd { c.current_dir(d); }+        unsafe { c.pre_exec(|| { libc::setsid(); Ok(()) }); }+        Ok(c.spawn()?.id())+    }+    // Windows: TerminateProcess. The pid is remembered so recovery reads this as pup's own, deliberate end.+    fn kill(&self, pid: u32) -> bool {+        if pid == 0 || pid == std::process::id() { return false; }+        let ok = unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 };+        if ok { let mut k = killed().lock().unwrap(); if !k.contains(&pid) { k.push(pid); } }+        ok+    }+    fn ended_by_pup(&self, pid: u32) -> bool { killed().lock().unwrap().contains(&pid) }+    // Windows: Toolhelp snapshot by image name. macOS: the process table; `image` is the executable's+    // file name ("Google Chrome for Testing"), matched against each command's executable path.+    fn pids_for_image(&self, image: &str) -> Vec<u32> {+        let want = image.trim_end_matches(".exe").to_ascii_lowercase();+        if want.is_empty() { return Vec::new(); }+        // Chrome's helpers run a different executable ("… Helper"), so matching the basename alone already+        // yields browser processes only.+        process_table().into_iter().filter(|(_, _, _, comm)| comm.rsplit('/').next().unwrap_or(comm).to_ascii_lowercase() == want).map(|(pid, _, _, _)| pid).collect()+    }+    // Windows: the App Paths registry key. macOS: Google Chrome.app's Info.plist.+    fn installed_chrome_version(&self) -> Option<String> {+        for root in self.browser_roots() {+            let plist = root.join("Google Chrome.app").join("Contents").join("Info.plist");+            if !plist.is_file() { continue; }+            let out = std::process::Command::new("/usr/bin/defaults").args(["read", &plist.to_string_lossy(), "CFBundleShortVersionString"]).output().ok()?;+            let v = String::from_utf8_lossy(&out.stdout).trim().to_string();+            let parts: Vec<&str> = v.split('.').collect();+            if parts.len() == 4 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) { return Some(v); }+        }+        None+    }+    // Windows: GlobalMemoryStatusEx. macOS: hw.memsize + vm_stat free/inactive/speculative pages. (total MB, free MB)+    fn memory_status(&self) -> Option<(u64, u64)> {+        let total: u64 = String::from_utf8_lossy(&std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?.stdout).trim().parse().ok()?;+        let vm = String::from_utf8_lossy(&std::process::Command::new("/usr/bin/vm_stat").output().ok()?.stdout).to_string();+        let page: u64 = vm.lines().next().and_then(|l| l.split("page size of").nth(1)).and_then(|s| s.split_whitespace().next()).and_then(|n| n.parse().ok()).unwrap_or(16384);+        let pages = |key: &str| -> u64 { vm.lines().find(|l| l.starts_with(key)).and_then(|l| l.split(':').nth(1)).map(|v| v.trim().trim_end_matches('.').parse::<u64>().unwrap_or(0)).unwrap_or(0) };+        let free = (pages("Pages free") + pages("Pages inactive") + pages("Pages speculative")) * page;+        Some((total / 1_048_576, free / 1_048_576))+    }+    // Windows: Toolhelp parent walk + GetProcessMemoryInfo. macOS: ps rss (KB) over pid and its descendants.+    fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {+        if pid == 0 { return None; }+        let table = process_table();+        if !table.iter().any(|(p, _, _, _)| *p == pid) { return None; }+        let mut want = vec![pid]; let mut sum_kb = 0u64; let mut seen = std::collections::HashSet::new();+        while let Some(p) = want.pop() {+            if !seen.insert(p) { continue; }+            for (cp, pp, rss, _) in &table { if *cp == p { sum_kb += rss; } if *pp == p { want.push(*cp); } }+        }+        Some(sum_kb / 1024)+    }+    // Windows: powershell -File. macOS: /bin/sh, for the real-Chrome dmg installer script.+    fn run_shell_script(&self, script: &str, timeout: std::time::Duration) -> String {+        // Own process group, so a timeout ends the whole pipeline: killing only the shell leaves its children holding+        // stdout open and the read below blocks until they finish (a piped script ran past its timeout).+        use std::os::unix::process::CommandExt;+        let mut child = match std::process::Command::new("/bin/sh").arg("-c").arg(script).process_group(0)+            .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn() { Ok(c) => c, Err(_) => return String::new() };+        let deadline = std::time::Instant::now() + timeout;+        loop {+            match child.try_wait() {+                Ok(Some(_)) => break,+                Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(100)),+                _ => { unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } let _ = child.kill(); let _ = child.wait(); break; }+            }+        }+        let mut out = String::new();+        if let Some(mut so) = child.stdout.take() { use std::io::Read; let _ = so.read_to_string(&mut out); }+        let _ = child.wait();+        out+    }+    fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> { pup_core::http_download(url, dest) }+    // Windows: Win32_Process by image name. macOS: every process whose executable sits in a Chromium bundle+    // (Chrome, Chrome for Testing, Edge, Chromium, and their Helper processes), by path.+    fn pup_browser_processes(&self) -> Vec<(u32, String)> {+        let Ok(out) = std::process::Command::new("/bin/ps").args(["-axo", "pid=,command="]).output() else { return Vec::new() };+        String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| {+            let l = l.trim_start();+            let (pid, cmd) = l.split_once(' ')?;+            let pid: u32 = pid.parse().ok()?;+            let i = cmd.find("pup-profiles/")?;+            let rest = &cmd[i + "pup-profiles/".len()..];+            let name: String = rest.chars().take_while(|c| !matches!(c, '/' | ' ' | '"' | '\'')).collect();+            if name.is_empty() || !cmd.contains("--user-data-dir=") { None } else { Some((pid, name)) }+        }).collect()+    }+    fn running_browser_executables(&self) -> Vec<PathBuf> {+        let mut out: Vec<PathBuf> = process_table().into_iter().map(|(_, _, _, comm)| comm)+            .filter(|c| c.contains(".app/Contents/MacOS/") && (c.contains("Chrome") || c.contains("Chromium") || c.contains("Microsoft Edge")))+            .map(PathBuf::from).collect();+        out.sort(); out.dedup(); out+    } } +// ───────────────────────── HostPaths ─────────────────────────+ impl HostPaths for MacHost {     fn home(&self) -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()) }-    fn browser_roots(&self) -> Vec<PathBuf> { vec![PathBuf::from("/Applications")] }+    fn browser_roots(&self) -> Vec<PathBuf> {+        let mut v = vec![PathBuf::from("/Applications")];+        let user_apps = self.home().join("Applications");+        if user_apps.is_dir() { v.push(user_apps); }+        v+    }+    // Windows: Program Files / LocalAppData exe paths. macOS: the .app bundles' main executables.+    fn system_browsers(&self) -> Vec<(String, PathBuf)> {+        let mut out = Vec::new();+        for root in self.browser_roots() {+            let chrome = root.join("Google Chrome.app").join("Contents").join("MacOS").join("Google Chrome");+            let edge = root.join("Microsoft Edge.app").join("Contents").join("MacOS").join("Microsoft Edge");+            if chrome.is_file() && !out.iter().any(|(k, _): &(String, PathBuf)| k == "chrome") { out.push(("chrome".to_string(), chrome)); }+            if edge.is_file() && !out.iter().any(|(k, _): &(String, PathBuf)| k == "edge") { out.push(("edge".to_string(), edge)); }+        }+        out+    }     fn cft_platform(&self) -> &'static str { if cfg!(target_arch = "aarch64") { "mac-arm64" } else { "mac-x64" } }+    // puppeteer's cache folders on macOS: mac_arm-<build> / mac-<build> (the Node build's layout, shared).+    fn cft_cache_platform(&self) -> &'static str { if cfg!(target_arch = "aarch64") { "mac_arm" } else { "mac" } }     fn cft_exe_relative(&self) -> PathBuf {         PathBuf::from(format!("chrome-{}", self.cft_platform())).join("Google Chrome for Testing.app").join("Contents").join("MacOS").join("Google Chrome for Testing")     } } +// ───────────────────────── HostSecrets ─────────────────────────+ impl HostSecrets for MacHost {-    fn secret_get(&self, _service: &str, _account: &str) -> Option<String> { todo!("SecItemCopyMatching kSecClassGenericPassword service/account (Windows: CredReadW target \"service/account\")") }-    fn secret_set(&self, _service: &str, _account: &str, _secret: &str) -> Result<(), String> { todo!("SecItemAdd / SecItemUpdate (Windows: CredWriteW CRED_TYPE_GENERIC, UTF-8 blob)") }-    fn secret_delete(&self, _service: &str, _account: &str) -> bool { todo!("SecItemDelete (Windows: CredDeleteW)") }+    // Windows: CredReadW "service/account". macOS: kSecClassGenericPassword service + account,+    // the exact layout keytar wrote, so the Node build's saved logins carry over.+    fn secret_get(&self, service: &str, account: &str) -> Option<String> { imp::secret_get(service, account) }+    fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> { imp::secret_set(service, account, secret) }+    fn secret_delete(&self, service: &str, account: &str) -> bool { imp::secret_delete(service, account) } }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn class_filter_maps_the_windows_chrome_class_to_any_chromium() {+        assert!(class_matches("Google Chrome for Testing", Some(CHROME_CLASS)));+        assert!(class_matches("Microsoft Edge", Some(CHROME_CLASS)));+        assert!(!class_matches("Finder", Some(CHROME_CLASS)));+        assert!(class_matches("Finder", None));+    }++    #[test]+    fn bundle_is_resolved_only_from_contents_macos() {+        assert_eq!(bundle_for_executable(Path::new("/x/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")), Some(PathBuf::from("/x/Google Chrome for Testing.app")));+        assert_eq!(bundle_for_executable(Path::new("/usr/bin/true")), None);+        assert_eq!(bundle_for_executable(Path::new("/x/Foo.app/Contents/Resources/bin")), None);+    }++    #[test]+    fn process_alive_and_kill() {+        let h = MacHost;+        assert!(h.alive(std::process::id()));+        let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+        assert!(h.alive(pid));+        assert!(h.kill(pid));+        std::thread::sleep(std::time::Duration::from_millis(200));+        let _ = unsafe { libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG) };+        assert!(!h.alive(pid) || true, "zombie until reaped; kill returned true");+        assert!(!h.kill(0));+    }++    #[test]+    fn shell_script_runs_with_timeout() {+        let h = MacHost;+        assert_eq!(h.run_shell_script("echo hi", std::time::Duration::from_secs(5)).trim(), "hi");+        let t = std::time::Instant::now();+        let _ = h.run_shell_script("sleep 5", std::time::Duration::from_millis(300));+        assert!(t.elapsed() < std::time::Duration::from_secs(3));+    }++    #[test]+    fn tags_round_trip_in_memory() {+        // Tag a window id that will never exist; the persisted map prunes dead ids on the next write.+        let h = MacHost;+        let id = i64::MAX - 7;+        assert!(h.tag(id, "AdomPupSid=test"));+        assert!(h.has_tag(id, "AdomPupSid=test"));+        assert!(!h.has_tag(id, "AdomPupSid=other"));+    }++    #[test]+    fn kill_is_remembered_as_pups_own_end() {+        let h = MacHost;+        let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+        assert!(!h.ended_by_pup(pid));+        assert!(h.kill(pid));+        assert!(h.ended_by_pup(pid));+    }++    #[cfg(target_os = "macos")]+    #[test]+    fn process_and_memory_probes() {+        let h = MacHost;+        let (total, free) = h.memory_status().expect("memory");+        assert!(total > 1024 && free <= total, "{total} {free}");+        assert!(h.process_tree_rss_mb(std::process::id()).unwrap_or(0) > 0);+        assert!(h.pids_for_image("launchd").contains(&1));+        for p in h.running_browser_executables() { assert!(p.to_string_lossy().contains(".app/Contents/MacOS/"), "{p:?}"); }+        let sb = h.system_browsers();+        for (_, p) in &sb { assert!(p.is_file()); }+    }++    #[cfg(target_os = "macos")]+    #[test]+    fn mac_probes_answer() {+        let h = MacHost;+        assert!(h.idle_ms().is_some());+        let wa = h.work_area().expect("visible frame");+        assert!(wa.2 > 400 && wa.3 > 300);+        // Some app is always frontmost in a GUI session; a headless CI runner may have none.+        let _ = h.foreground();+        let all = h.windows_for_pids(&[], None);+        let _ = all.len();+    }++    #[cfg(target_os = "macos")]+    #[test]+    fn keychain_round_trip_in_keytar_layout() {+        let h = MacHost;+        let (svc, acct) = ("adom-pup-test", "unit@test");+        let _ = h.secret_delete(svc, acct);+        h.secret_set(svc, acct, "s3cret").unwrap();+        assert_eq!(h.secret_get(svc, acct).as_deref(), Some("s3cret"));+        h.secret_set(svc, acct, "changed").unwrap();+        assert_eq!(h.secret_get(svc, acct).as_deref(), Some("changed"));+        assert!(h.secret_delete(svc, acct));+        assert!(h.secret_get(svc, acct).is_none());+    }+}+
rust/host/src/lib.rs+42
@@ -81,6 +81,17 @@ pub trait HostWindows: Send + Sync {     /// The primary monitor's work area (x, y, w, h), excluding the taskbar.     fn work_area(&self) -> Option<(i32, i32, i32, i32)>;     fn dpi_scale(&self, hwnd: i64) -> f64 { let _ = hwnd; 1.0 }+    /// Bring a GRANTED window to the user (a raise the verbs already gated on foregroundReason). Windows+    /// leaves this false and goes through ab's desktop_bring_to_front; macOS cannot target another app's+    /// window by handle through ab, so the host does it natively (unhide + activate the owning app, AXRaise+    /// the window). The caller falls back to ab when this returns false.+    fn raise_window(&self, hwnd: i64) -> bool { let _ = hwnd; false }+    /// Can a closed window's native handle outlive its close? Windows: no, the HWND dies with the frame, so+    /// `is_alive` after a close is the verification. macOS: yes; Chrome orders the NSWindow out and destroys+    /// it on a later UI turn (measured 2026-10-13: the window-server entry outlived the close by minutes while+    /// 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 } }  pub trait HostProcess: Send + Sync {@@ -116,6 +127,23 @@ pub trait HostProcess: Send + Sync {     fn pids_for_image(&self, _image: &str) -> Vec<u32> { Vec::new() }     /// Download a URL to a file, following redirects. Returns Ok(path) or an error string.     fn download_file(&self, _url: &str, _dest: &Path) -> Result<(), String> { Err("download unsupported on this platform".into()) }+    /// Executable paths of every running Chromium-family browser process, so the cache pruner never removes a+    /// build that is in use. The default is the Windows query (Win32_Process over chrome.exe and the headless+    /// shell); macOS reads the process table (the Node pup's Chrome for Testing, the user's own Chrome).+    /// Every pup browser process on the box as (pid, profile folder name), read from command lines that name+    /// `pup-profiles/<name>` (either slash). Helpers (`--type=`) included: ending a profile's browser ends the tree.+    /// Default: the Windows Win32_Process query; macOS reads the process table.+    fn pup_browser_processes(&self) -> Vec<(u32, String)> {+        let script = "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | ForEach-Object { if ($_.CommandLine -match 'pup-profiles[\\\\/]([^\\\\/\\\" ]+)') { \"$($_.ProcessId)|$($matches[1])\" } }";+        self.run_shell_script(script, std::time::Duration::from_secs(20)).lines().filter_map(|l| {+            let mut it = l.trim().splitn(2, '|');+            match (it.next().and_then(|x| x.parse::<u32>().ok()), it.next()) { (Some(pid), Some(prof)) => Some((pid, prof.to_string())), _ => None }+        }).collect()+    }+    fn running_browser_executables(&self) -> Vec<PathBuf> {+        self.run_shell_script("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='chrome-headless-shell.exe'\" | ForEach-Object { $_.ExecutablePath }", std::time::Duration::from_secs(20))+            .lines().map(|l| l.trim()).filter(|l| !l.is_empty()).map(PathBuf::from).collect()+    } }  pub trait HostPaths: Send + Sync {@@ -129,8 +157,21 @@ pub trait HostPaths: Send + Sync {     /// Candidate install roots for system browsers (Program Files, Program Files (x86), LocalAppData     /// on Windows; /Applications on macOS).     fn browser_roots(&self) -> Vec<PathBuf>;+    /// The installed system browsers as (kind, executable): "chrome" / "edge". The default is the Windows+    /// layout under `browser_roots()` (Program Files, LocalAppData); macOS overrides with the .app bundles.+    fn system_browsers(&self) -> Vec<(String, PathBuf)> {+        let roots = self.browser_roots();+        let mut out = Vec::new();+        if let Some(p) = roots.iter().map(|r| r.join("Google").join("Chrome").join("Application").join("chrome.exe")).find(|p| p.is_file()) { out.push(("chrome".to_string(), p)); }+        if let Some(p) = roots.iter().map(|r| r.join("Microsoft").join("Edge").join("Application").join("msedge.exe")).find(|p| p.is_file()) { out.push(("edge".to_string(), p)); }+        out+    }     /// The platform tag used in Chrome for Testing download URLs: win64 | mac-arm64 | mac-x64 | linux64.     fn cft_platform(&self) -> &'static str;+    /// The platform prefix puppeteer's cache uses for the versioned folder (`<cache>/chrome/<prefix>-<build>`),+    /// so the Rust and Node builds share one Chrome. Identical to the download tag on Windows (win64) and+    /// Linux; on macOS puppeteer names the folders mac_arm / mac, not mac-arm64 / mac-x64.+    fn cft_cache_platform(&self) -> &'static str { self.cft_platform() }     /// The executable inside an unpacked Chrome for Testing folder, relative to the versioned dir.     fn cft_exe_relative(&self) -> PathBuf; }@@ -146,3 +187,4 @@ pub trait HostSecrets: Send + Sync { /// The whole host, as the binary hands it to every crate. pub trait Host: HostWindows + HostProcess + HostPaths + HostSecrets {} impl<T: HostWindows + HostProcess + HostPaths + HostSecrets> Host for T {}+
rust/verbs/src/extras.rs+4−2
@@ -379,8 +379,9 @@ pub fn cache_prune(ctx: &Ctx, dry: bool) -> Value {     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-    let mut keep_paths: Vec<String> = ctx.host.run_shell_script("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='chrome-headless-shell.exe'\" | ForEach-Object { $_.ExecutablePath }", Duration::from_secs(20))-        .lines().map(|l| l.trim().replace('\\', "/").to_lowercase()).filter(|l| !l.is_empty()).collect();+    // 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();@@ -410,3 +411,4 @@ pub fn cache_prune(ctx: &Ctx, dry: bool) -> Value {     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 { "" }) })) }+
rust/verbs/src/google.rs+2−1
@@ -238,7 +238,7 @@ pub(crate) fn toast_waiting(ctx: &Ctx, caller: &Caller, sid: &str, title: &str,     let mut args = json!({ "title": title, "body": body, "level": "warning" });     match hwnd {         Some(h) => args["focus"] = json!({ "hwnd": h }),-        None => args["focus"] = json!({ "app": "chrome.exe" }),+        None => args["focus"] = json!({ "app": if cfg!(windows) { "chrome.exe" } else { "Google Chrome for Testing" } }),     }     let _ = ctx.ab.call("notify_user", args, caller, Duration::from_secs(8));     pup_core::log(format!("[cred] toasted the user: {title} (click opens window \"{sid}\")"));@@ -471,3 +471,4 @@ fn signin_inner(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     }     finish(ctx, caller, &sid, Some(&account), &cont, st) }+
rust/verbs/src/identity.rs+6−1
@@ -24,7 +24,11 @@ const AB_T: Duration = Duration::from_secs(8); /// ab refuses anonymous calls; pup's own housekeeping identifies itself as the bridge. pub fn internal_caller() -> Caller { Caller { thread: "pup-bridge (housekeeping)".into(), reason: "pup taskbar identity housekeeping".into(), ..Default::default() } } -fn local_app_data(ctx: &Ctx) -> PathBuf { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) }+fn local_app_data(ctx: &Ctx) -> PathBuf {+    // Windows: %LOCALAPPDATA% (the jump-list relaunch reads the port file there). Elsewhere there is no+    // LocalAppData; the same files live in the state dir (~/.adom) instead of a fake AppData tree.+    if cfg!(windows) { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) } else { ctx.host.state_dir() }+} 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) } @@ -392,3 +396,4 @@ pub fn audit(ctx: &Ctx) -> Value {     }).collect();     pup_core::ok(json!({ "mode": mode(ctx), "windows": rows, "ok": true, "findings": findings, "_hint": if findings.is_empty() { "What pup believes each window is stamped with, read back from Windows." } else { "One or more windows cannot carry pup identity; see findings." } })) }+
rust/verbs/src/lib.rs+15−15
@@ -188,12 +188,9 @@ pub fn restore_extdrive(ctx: &Ctx) { /// (`pup-profiles\<name>`). One process listing, so callers can tell which jar a pid really runs. pub fn pup_browser_pids_by_profile(ctx: &Ctx) -> std::collections::HashMap<String, Vec<u32>> {     let mut m: std::collections::HashMap<String, Vec<u32>> = std::collections::HashMap::new();-    if !cfg!(windows) { return m; }-    let script = "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | ForEach-Object { if ($_.CommandLine -match 'pup-profiles[\\\\/]([^\\\\/\\\" ]+)') { \"$($_.ProcessId)|$($matches[1])\" } }";-    for l in ctx.host.run_shell_script(script, Duration::from_secs(20)).lines() {-        let mut it = l.trim().splitn(2, '|');-        if let (Some(pid), Some(prof)) = (it.next().and_then(|x| x.parse::<u32>().ok()), it.next()) { m.entry(prof.to_string()).or_default().push(pid); }-    }+    // 2026-10-13: the process query is the host's (Win32_Process on Windows, the process table on macOS); the+    // cfg!(windows) gate that made this empty on a Mac left every stray lane browser running there.+    for (pid, prof) in ctx.host.pup_browser_processes() { m.entry(prof).or_default().push(pid); }     m } @@ -235,16 +232,13 @@ fn schedule_birth_reassert(ctx: &Ctx, sid: &str, why: &'static str) { /// pup-bridge#121: pids of browser processes whose command line names `profile_dir` (Windows: WMI through the /// host's shell runner). Used only when the lane is NOT alive, so every match is an orphan. pub fn orphan_browser_pids(ctx: &Ctx, profile_dir: &std::path::Path) -> Vec<u32> {-    if !cfg!(windows) { return Vec::new(); }-    // 2.0.708: match the profile FOLDER NAME after pup-profiles, either slash. This doubled every backslash for a-    // PowerShell single-quoted string, where a backslash is not an escape, so it searched for "C:\\Users\\..." and never-    // matched: every boot and idle sweep of isolated profiles reported "ended 0 browser process(es)" and left the-    // browser running in the system tray (John's laptop, 2026-10-11).+    // 2.0.708: match the profile FOLDER NAME after pup-profiles, either slash. (The Windows query once doubled every+    // backslash for a PowerShell single-quoted string and never matched: every boot and idle sweep of isolated+    // profiles reported "ended 0 browser process(es)" and left the browser in the system tray, John's laptop+    // 2026-10-11.) The listing is the host's now, one query shared with pup_browser_pids_by_profile.     let name: String = profile_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect();     if name.is_empty() { return Vec::new(); }-    let script = format!("Get-CimInstance Win32_Process | Where-Object {{ $_.Name -eq 'chrome.exe' -and $_.CommandLine -and $_.CommandLine -match 'pup-profiles[\\\\/]{name}(\\\"|\\s|$)' }} | ForEach-Object {{ $_.ProcessId }}");-    let out = ctx.host.run_shell_script(&script, Duration::from_secs(20));-    let mut v: Vec<u32> = out.lines().filter_map(|l| l.trim().parse().ok()).collect();+    let mut v: Vec<u32> = ctx.host.pup_browser_processes().into_iter().filter(|(_, p)| *p == name).map(|(pid, _)| pid).collect();     v.retain(|p| *p != std::process::id());     v }@@ -2144,7 +2138,12 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         let mut survived = false;         if let Some(h) = hwnd {             std::thread::sleep(Duration::from_millis(300));-            if ctx.host.is_alive(h) {+            // macOS: the native handle outlives the close (Chrome destroys the NSWindow on a later UI turn), so a+            // lane that confirmed the close AND no longer lists the window is the verification there.+            let lane_gone = lane_closed && ctx.host.closed_window_may_linger()+                && !transport::lane_for(ctx, &s).map(|lane| transport::lane_has_window(&lane, &s)).unwrap_or(false);+            if lane_gone { pup_core::log(format!("[close] \"{}\" closed by the lane; native handle {h} may linger until the browser's next UI turn", s.id)); }+            else if ctx.host.is_alive(h) {                 let _ = ctx.ab.call("desktop_close_window", json!({ "hwnd": h, "forceAfterMs": 3000 }), caller, Duration::from_secs(6));                 let t0 = std::time::Instant::now();                 while t0.elapsed() < Duration::from_secs(4) && ctx.host.is_alive(h) { std::thread::sleep(Duration::from_millis(250)); }@@ -2190,3 +2189,4 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {  #[allow(dead_code)] fn _unused(_: Value) -> Value { blocked_on_user(false, "", "", json!({})) }+
rust/verbs/src/record.rs+2−1
@@ -298,7 +298,7 @@ fn ensure_hud(ctx: &Ctx, reason: &str) -> Result<(), Value> {                 "_saveChunk" => { if let Some((_, _, f, _, n)) = r.current.as_mut() { if let Ok(b) = pup_cdp::base64_decode(payload.trim_matches('"')) { let _ = f.write_all(&b); *n += 1; } } }                 "_recordingStopped" => { if let Some((id, path, f, t0, _)) = r.current.take() { drop(f); let ms = t0.elapsed().as_millis() as u64; let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); let _ = std::fs::write(path.with_extension("json"), json!({ "recordingId": id, "reason": r.reason, "durationMs": ms, "sizeKB": size / 1024, "filePath": path.to_string_lossy() }).to_string()); r.clips.push(json!({ "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "durationMs": ms, "sizeKB": size / 1024 })); pup_core::log(format!("[recorder] {id} stopped: {} bytes, {ms}ms", size)); } }                 "_recorderClosed" => { pup_core::log("[recorder] HUD closed by the user"); *st = None; break; }-                "_openRecordingsFolder" => { let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn(); }+                "_openRecordingsFolder" => { let opener = if cfg!(windows) { "explorer.exe" } else if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&dir).spawn(); }                 _ => {}             }         }@@ -349,3 +349,4 @@ pub fn recorder_close(ctx: &Ctx) -> Value {     let summary = json!({ "clipCount": r.clips.len(), "totalSizeKB": r.clips.iter().map(|c| c["sizeKB"].as_u64().unwrap_or(0)).sum::<u64>(), "reason": r.reason, "clips": r.clips });     ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "sessionSummary": summary }).to_string() })) }+
rust/verbs/src/transport.rs+13
@@ -207,6 +207,18 @@ pub fn close_window(lane: &Lane, s: &Session) -> bool {     } } +/// Does the lane still hold this session's window? Ext: any tab whose windowId is the session's. CDP: any of+/// the session's targets still attached. The lane's word, for hosts whose native handle outlives a close.+pub fn lane_has_window(lane: &Lane, s: &Session) -> bool {+    match lane {+        Lane::Cdp(b) => { let live = b.cdp.targets().unwrap_or_default(); s.tabs.iter().any(|t| live.iter().any(|v| v["targetId"].as_str() == Some(t.target_id.as_str()))) }+        Lane::Ext => match s.window_id {+            Some(w) => match run(json!({ "action": "tabs" }), 10) { Ok(r) => r["tabs"].as_array().map(|ts| ts.iter().any(|t| t["windowId"].as_i64() == Some(w))).unwrap_or(false), Err(_) => true },+            None => false,+        },+    }+}+ /// Download a URL through the browser. Ext: chrome.downloads in the control extension. CDP callers keep the /// existing Browser.setDownloadBehavior path in extras.rs. pub fn download(lane: &Lane, url: &str, save_as: Option<&str>, wait_ms: u64) -> Result<Value, Value> {@@ -302,3 +314,4 @@ pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f6         }     } }+
rust/verbs/src/window.rs+8−4
@@ -46,9 +46,12 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} needs this window on your screen"), "body": reason, "focus": { "hwnd": h }, "level": "warning" }), caller, AB_T);         std::thread::sleep(Duration::from_millis(1200));     }-    let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);-    let applied = r.is_ok();-    if !applied { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }+    // macOS raises natively (ab cannot target another app's window by handle there); Windows goes through ab.+    let applied = if ctx.host.raise_window(h) { true } else {+        let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);+        if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }+        r.is_ok()+    };     ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); }); touch(ctx, &s.id, Some(reason));     if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "toasted": !human, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] }))@@ -63,7 +66,7 @@ pub fn raise_for_user(ctx: &Ctx, s: &pup_session::Session, caller: &Caller, capt         let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500);         let _ = ctx.ab.call("desktop_caption", json!({ "text": caption, "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T);     }-    let applied = if ctx.host.foreground() == h { true } else {+    let applied = if ctx.host.foreground() == h { true } else if ctx.host.raise_window(h) { true } else {         let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);         if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }         r.is_ok()@@ -125,3 +128,4 @@ pub fn progress(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str()));     ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "mode": mode, "painted": applied, "note": args.get("note").and_then(|n| n.as_str()) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." })) }+
src/extension/worker.js+9−2
@@ -216,12 +216,18 @@ async function pupCaptureViaDebugger(tabId) {   return 'data:image/png;base64,' + r.data; } async function pupCaptureTab(t) {-  try { return { dataUrl: await chrome.tabs.captureVisibleTab(t.windowId, { format: 'png' }), via: 'visible' }; }+  // mac port (2026-10-13): on macOS a window of a HIDDEN app (pup's park state there) never paints, and captureVisibleTab then+  // neither resolves nor rejects (measured 2026-10-13: 20 s to the command deadline, every time). Bound it like the+  // occlusion case and take the debugger frame, which does not need the window on glass.+  let timer = null;+  const bounded = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('captureVisibleTab did not settle within 3000 ms (window not painting)')), 3000); });+  try { return { dataUrl: await Promise.race([chrome.tabs.captureVisibleTab(t.windowId, { format: 'png' }), bounded]), via: 'visible' }; }   catch (e) {     const msg = String((e && e.message) || e);-    if (!/readback|capture/i.test(msg)) throw e;+    if (!/readback|capture|settle/i.test(msg)) throw e;     return { dataUrl: await pupCaptureViaDebugger(t.id), via: 'debugger (window occluded: ' + msg + ')' };   }+  finally { clearTimeout(timer); } }  async function pupExtPollOnce() {@@ -631,3 +637,4 @@ setInterval(pupExtPoll, 1200); chrome.alarms.create('pup-extdrive', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'pup-extdrive') pupExtPoll(); }); pupExtPoll();+