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.727 (insiders): A pup window waiting on a slow page now explains itself instead of showing a blank PUPBIRTH tab: w…
b75d102
22d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
//! pup-host-win: the Windows host. The ONLY crate in the workspace that may name windows_sys.
//! Everything here mirrors what src/server.js + src/win32.js did with koffi and PowerShell; the
//! matching Node function is named on each method so the behaviour can be cross-checked.
use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows};
use std::path::{Path, PathBuf};
pub struct WinHost;
#[cfg(windows)]
mod imp {
use super::*;
use std::ffi::c_void;
use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE};
use windows_sys::Win32::UI::HiDpi::GetDpiForWindow;
use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetClassNameW, GetForegroundWindow, GetPropW, GetWindow, GetWindowLongPtrW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible,
LoadImageW, PostMessageW, SetPropW, SetWindowPos, SystemParametersInfoW, GWL_STYLE, GW_OWNER, HWND_BOTTOM, IMAGE_ICON, LR_LOADFROMFILE, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, WM_SETICON, WS_CAPTION,
};
fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }
struct EnumCtx { pids: Vec<u32>, class: Option<String>, out: Vec<HostWindow>, frames_all: bool }
unsafe extern "system" fn enum_cb(hwnd: HWND, lp: LPARAM) -> BOOL {
let ctx = &mut *(lp as *mut EnumCtx);
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
if !ctx.pids.is_empty() && !ctx.pids.contains(&pid) { return 1; }
let mut cls = [0u16; 128];
let n = GetClassNameW(hwnd, cls.as_mut_ptr(), cls.len() as i32);
let class = String::from_utf16_lossy(&cls[..n.max(0) as usize]);
if let Some(want) = &ctx.class { if &class != want { return 1; } }
let visible = IsWindowVisible(hwnd) != 0;
if ctx.frames_all {
// frame windows only: unowned and captioned (a browser frame; not Chrome's tooltips, popups, or helper windows)
if !GetWindow(hwnd, GW_OWNER).is_null() { return 1; }
if (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32) & WS_CAPTION != WS_CAPTION { return 1; }
} else if !visible { return 1; }
let mut t = [0u16; 512];
let tn = GetWindowTextW(hwnd, t.as_mut_ptr(), t.len() as i32);
let title = String::from_utf16_lossy(&t[..tn.max(0) as usize]);
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
GetWindowRect(hwnd, &mut r);
ctx.out.push(HostWindow { hwnd: hwnd as i64, pid, title, class, rect: (r.left, r.top, r.right, r.bottom), visible });
1
}
impl HostWindows for WinHost {
// Node: enumChromeWidgetWindows (via ab desktop_enum_windows) / win32.findChromeWindowByTitle
fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {
let mut ctx = EnumCtx { pids: pids.to_vec(), class: class_filter.map(|s| s.to_string()), out: Vec::new(), frames_all: false };
unsafe { EnumWindows(Some(enum_cb), &mut ctx as *mut EnumCtx as LPARAM); }
ctx.out
}
fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> {
let mut ctx = EnumCtx { pids: Vec::new(), class: class_filter.map(|s| s.to_string()), out: Vec::new(), frames_all: true };
unsafe { EnumWindows(Some(enum_cb), &mut ctx as *mut EnumCtx as LPARAM); }
ctx.out
}
// ab window_identity.rs set_identity, in-process: IPropertyStore on the window (SHGetPropertyStoreForWindow),
// PKEY_AppUserModel_ID / RelaunchCommand / RelaunchDisplayNameResource / RelaunchIconResource as VT_LPWSTR,
// Commit, then WM_SETICON big+small from the .ico. Raw COM like taskbar_tab (no windows-rs dependency).
fn stamp_identity(&self, hwnd: i64, app_id: &str, display: &str, icon: &str, relaunch: &str) -> bool {
#[repr(C)] struct Guid { d1: u32, d2: u16, d3: u16, d4: [u8; 8] }
#[repr(C)] struct PropKey { fmtid: Guid, pid: u32 }
#[repr(C)] struct PropVariant { vt: u16, r1: u16, r2: u16, r3: u16, val: *const u16, pad: usize }
#[repr(C)] struct VTable { qi: usize, add_ref: usize, release: unsafe extern "system" fn(*mut c_void) -> u32, get_count: usize, get_at: usize, get_value: usize,
set_value: unsafe extern "system" fn(*mut c_void, *const PropKey, *const PropVariant) -> i32, commit: unsafe extern "system" fn(*mut c_void) -> i32 }
#[repr(C)] struct Obj { vt: *const VTable }
#[link(name = "shell32")] extern "system" { fn SHGetPropertyStoreForWindow(hwnd: HWND, riid: *const Guid, out: *mut *mut c_void) -> i32; }
extern "system" { fn CoInitializeEx(reserved: *mut c_void, coinit: u32) -> i32; }
const IID_STORE: Guid = Guid { d1: 0x886D8EEB, d2: 0x8CF2, d3: 0x4446, d4: [0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99] };
const FMT: Guid = Guid { d1: 0x9F4C2855, d2: 0x9F79, d3: 0x4B39, d4: [0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3] };
let (w_id, w_cmd, w_name, w_icon) = (wide(app_id), wide(relaunch), wide(display), wide(icon));
unsafe {
let _ = CoInitializeEx(std::ptr::null_mut(), 0x2);
let mut p: *mut c_void = std::ptr::null_mut();
if SHGetPropertyStoreForWindow(hwnd as HWND, &IID_STORE, &mut p) < 0 || p.is_null() { return false; }
let vt = &*(*(p as *mut Obj)).vt;
let set = |pid: u32, w: &Vec<u16>| -> bool { let pv = PropVariant { vt: 31 /* VT_LPWSTR */, r1: 0, r2: 0, r3: 0, val: w.as_ptr(), pad: 0 }; (vt.set_value)(p, &PropKey { fmtid: FMT, pid }, &pv) >= 0 };
let ok = set(5, &w_id) & set(2, &w_cmd) & set(4, &w_name) & set(3, &w_icon);
let committed = (vt.commit)(p) >= 0;
(vt.release)(p);
// Window icon AFTER the commit and POSTED, never sent: Chrome's UI thread is mid-initialisation at
// this moment and a SendMessage would wait on it (measured 2.0.446: 60 to 80 ms, leaving a 4 ms
// margin before the first show). The button's picture resolves through the AUMID anyway.
for (cx, which) in [(32, 1usize), (16, 0usize)] {
let h = LoadImageW(std::ptr::null_mut(), w_icon.as_ptr(), IMAGE_ICON, cx, cx, LR_LOADFROMFILE);
if !h.is_null() { PostMessageW(hwnd as HWND, WM_SETICON, which, h as isize); }
}
ok && committed
}
}
// Node: win32.isWindow / osHwndAlive
fn is_alive(&self, hwnd: i64) -> bool { unsafe { IsWindow(hwnd as HWND) != 0 } }
// Node: tagWindowSession (SetProp "AdomPupSid=<sid>" = 1)
fn tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { SetPropW(hwnd as HWND, wide(tag).as_ptr(), 1 as *mut c_void) != 0 } }
fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {
let q = if quote_for_relaunch { "\\\"" } else { "\"" };
("powershell.exe".into(), format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File {q}{}{q} {tail}", script.display()))
}
// Node: win32.hasProp
fn has_tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { !GetPropW(hwnd as HWND, wide(tag).as_ptr()).is_null() } }
// Node: osBottomUnlessForeground's in-process leg (SetWindowPos HWND_BOTTOM, NOACTIVATE)
fn taskbar_tab(&self, hwnd: i64, present: bool) -> bool {
// ITaskbarList {56FDF342-FD6D-11d0-958A-006097C9A090}, CLSID TaskbarList {56FDF344-FD6D-11d0-958A-006097C9A090}
#[repr(C)] struct Guid { d1: u32, d2: u16, d3: u16, d4: [u8; 8] }
#[repr(C)] struct VTable { qi: usize, add_ref: usize, release: unsafe extern "system" fn(*mut c_void) -> u32, hr_init: unsafe extern "system" fn(*mut c_void) -> i32, add_tab: unsafe extern "system" fn(*mut c_void, HWND) -> i32, delete_tab: unsafe extern "system" fn(*mut c_void, HWND) -> i32 }
#[repr(C)] struct Obj { vt: *const VTable }
extern "system" { fn CoInitializeEx(reserved: *mut c_void, coinit: u32) -> i32; fn CoCreateInstance(clsid: *const Guid, outer: *mut c_void, ctx: u32, iid: *const Guid, out: *mut *mut c_void) -> i32; }
const CLSID: Guid = Guid { d1: 0x56FDF344, d2: 0xFD6D, d3: 0x11d0, d4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90] };
const IID: Guid = Guid { d1: 0x56FDF342, d2: 0xFD6D, d3: 0x11d0, d4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90] };
unsafe {
let _ = CoInitializeEx(std::ptr::null_mut(), 0x2 /* APARTMENTTHREADED */);
let mut p: *mut c_void = std::ptr::null_mut();
if CoCreateInstance(&CLSID, std::ptr::null_mut(), 0x1 /* INPROC_SERVER */, &IID, &mut p) < 0 || p.is_null() { return false; }
let obj = p as *mut Obj; let vt = &*(*obj).vt;
let ok = (vt.hr_init)(p) >= 0 && (if present { (vt.add_tab)(p, hwnd as HWND) } else { (vt.delete_tab)(p, hwnd as HWND) }) >= 0;
(vt.release)(p);
ok
}
}
fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> {
extern "system" { fn IsWindowVisible(hwnd: HWND) -> i32; fn GetForegroundWindow() -> HWND; }
let t0 = std::time::Instant::now();
unsafe {
while IsWindowVisible(hwnd as HWND) == 0 {
if t0.elapsed().as_millis() as u64 > timeout_ms { return None; }
std::thread::sleep(std::time::Duration::from_micros(100));
}
let shown = t0.elapsed().as_millis() as u64;
SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
let t1 = std::time::Instant::now();
// 2.0.641: Chrome re-tops the window during its first frames (z-watch: a 22 ms episode inside the
// 1 ms-cadence hold). Spin without sleeping for the first 100 ms so the gap between Chrome's
// re-top and pup's re-bottom is microseconds, under one composed frame; then 1 ms until 300 ms.
while t1.elapsed().as_millis() < 300 {
if GetForegroundWindow() as i64 != hwnd { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER); }
if t1.elapsed().as_millis() < 100 { std::thread::yield_now(); } else { std::thread::sleep(std::time::Duration::from_millis(1)); }
}
Some(shown)
}
}
fn list_aumids(&self, prefix: &str) -> Vec<String> {
extern "system" {
fn RegOpenKeyExW(hkey: isize, sub: *const u16, opts: u32, sam: u32, out: *mut isize) -> i32;
fn RegEnumKeyExW(hkey: isize, idx: u32, name: *mut u16, name_len: *mut u32, res: *const u32, cls: *mut u16, cls_len: *mut u32, ft: *mut c_void) -> i32;
fn RegCloseKey(hkey: isize) -> i32;
}
const HKEY_CURRENT_USER: isize = 0x80000001u32 as i32 as isize;
const KEY_READ: u32 = 0x20019;
let sub: Vec<u16> = "Software\\Classes\\AppUserModelId".encode_utf16().chain(std::iter::once(0)).collect();
let mut out = Vec::new();
unsafe {
let mut h: isize = 0;
if RegOpenKeyExW(HKEY_CURRENT_USER, sub.as_ptr(), 0, KEY_READ, &mut h) != 0 { return out; }
let mut i = 0u32;
loop {
let mut buf = [0u16; 512]; let mut len = buf.len() as u32;
let r = RegEnumKeyExW(h, i, buf.as_mut_ptr(), &mut len, std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut());
if r != 0 { break; }
let name = String::from_utf16_lossy(&buf[..len as usize]);
if name.starts_with(prefix) { out.push(name); }
i += 1;
}
RegCloseKey(h);
}
out
}
fn stop_flash(&self, hwnd: i64) -> bool {
#[repr(C)] struct FLASHWINFO { cb_size: u32, hwnd: HWND, flags: u32, count: u32, timeout: u32 }
extern "system" { fn FlashWindowEx(p: *const FLASHWINFO) -> i32; }
let f = FLASHWINFO { cb_size: std::mem::size_of::<FLASHWINFO>() as u32, hwnd: hwnd as HWND, flags: 0 /* FLASHW_STOP */, count: 0, timeout: 0 };
unsafe { FlashWindowEx(&f); true }
}
fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool {
extern "system" { fn ShowWindow(hwnd: HWND, ncmd: i32) -> i32; fn GetWindow(hwnd: HWND, cmd: u32) -> HWND; fn IsWindow(hwnd: HWND) -> i32; }
// 2.0.636: a hidden window that is re-shown, even with SW_SHOWNOACTIVATE, comes back at the TOP of
// its band: the taskbar-button rebuild (hide + re-show) put a background pup window over the user's
// work for a sample (measured on the conference-room box, 2026-09-19, 01:07:56). Remember the window
// that was directly above it at hide time and re-insert under that one on show; if that window is
// gone, the bottom. Never the top, never activated.
static PREV: std::sync::Mutex<Vec<(i64, i64)>> = std::sync::Mutex::new(Vec::new());
const GW_HWNDPREV: u32 = 3;
unsafe {
if !visible {
let prev = GetWindow(hwnd as HWND, GW_HWNDPREV) as i64;
let mut m = PREV.lock().unwrap_or_else(|e| e.into_inner());
m.retain(|(h, _)| *h != hwnd); m.push((hwnd, prev));
ShowWindow(hwnd as HWND, 0 /* SW_HIDE */); return true;
}
let prev = { let mut m = PREV.lock().unwrap_or_else(|e| e.into_inner()); let p = m.iter().find(|(h, _)| *h == hwnd).map(|(_, p)| *p); m.retain(|(h, _)| *h != hwnd); p };
let after: HWND = match prev { Some(p) if p != 0 && IsWindow(p as HWND) != 0 => p as HWND, _ => HWND_BOTTOM };
SetWindowPos(hwnd as HWND, after, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
ShowWindow(hwnd as HWND, 4 /* SW_SHOWNOACTIVATE */);
SetWindowPos(hwnd as HWND, after, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
true
}
}
fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool {
extern "system" { fn ShowWindow(hwnd: HWND, ncmd: i32) -> i32; }
const SWP_SHOWWINDOW: u32 = 0x0040;
unsafe {
// Z-ORDER FIRST, then draw. The window arrives here hidden (the lane watcher) or minimized
// (chrome.windows.create state:minimized), and both restore ON TOP if you show them first.
// Bottom it while it is still not on screen, restore it without activating, then bottom it
// once more in case the restore re-ordered it. The user never sees a frame of it in front.
extern "system" { fn IsWindowVisible(hwnd: HWND) -> i32; fn IsIconic(hwnd: HWND) -> i32; }
SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
// 2.0.640: ShowWindow(SW_SHOWNOACTIVATE) on a window that is ALREADY visible and not minimized
// re-raises it (z-watch: 19 ms on top on every birth, 2.0.639). Only show what needs showing.
if IsWindowVisible(hwnd as HWND) == 0 || IsIconic(hwnd as HWND) != 0 { ShowWindow(hwnd as HWND, 4 /* SW_SHOWNOACTIVATE */); }
SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW) != 0
}
}
// 2.0.626: THE flag. MEASURED on the conference-room box, 2026-09-18, straight from a fresh process:
// SetWindowPos(HWND_BOTTOM, NOMOVE|NOSIZE|NOACTIVATE) on a pup Chrome window returned TRUE and moved it to
// rank 0, THE TOP, where it stayed; the same call with SWP_NOOWNERZORDER put it at rank 25, the bottom, and
// it stayed. Chrome's frame owns helper windows, and without SWP_NOOWNERZORDER Windows re-orders the owner
// together with them. So every "parked at z-bottom: true" this week was a RAISE: the birth flash, the
// "navigate surfaces it", the lower that "succeeded" with the window still on top. One flag.
fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool {
unsafe { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER) != 0 }
}
// Node: healOffscreenWindow / setSessionWindowBounds (SetWindowPos 0x14 = NOACTIVATE|NOZORDER)
fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool {
unsafe { SetWindowPos(hwnd as HWND, std::ptr::null_mut(), left, top, width, height, SWP_NOACTIVATE | SWP_NOZORDER) != 0 }
}
// Node: osGetForegroundWindow
fn foreground(&self) -> i64 { unsafe { GetForegroundWindow() as i64 } }
fn idle_ms(&self) -> Option<u64> {
#[repr(C)] struct LastInputInfo { cb_size: u32, dw_time: u32 }
extern "system" { fn GetLastInputInfo(plii: *mut LastInputInfo) -> i32; fn GetTickCount() -> u32; }
unsafe { let mut li = LastInputInfo { cb_size: std::mem::size_of::<LastInputInfo>() as u32, dw_time: 0 }; if GetLastInputInfo(&mut li) == 0 { return None; } Some(GetTickCount().wrapping_sub(li.dw_time) as u64) }
}
// Node: primaryWorkArea (SystemParametersInfo SPI_GETWORKAREA)
fn work_area(&self) -> Option<(i32, i32, i32, i32)> {
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
let ok = unsafe { SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut r as *mut RECT as *mut c_void, 0) } != 0;
if !ok { return None; }
let (w, h) = (r.right - r.left, r.bottom - r.top);
if w > 400 && h > 300 { Some((r.left, r.top, w, h)) } else { None }
}
fn dpi_scale(&self, hwnd: i64) -> f64 { let d = unsafe { GetDpiForWindow(hwnd as HWND) }; if d == 0 { 1.0 } else { d as f64 / 96.0 } }
}
// 2.0.595: handles on the browsers pup launched, so their exit code outlives them (GetExitCodeProcess on a
// handle opened while the process ran). Pids pup killed itself are remembered as deliberate ends.
static WATCHED: std::sync::Mutex<Vec<(u32, isize)>> = std::sync::Mutex::new(Vec::new());
static KILLED: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
impl HostProcess for WinHost {
fn watch_process(&self, pid: u32) {
if pid == 0 { return; }
unsafe {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() { return; }
let mut w = WATCHED.lock().unwrap();
if let Some(i) = w.iter().position(|(p, _)| *p == pid) { let (_, old) = w.remove(i); windows_sys::Win32::Foundation::CloseHandle(old as windows_sys::Win32::Foundation::HANDLE); }
w.push((pid, h as isize));
KILLED.lock().unwrap().retain(|p| *p != pid);
}
}
fn ended_by_pup(&self, pid: u32) -> bool { KILLED.lock().unwrap().contains(&pid) }
fn exit_code(&self, pid: u32) -> Option<u32> {
let h = WATCHED.lock().unwrap().iter().find(|(p, _)| *p == pid).map(|(_, h)| *h)?;
unsafe {
let mut code: u32 = 0;
if GetExitCodeProcess(h as windows_sys::Win32::Foundation::HANDLE, &mut code) == 0 { return None; }
if code == STILL_ACTIVE as u32 { None } else { Some(code) }
}
}
// Node: isProcessAlive
fn alive(&self, pid: u32) -> bool {
unsafe {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() { return false; }
let mut code: u32 = 0;
let ok = GetExitCodeProcess(h, &mut code) != 0;
windows_sys::Win32::Foundation::CloseHandle(h);
ok && code == STILL_ACTIVE as u32
}
}
// Node: the detached puppeteer.launch path (Chrome must outlive the bridge for recovery)
fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
let mut c = std::process::Command::new(exe);
c.args(args).creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.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 child = c.spawn()?;
Ok(child.id())
}
// Node: killBrowserProcess (taskkill /F)
fn run_shell_script(&self, script: &str, timeout: std::time::Duration) -> String {
// 2.0.726 (John, 2026-10-19, after a reboot: "a bunch of windows terminal cmd line windows pop open for 50ms").
// Three fixes: CREATE_NO_WINDOW, so a PowerShell started by a pup with no console never flashes one (boot
// recovery runs many of these); one script file PER CALL (it was one per process, so two concurrent queries
// overwrote each other's script); and the timeout is honoured (it was ignored, so a hung query blocked forever).
use std::io::Read;
use std::os::windows::process::CommandExt;
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir(); let f = dir.join(format!("pup-ps-{}-{n}.ps1", std::process::id()));
if std::fs::write(&f, script).is_err() { return String::new(); }
let mut cmd = std::process::Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File"]).arg(&f);
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());
let mut child = match cmd.spawn() { Ok(c) => c, Err(_) => { let _ = std::fs::remove_file(&f); return String::new(); } };
// read stdout on a thread so a full pipe never deadlocks the wait below
let mut so = child.stdout.take();
let reader = std::thread::spawn(move || { let mut s = String::new(); if let Some(o) = so.as_mut() { let _ = o.read_to_string(&mut s); } s });
let deadline = std::time::Instant::now() + timeout.max(std::time::Duration::from_secs(1));
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
_ => { let _ = child.kill(); let _ = child.wait(); break; }
}
}
let _ = std::fs::remove_file(&f);
reader.join().unwrap_or_default()
}
fn pids_for_image(&self, image: &str) -> Vec<u32> {
use windows_sys::Win32::System::Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS};
let want = image.to_ascii_lowercase();
let mut out = Vec::new();
unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap as isize == -1 { return out; }
let mut e: PROCESSENTRY32W = std::mem::zeroed();
e.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
if Process32FirstW(snap, &mut e) != 0 {
loop {
let n = e.szExeFile.iter().position(|&c| c == 0).unwrap_or(e.szExeFile.len());
let name = String::from_utf16_lossy(&e.szExeFile[..n]).to_ascii_lowercase();
if name == want { out.push(e.th32ProcessID); }
if Process32NextW(snap, &mut e) == 0 { break; }
}
}
}
out
}
fn external_extension_ids(&self) -> Vec<String> {
const PS: &str = "$k=@('HKCU:\\Software\\Google\\Chrome\\Extensions','HKLM:\\Software\\Google\\Chrome\\Extensions','HKLM:\\Software\\Wow6432Node\\Google\\Chrome\\Extensions'); foreach($p in $k){ if(Test-Path $p){ Get-ChildItem $p | ForEach-Object { $_.PSChildName } } }";
self.run_shell_script(PS, std::time::Duration::from_secs(8))
.lines().map(|l| l.trim().to_string())
.filter(|l| l.len() == 32 && l.chars().all(|c| c.is_ascii_lowercase()))
.collect()
}
fn installed_chrome_version(&self) -> Option<String> {
let ps = "$ErrorActionPreference='SilentlyContinue'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe').'(default)'; if(-not $p){$p=(Get-ItemProperty 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe').'(default)'}; if($p -and (Test-Path $p)){ (Get-Item $p).VersionInfo.ProductVersion }";
let out = self.run_shell_script(ps, std::time::Duration::from_secs(8));
let v = out.split_whitespace().next().unwrap_or("").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())) { Some(v) } else { None }
}
fn download_file(&self, url: &str, dest: &std::path::Path) -> Result<(), String> {
match pup_core::http_download(url, dest) { Ok(_) => Ok(()), Err(e) => Err(e) }
}
// GlobalMemoryStatusEx: what the machine has and what is left. pup reports this to the calling AI on
// every open (2.0.462, John: "i suppose you should be automatically telling it what % of ram was even
// left on the box"), because a thread that opened twenty component pages had no way to know it was
// spending 200 MB apiece until the laptop was thrashing.
fn memory_status(&self) -> Option<(u64, u64)> {
#[repr(C)] struct MemStatusEx { length: u32, memory_load: u32, total_phys: u64, avail_phys: u64, total_page: u64, avail_page: u64, total_virtual: u64, avail_virtual: u64, avail_ext: u64 }
extern "system" { fn GlobalMemoryStatusEx(buf: *mut MemStatusEx) -> i32; }
unsafe {
let mut m: MemStatusEx = std::mem::zeroed();
m.length = std::mem::size_of::<MemStatusEx>() as u32;
if GlobalMemoryStatusEx(&mut m) == 0 { return None; }
Some((m.total_phys / 1_048_576, m.avail_phys / 1_048_576))
}
}
/// Working set of `pid` and every descendant (a browser process owns its renderers and GPU process).
fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {
use windows_sys::Win32::System::Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS};
use windows_sys::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
if pid == 0 { return None; }
let mut pairs: Vec<(u32, u32)> = Vec::new(); // (pid, ppid)
unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap as isize == -1 { return None; }
let mut e: PROCESSENTRY32W = std::mem::zeroed();
e.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
if Process32FirstW(snap, &mut e) != 0 {
loop { pairs.push((e.th32ProcessID, e.th32ParentProcessID)); if Process32NextW(snap, &mut e) == 0 { break; } }
}
windows_sys::Win32::Foundation::CloseHandle(snap);
}
let mut tree = vec![pid];
let mut i = 0;
while i < tree.len() { let parent = tree[i]; for (c, p) in &pairs { if *p == parent && !tree.contains(c) { tree.push(*c); } } i += 1; }
let mut total: u64 = 0;
unsafe {
for p in &tree {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, *p);
if h.is_null() { continue; }
let mut c: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
if GetProcessMemoryInfo(h, &mut c, std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32) != 0 { total += c.WorkingSetSize as u64; }
windows_sys::Win32::Foundation::CloseHandle(h);
}
}
Some(total / 1_048_576)
}
fn kill(&self, pid: u32) -> bool {
if pid == 0 || pid == std::process::id() { return false; } // never the bridge itself, never "no pid"
unsafe {
let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
if h.is_null() { return false; }
let ok = TerminateProcess(h, 1) != 0;
windows_sys::Win32::Foundation::CloseHandle(h);
if ok { KILLED.lock().unwrap().push(pid); }
ok
}
}
}
}
#[cfg(windows)]
mod secrets {
use super::*;
use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree, CredReadW, CREDENTIALW, CRED_TYPE_GENERIC};
use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB};
use windows_sys::Win32::Foundation::LocalFree;
fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }
// 2.0.649 (pup-bridge#122): ONE DPAPI-protected file holds every secret, instead of one Credential Manager
// entry per login. Windows caps the per-user credential set: after a full browser import (871 logins) every
// CredWriteW answered ERROR_NOT_ENOUGH_MEMORY (8) on John's laptop, even for a 1-byte probe. DPAPI
// (CryptProtectData, current user, UI forbidden) has no such cap, is the same per-user key Chrome's own
// password store uses, and stays encrypted at rest. Legacy Credential Manager entries are read once,
// moved into the file, and deleted, which also frees the slots the import had consumed.
const STORE: &str = "pup-vault.bin";
static CACHE: std::sync::Mutex<Option<std::collections::BTreeMap<String, String>>> = std::sync::Mutex::new(None);
fn store_path(h: &WinHost) -> std::path::PathBuf { h.state_dir().join(STORE) }
fn dpapi(data: &[u8], protect: bool) -> Option<Vec<u8>> {
unsafe {
let mut input = CRYPT_INTEGER_BLOB { cbData: data.len() as u32, pbData: data.as_ptr() as *mut u8 };
let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: std::ptr::null_mut() };
let ok = if protect { CryptProtectData(&mut input, std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), 0x1 /* CRYPTPROTECT_UI_FORBIDDEN */, &mut out) }
else { CryptUnprotectData(&mut input, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), 0x1, &mut out) };
if ok == 0 || out.pbData.is_null() { return None; }
let v = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
LocalFree(out.pbData as _);
Some(v)
}
}
fn load(h: &WinHost, service: &str) -> std::collections::BTreeMap<String, String> {
if let Some(m) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { return m.clone(); }
// NOT under the cache lock: 2.0.650 held it here and called save(), which locks it again; every vault
// verb on the laptop hung and the migration deleted 30 entries it had not yet persisted.
let mut m: std::collections::BTreeMap<String, String> = std::fs::read(store_path(h)).ok().and_then(|b| dpapi(&b, false)).and_then(|j| serde_json::from_slice(&j).ok()).unwrap_or_default();
// 2.0.652: the service was renamed (adom-desktop-pup -> adom-bridge-pup); keys stored under the old name
// are renamed in the file, and legacy Credential Manager entries are read under BOTH names below.
let legacy_prefix = "adom-desktop-pup/";
if service != "adom-desktop-pup" {
let old_keys: Vec<String> = m.keys().filter(|k| k.starts_with(legacy_prefix)).cloned().collect();
if !old_keys.is_empty() { for k in old_keys { if let Some(v) = m.remove(&k) { m.insert(format!("{service}/{}", &k[legacy_prefix.len()..]), v); } } let _ = save(h, &m); }
}
// one-time migration, SAVE FIRST: read every legacy entry into the map, persist the whole map, and only
// then delete the Credential Manager copies (a failed save leaves the legacy store untouched).
let index = h.state_dir().join("pup-credentials.index.json");
if let Ok(txt) = std::fs::read_to_string(&index) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
let mut moved: Vec<String> = Vec::new();
for e in v["entries"].as_array().cloned().unwrap_or_default() {
let acct = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("").to_string();
if acct.is_empty() { continue; }
let key = format!("{service}/{acct}");
if m.contains_key(&key) { continue; }
let legacy_key = format!("{legacy_prefix}{acct}");
if let Some(secret) = cred_read(&key) { m.insert(key.clone(), secret); moved.push(key); }
else if let Some(secret) = cred_read(&legacy_key) { m.insert(key.clone(), secret); moved.push(legacy_key); }
}
if !moved.is_empty() {
match save(h, &m) {
Ok(()) => { for k in &moved { cred_delete(k); } pup_core::log(format!("[vault] moved {} login(s) from Windows Credential Manager into the DPAPI store and freed their slots", moved.len())); }
Err(e) => pup_core::log(format!("[vault] migration NOT applied (save failed: {e}); the Credential Manager copies were left in place")),
}
}
}
}
*CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(m.clone());
m
}
fn save(h: &WinHost, m: &std::collections::BTreeMap<String, String>) -> Result<(), String> {
let json = serde_json::to_vec(m).map_err(|e| e.to_string())?;
let enc = dpapi(&json, true).ok_or_else(|| "CryptProtectData failed".to_string())?;
let p = store_path(h); let tmp = p.with_extension("bin.tmp");
std::fs::write(&tmp, enc).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &p).map_err(|e| format!("rename {}: {e}", p.display()))?;
*CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(m.clone());
Ok(())
}
fn cred_read(target: &str) -> Option<String> {
let t = wide(target);
let mut p: *mut CREDENTIALW = std::ptr::null_mut();
unsafe {
if CredReadW(t.as_ptr(), CRED_TYPE_GENERIC, 0, &mut p) == 0 || p.is_null() { return None; }
let c = &*p;
let bytes = std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize).to_vec();
CredFree(p as *mut _);
String::from_utf8(bytes).ok()
}
}
fn cred_delete(target: &str) -> bool { let t = wide(target); unsafe { CredDeleteW(t.as_ptr(), CRED_TYPE_GENERIC, 0) != 0 } }
impl HostSecrets for WinHost {
fn secret_get(&self, service: &str, account: &str) -> Option<String> {
let key = format!("{service}/{account}");
if let Some(v) = load(self, service).get(&key) { return Some(v.clone()); }
// a legacy entry the index did not name: move it too
let v = cred_read(&key)?;
let mut m = load(self, service); m.insert(key.clone(), v.clone());
if save(self, &m).is_ok() { cred_delete(&key); }
Some(v)
}
fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> {
let key = format!("{service}/{account}");
let mut m = load(self, service); m.insert(key.clone(), secret.to_string());
save(self, &m)?;
cred_delete(&key); // never keep a second copy in the capped store
Ok(())
}
fn secret_delete(&self, service: &str, account: &str) -> bool {
let key = format!("{service}/{account}");
let mut m = load(self, service); let had = m.remove(&key).is_some();
let saved = save(self, &m).is_ok();
let legacy = cred_delete(&key);
(had && saved) || legacy
}
}
}
#[cfg(not(windows))]
impl HostSecrets for WinHost {
fn secret_get(&self, _s: &str, _a: &str) -> Option<String> { None }
fn secret_set(&self, _s: &str, _a: &str, _v: &str) -> Result<(), String> { Err("no secret store on this dev box".into()) }
fn secret_delete(&self, _s: &str, _a: &str) -> bool { false }
}
#[cfg(not(windows))]
mod imp {
// Cross-compiled checks on Linux: the Windows impl is cfg'd out; these stubs let the crate
// build on a dev box. The release binary is always the cfg(windows) side above.
use super::*;
impl HostWindows for WinHost {
fn windows_for_pids(&self, _p: &[u32], _c: Option<&str>) -> Vec<HostWindow> { Vec::new() }
fn is_alive(&self, _h: i64) -> bool { false }
fn tag(&self, _h: i64, _t: &str) -> bool { false }
fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {
let q = if quote_for_relaunch { "\\\"" } else { "\"" };
("powershell.exe".into(), format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File {q}{}{q} {tail}", script.display()))
}
fn has_tag(&self, _h: i64, _t: &str) -> bool { false }
fn send_to_bottom_no_activate(&self, _h: i64) -> bool { false }
fn set_bounds_no_activate(&self, _h: i64, _l: i32, _t: i32, _w: i32, _hh: i32) -> bool { false }
fn foreground(&self) -> i64 { 0 }
fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None }
}
impl HostProcess for WinHost {
fn alive(&self, pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() }
fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {
let mut c = std::process::Command::new(exe); c.args(args); if let Some(d) = cwd { c.current_dir(d); }
Ok(c.spawn()?.id())
}
fn kill(&self, _pid: u32) -> bool { false }
}
}
impl HostPaths for WinHost {
fn home(&self) -> PathBuf { PathBuf::from(std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default()) }
fn browser_roots(&self) -> Vec<PathBuf> {
let mut v = Vec::new();
for k in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] { if let Ok(p) = std::env::var(k) { if !p.is_empty() { v.push(PathBuf::from(p)); } } }
if v.is_empty() { v.push(PathBuf::from("C:\\Program Files")); v.push(PathBuf::from("C:\\Program Files (x86)")); }
v
}
fn cft_platform(&self) -> &'static str { "win64" }
fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-win64").join("chrome.exe") }
}