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.
master
John Lauer
v2.0.730 (insiders): pup_eval and pup_login work on strict pages again (pup-bridge#130): American Express's login page…
705edb1
21d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
//! 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).
//!
//! 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()
}
/// Apps capture_frames_begin unhid, for capture_frames_end to park again.
// Depth-counted so two concurrent captures share one unhide/re-hide (John's review on PR #4): the apps list
// is set by the OUTERMOST begin and re-hidden by the LAST end; inner calls only bump the count.
fn capture_unhidden() -> &'static Mutex<(usize, Vec<u32>)> {
static G: std::sync::OnceLock<Mutex<(usize, Vec<u32>)>> = std::sync::OnceLock::new();
G.get_or_init(|| Mutex::new((0, Vec::new())))
}
/// Is the whole app that owns `pid` hidden (Cmd+H state)? None when the pid has no app.
fn app_hidden(pid: u32) -> Option<bool> { imp::app_hidden(pid) }
/// The user is using this app right now: it is the frontmost application.
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 {
// 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 }
}
// 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 }
// Unhide (no activation) every hidden app among pup's own browser processes, remember them, and hide them
// again in capture_frames_end. The user's own Chrome is never touched (pup-profiles marker), and an app the
// user has frontmost is left alone in both directions.
fn capture_frames_begin(&self) -> bool {
let mut g = capture_unhidden().lock().unwrap();
if g.0 > 0 { g.0 += 1; return !g.1.is_empty(); } // a capture is already holding the apps unhidden
let mut apps: Vec<u32> = Vec::new();
for (pid, _) in self.pup_browser_processes() {
if app_hidden(pid) == Some(true) && !app_is_frontmost(pid) && !apps.contains(&pid) { apps.push(pid); }
}
g.0 = 1;
if apps.is_empty() { return false; }
for pid in &apps { imp::set_app_hidden(*pid, false); }
// one compositor turn so the first capture after the unhide finds a frame
std::thread::sleep(std::time::Duration::from_millis(150));
g.1 = apps;
true
}
fn capture_frames_end(&self) {
let mut g = capture_unhidden().lock().unwrap();
g.0 = g.0.saturating_sub(1);
if g.0 > 0 { return; }
let apps = std::mem::take(&mut g.1);
for pid in apps { if !app_is_frontmost(pid) { imp::set_app_hidden(pid, true); } }
}
// Windows: FlashWindowEx(FLASHW_STOP). No taskbar flash on macOS; nothing to stop.
fn stop_flash(&self, _hwnd: i64) -> bool { false }
// A GRANTED raise (the verbs gate it on foregroundReason): unhide + activate the owning app, then
// 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 {
// 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> {
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 {
// 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());
}
}