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());
    }
}