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.722 (insiders): Mac groundwork, part two (Kyle's pull request #4): a capture guard so a screenshot of a pup window…
9cf30e3
27d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
//! 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 {}