master
John Lauer v2.0.722 (insiders): Mac groundwork, part two (Kyle's pull request #4): a capture guard so a screenshot of a pup window… 9cf30e3 27d ago
//! pup-host: the OS traits and NOTHING else. This crate is the border between pup and the
//! operating system, and the border between the Windows build and Kyle's macOS build.
//!
//! Rules (tools/check-borders.sh enforces them at ship time):
//!   - no `cfg(windows)`, no `windows_sys`, no `osascript`, no PowerShell in this crate;
//!   - `pup-verbs`, `pup-session`, `pup-identity` depend on THESE traits, never on an impl;
//!   - only the binary picks the implementation, per target (like fusion's os-win / os-mac).
//!
//! Everything in the taskbar family (AUMID, jump lists, overlay badges, progress, flash, toasts)
//! is deliberately NOT here: it is ab's job (`AbClient` in pup-core), so a macOS host does not
//! have to invent Dock equivalents.

use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, PartialEq)]
pub struct HostWindow {
    pub hwnd: i64,
    pub pid: u32,
    pub title: String,
    pub class: String,
    /// left, top, right, bottom in screen pixels
    pub rect: (i32, i32, i32, i32),
    pub visible: bool,
}

/// Top-level OS windows: find, tag, and z-order them WITHOUT ever activating one.
pub trait HostWindows: Send + Sync {
    /// Every visible top-level window owned by one of `pids` (Windows: class Chrome_WidgetWin_1
    /// when `class_filter` is given). Empty on platforms without native windows support yet.
    fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow>;
    /// Every top-level FRAME window (unowned, captioned) of the class, visible or not. Birth uses it to
    /// catch a newborn browser window before Chrome shows it (2.0.446). Empty off Windows.
    fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> { let _ = class_filter; Vec::new() }
    /// Write the AppUserModel identity (id, relaunch command, display name, icon) straight onto the
    /// window's property store, in-process, and set its window icon: what ab's desktop_set_window_identity
    /// does, minus the round-trip, so it can land in the milliseconds before Chrome's first show.
    fn stamp_identity(&self, hwnd: i64, app_id: &str, display: &str, icon: &str, relaunch: &str) -> bool { let _ = (hwnd, app_id, display, icon, relaunch); false }
    fn is_alive(&self, hwnd: i64) -> bool;
    /// Tag a window with a pup-owned marker (Windows: SetProp "AdomPupSid=<sid>") so it can be
    /// recognised after a bridge restart. Returns false when tagging is unsupported.
    fn tag(&self, hwnd: i64, tag: &str) -> bool;
    /// The (target, argument string) that runs a pup helper script with `tail` appended, for
    /// jump-list tasks and taskbar relaunch commands (Windows: a hidden PowerShell). `quote_for_relaunch`
    /// escapes the script path the way the relaunch-command string needs it.
    fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String);
    fn has_tag(&self, hwnd: i64, tag: &str) -> bool;
    /// Bottom of the z-order, never activated (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE).
    /// ab exposes the same as desktop_set_window_state {state:"bottom"}; this is the in-process
    /// fallback for the birth moment, when a round-trip would let the window flash on top.
    fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool;
    /// ShowWindow(SW_HIDE) / ShowWindow(SW_SHOWNOACTIVATE): a hidden window has no taskbar button; showing it
    /// creates the button ONCE with whatever identity the window carries by then.
    fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool { let _ = (hwnd, visible); false }
    /// 2.0.612: SetWindowPos(HWND_BOTTOM, SWP_SHOWWINDOW | SWP_NOACTIVATE): make a hidden window visible
    /// ALREADY at the bottom, in ONE call. Showing first and lowering after leaves a frame where the user
    /// sees it in front, which is the whole complaint.
    fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool { let _ = hwnd; false }
    /// ITaskbarList::DeleteTab / AddTab: remove or (re)create the taskbar button WITHOUT touching the
    /// window (no hide, so Chrome queues no activation). AddTab creates the button fresh with whatever
    /// AUMID/icon the window carries at that moment.
    fn taskbar_tab(&self, hwnd: i64, present: bool) -> bool { let _ = (hwnd, present); false }
    /// Move/resize without activation or z-order change.
    fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool;
    fn foreground(&self) -> i64;
    /// 2.0.637: stop a taskbar flash / clear the attention tint NATIVELY (FlashWindowEx FLASHW_STOP). Never
    /// activates or re-orders anything. Replaces ab's desktop_clear_window_attention on pup's own paths, which
    /// RAISES and briefly ACTIVATES the window (adom-bridge#199; z-watch measured 40-80 ms of the newborn as the
    /// foreground window on every birth).
    fn stop_flash(&self, hwnd: i64) -> bool { let _ = hwnd; false }
    /// 2.0.640: spin (sub-millisecond) until `hwnd` becomes visible, order it to the bottom in that same
    /// instant, and keep it there for the first 300 ms unless the user has made it the foreground. Returns
    /// the ms until the first show, None if it never showed within `timeout_ms`. A per-poll EnumWindows was
    /// 22 ms behind Chrome's first show (z-watch, 2.0.639); the compositor paints a frame in 16.
    fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> { let _ = (hwnd, timeout_ms); None }
    /// 2.0.666: the subkey names under HKCU\Software\Classes\AppUserModelId that start with `prefix`
    /// (pup's own identities), so a boot sweep can unregister the ones no live window holds. John found
    /// 116 dead thread identities on arav-rog on 2026-09-20 ("are you still leaving aumid icon residue?").
    fn list_aumids(&self, prefix: &str) -> Vec<String> { let _ = prefix; Vec::new() }
    /// Milliseconds since the last keyboard/mouse input (GetLastInputInfo); None where unknown.
    fn idle_ms(&self) -> Option<u64> { None }
    /// 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 }
    /// Make pup's PARKED browsers produce frames for a capture. Windows: a bottomed window keeps painting
    /// (occlusion calculation is off), nothing to do. macOS: a window of a hidden app stops rendering after a
    /// while and Page.captureScreenshot / captureVisibleTab then hang (measured 2026-10-14: every capture of a
    /// window hidden for a minute timed out; the same window answered in under a second once its app was
    /// unhidden behind the user's app). So the host unhides pup's own hidden browsers WITHOUT activating them
    /// (they come back at their old z positions, behind the user's work), and `capture_frames_end` hides them
    /// again. Returns true when something was unhidden, so the caller knows to call end.
    fn capture_frames_begin(&self) -> bool { false }
    fn capture_frames_end(&self) {}
}

pub trait HostProcess: Send + Sync {
    fn alive(&self, pid: u32) -> bool;
    /// (total MB, available MB) of physical RAM on this machine. None where it cannot be read.
    /// 2.0.462: an AI opening pages on someone's laptop has to be able to see what it is spending.
    fn memory_status(&self) -> Option<(u64, u64)> { None }
    /// Resident memory (MB) of a process AND its descendants: a browser plus every renderer and GPU process.
    fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> { let _ = pid; None }
    /// Spawn a browser DETACHED from this bridge (it must survive a bridge restart). Returns the pid.
    fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32>;
    fn kill(&self, pid: u32) -> bool;
    /// 2.0.595: keep a handle on a browser pup launched so its EXIT CODE is readable after it is gone. A
    /// browser whose last window the user closed exits 0; a crash does not. Recovery restores only a crash.
    fn watch_process(&self, pid: u32) { let _ = pid; }
    /// The exit code of a watched process that has exited: None while it runs, or if it was never watched.
    fn exit_code(&self, pid: u32) -> Option<u32> { let _ = pid; None }
    /// Did PUP end this process itself (Host::kill)? 2.0.600: pup's own kill is NOT the user closing a
    /// window, and conflating them lost windows (see recovery::browser_gone).
    fn ended_by_pup(&self, pid: u32) -> bool { let _ = pid; false }
    /// Run a script through the platform shell and return stdout (Windows: powershell -Command; empty
    /// on platforms without one). For the credential-import scan and the real-Chrome installer.
    fn run_shell_script(&self, _script: &str, _timeout: std::time::Duration) -> String { String::new() }
    /// Extension ids Chrome was told about MACHINE-WIDE (HKCU/HKLM ...\Chrome\Extensions). Chrome offers
    /// each to every NEW profile and raises the "Another program on your computer added an extension"
    /// bubble; pup pre-acknowledges them per profile. Empty off Windows.
    fn external_extension_ids(&self) -> Vec<String> { Vec::new() }
    /// The installed CONSUMER Chrome version (e.g. "152.0.7936.90"), so pup can present exactly the browser
    /// the user runs. None when there is no installed Chrome (CfT-only box) or off Windows.
    fn installed_chrome_version(&self) -> Option<String> { None }
    /// Every live pid running `image` (e.g. "chrome.exe"). Used to find a browser pup launched but cannot
    /// address by the pid it spawned: Chrome's launcher process exits at once and hands off to a new pid.
    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 {
    fn home(&self) -> PathBuf;
    /// ~/.adom
    fn state_dir(&self) -> PathBuf { self.home().join(".adom") }
    /// Where Chrome for Testing lives. Same layout as puppeteer's cache so the two builds share it:
    /// <cache>/chrome/<platform>-<build>/chrome-<platform>/chrome(.exe)
    fn cft_cache_dir(&self) -> PathBuf { self.home().join(".cache").join("puppeteer") }
    fn temp_dir(&self) -> PathBuf { std::env::temp_dir() }
    /// 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;
}

/// The OS secret store (Windows Credential Manager / macOS Keychain). Target names follow keytar's
/// `service/account` layout so the vault the Node build wrote is readable by this build.
pub trait HostSecrets: Send + Sync {
    fn secret_get(&self, service: &str, account: &str) -> Option<String>;
    fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String>;
    fn secret_delete(&self, service: &str, account: &str) -> bool;
}

/// 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 {}