app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
v2.0.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).
20 files changed
+1060−73
rust/Cargo.lock+170−12rust/Cargo.toml+1−1rust/browser/src/lib.rs+5−9rust/host-mac/Cargo.toml+13−1rust/host-mac/examples/fg_probe.rs+10rust/host-mac/examples/hide_ctl.rs+9rust/host-mac/examples/live_probe.rs+37rust/host-mac/examples/procs.rs+3rust/host-mac/examples/title_probe.rs+9rust/host-mac/examples/wins.rs+3rust/host-mac/src/lib.rs+699−24rust/host/src/lib.rs+42rust/verbs/src/extras.rs+4−2rust/verbs/src/google.rs+2−1rust/verbs/src/identity.rs+6−1rust/verbs/src/lib.rs+15−15rust/verbs/src/record.rs+2−1rust/verbs/src/transport.rs+13rust/verbs/src/window.rs+8−4src/extension/worker.js+9−2rust/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();+