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") }
}