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.
macOS build of the Rust pup: host-mac, per-platform manifest, mac build script (pup-bridge#128) #3
The macOS build of the Rust pup, for pup-bridge#128 (a Mac that syncs the public manifest gets the Windows exe and cannot spawn it).
What this adds, all platform-gated or defaulted so the Windows exe is unchanged:
rust/host-mac: the full Host trait on macOS (CGWindowList windows, park = hide the owning app via NSRunningApplication, AX set-bounds + AXRaise,open -g -nnon-activating launch, Keychain in the keytar layout, process table / sysctl / vm_stat for the process verbs, .app bundle discovery for system browsers). 9 unit tests, live-probed on Apple Silicon.- Host trait additions with Windows-preserving defaults:
raise_window(mac raises natively; Windows keeps ab's desktop_bring_to_front),system_browsers(default = the Program Files layout, moved out of pup-browser),closed_window_may_linger(Chrome on mac destroys a closed NSWindow on a later UI turn, so close verifies through the lane),running_browser_executables+pup_browser_processes(defaults keep the Win32_Process queries; on a Mac the PowerShell form answered nothing, sopup_cache_prunedeleted every build but the one it had just installed, and the orphan/idle sweeps were empty),cft_cache_platform(puppeteer names mac cache foldersmac_arm/mac, not the download tag). - verbs: the identity port file lives in the state dir off Windows; the recordings folder opens with the platform opener; the google toast focus names the mac browser.
src/extension/worker.jspupCaptureTab: on a MaccaptureVisibleTabnever settles for a window of a hidden app (pup's park state there), so it is raced against 3 s and falls back to the existing debugger capture. Same code on Windows; the race only matters when the API hangs.tools/rust-build-macos.sh <ver>: universal (arm64 + x86_64) binary, Developer ID signed with hardened runtime, zip with a 0755adom-pup-bridge+ bridge.json (spawn.kind exe,updateManifestUrls {macos, windows}, macos supported / windows unsupported) + the stagedadom-bridge-puppeteer-manifest-macos.json.tools/rust-build.shis untouched.adom-bridge-puppeteer-manifest-macos.json: the macOS manifest (platform: macos), the same shape as the public one; its zip is the per-platform assetadom-bridge-puppeteer-macos-v2.0.719.zipon release 2.0.719 (uploaded separately). Bridge 1.9.217 (macOS) carries a pup seed whoseupdateManifestUrls.macospoints here, so a Mac never follows the Windows line again; the plainupdateManifestUrlandadom-bridge-puppeteer-manifest.jsonstay yours.
Verified on this Mac against the mac build on a spare port with an isolated HOME: tools/parity.py's golden sequence matches 55/56 on this Mac (the one drift is pup_screenshot on the CDP lane returning the base64 envelope instead of a file, and pup_screenshot_full_res on the CDP lane times out for a hidden window; the extension lane, the default, is clean), plus readiness, open (hidden, no focus steal), eval, screenshot, raise, lower, close, quit_idle_browsers. Workspace tests and tools/check-borders.sh green. Cold-start note: across 7 fresh-profile lane starts on this Mac, 2 (back to back, right after a killed previous lane) never had the extension call home within 2x25 s and the open failed; the next 5 came up in 4 to 10 s. Not reproduced since; worth a Mac-side eye on the lane start timeout.
Known gaps on mac, inert: AUMID / jump list / taskbar identity (logs desktop_register_app_identity refused), import.rs / assist.rs / activity.rs (cfg windows no-ops), shots_dir is exe-relative.
🤖 Generated with Claude Code
Diff Skip to comments (1)
@@ -1,71 +1,73 @@⋯ 19 unchanged lines ⋯ ("popup.js", include_bytes!("../../../src/extension/popup.js")), ("satoshi-400.woff2", include_bytes!("../../../src/extension/satoshi-400.woff2")), ("satoshi-500.woff2", include_bytes!("../../../src/extension/satoshi-500.woff2")),+ ("tile.js", include_bytes!("../../../src/extension/tile.js")), ("worker.js", include_bytes!("../../../src/extension/worker.js")), ]), ("extension-identity", "pup-identity-extension", &[⋯ 43 unchanged lines ⋯ ("toolbar.json", include_bytes!("../../../src/extension-nbe/toolbar.json")), ]), ];+@@ -1,830 +1,826 @@⋯ 37 unchanged lines ⋯ // ── detection (Node: listSystemBrowsers / detectChrome / launchCandidates) ───────────── pub fn list_system_browsers(host: &dyn Host) -> Vec<Candidate> {- let mut out = Vec::new();- let roots = host.browser_roots();- let chrome: Vec<PathBuf> = roots.iter().map(|r| r.join("Google").join("Chrome").join("Application").join("chrome.exe")).collect();- let edge: Vec<PathBuf> = roots.iter().map(|r| r.join("Microsoft").join("Edge").join("Application").join("msedge.exe")).collect();- if let Some(p) = chrome.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "chrome".into(), source: "system".into(), executable_path: p, build_id: None }); }- if let Some(p) = edge.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "edge".into(), source: "system".into(), executable_path: p, build_id: None }); }- out+ // The per-OS install layout is the host's (Program Files on Windows, /Applications bundles on macOS).+ host.system_browsers().into_iter().map(|(kind, p)| Candidate { kind, source: "system".into(), executable_path: p, build_id: None }).collect() } -pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_platform(), build_id)) }+pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_cache_platform(), build_id)) } pub fn cft_exe_path(host: &dyn Host, build_id: &str) -> PathBuf { cft_versioned_dir(host, build_id).join(host.cft_exe_relative()) } pub fn detect_cft(host: &dyn Host) -> Option<Candidate> {⋯ 2 unchanged lines ⋯ // any other complete build in the cache (newest wins), so an older cache still drives let root = host.cft_cache_dir().join("chrome"); let mut builds: Vec<(String, PathBuf)> = fs::read_dir(&root).ok()?.flatten()- .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) })+ .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_cache_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) }) // 2.0.679: a build id is a plain version. Folders like "<build>.partial" (an unpack in progress) or a renamed // copy matched the prefix and could be launched half-written (found testing ab's prewarm on CRR, 2026-09-22). .filter(|(b, p)| !b.is_empty() && b.chars().all(|c| c.is_ascii_digit() || c == '.') && p.is_file()).collect();⋯ 765 unchanged lines ⋯ let _ = fs::remove_dir_all(&tmp); } }+@@ -1,1491 +1,1650 @@⋯ 136 unchanged lines ⋯ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]]+name = "core-foundation"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"+dependencies = [+ "core-foundation-sys",+ "libc",+]++[[package]]+name = "core-foundation-sys"+version = "0.8.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"++[[package]]+name = "core-graphics"+version = "0.24.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "core-graphics-types",+ "foreign-types",+ "libc",+]++[[package]]+name = "core-graphics-types"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "libc",+]++[[package]] name = "core_maths" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 53 unchanged lines ⋯ ] [[package]]+name = "dispatch2"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"+dependencies = [+ "bitflags 2.13.2",+ "objc2",+]++[[package]] name = "displaydoc" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 68 unchanged lines ⋯ ] [[package]]+name = "foreign-types"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"+dependencies = [+ "foreign-types-macros",+ "foreign-types-shared",+]++[[package]]+name = "foreign-types-macros"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "foreign-types-shared"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"++[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 307 unchanged lines ⋯ ] [[package]]+name = "objc2"+version = "0.6.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"+dependencies = [+ "objc2-encode",+]++[[package]]+name = "objc2-app-kit"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"+dependencies = [+ "bitflags 2.13.2",+ "libc",+ "objc2",+ "objc2-foundation",+]++[[package]]+name = "objc2-core-foundation"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"+dependencies = [+ "bitflags 2.13.2",+ "dispatch2",+ "objc2",+]++[[package]]+name = "objc2-encode"+version = "4.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"++[[package]]+name = "objc2-foundation"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"+dependencies = [+ "bitflags 2.13.2",+ "objc2",+ "objc2-core-foundation",+]++[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 122 unchanged lines ⋯ name = "pup-host-mac" version = "2.0.719" dependencies = [+ "core-foundation",+ "core-graphics",+ "libc",+ "objc2",+ "objc2-app-kit",+ "objc2-foundation",+ "pup-core", "pup-host",+ "security-framework",+ "serde_json", ] [[package]]⋯ 184 unchanged lines ⋯ ] [[package]]+name = "security-framework"+version = "3.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"+dependencies = [+ "bitflags 2.13.2",+ "core-foundation",+ "core-foundation-sys",+ "libc",+ "security-framework-sys",+]++[[package]]+name = "security-framework-sys"+version = "2.17.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"+dependencies = [+ "core-foundation-sys",+ "libc",+]++[[package]] name = "serde" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 581 unchanged lines ⋯ dependencies = [ "zune-core", ]+@@ -1,9 +1,21 @@⋯ 2 unchanged lines ⋯ version.workspace = true edition.workspace = true license.workspace = true-description = "The macOS implementation of pup-host. Kyle's crate: every method is a todo!() naming the Windows counterpart."+description = "The macOS implementation of pup-host: CoreGraphics window list, AppKit hide/activate for park, Accessibility for bounds, Keychain secrets, non-activating detached launch." [dependencies] pup-host = { path = "../host" }+pup-core = { path = "../core" }+serde_json.workspace = true+libc = "0.2"++[target.'cfg(target_os = "macos")'.dependencies]+core-foundation = "0.10"+core-graphics = "0.24"+security-framework = "3"+objc2 = "0.6"+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSRunningApplication", "NSWorkspace", "NSScreen", "NSResponder", "NSApplication"] }+@@ -0,0 +1,10 @@+use pup_host::HostWindows;+fn main() {+ let h = pup_host_mac::MacHost;+ let fg = h.foreground();+ println!("foreground()={fg}; NSWorkspace frontmost pid={:?}", pup_host_mac::frontmost_pid());+ if fg != 0 { let r = h.windows_for_pids(&[], None).into_iter().find(|w| w.hwnd == fg); println!("foreground window: {:?}", r.map(|w| (w.pid, w.class, w.rect))); }+ let rows = h.windows_for_pids(&[], None);+ println!("{} layer-0 windows; first 6: {:?}", rows.len(), rows.iter().take(6).map(|w| (w.hwnd, w.pid, w.class.clone(), w.visible)).collect::<Vec<_>>());+}+@@ -0,0 +1,9 @@+//! hide_ctl <hwnd> show|hide|raise — drive one window's owning app through the trait, for live tests.+use pup_host::HostWindows;+fn main() {+ let a: Vec<String> = std::env::args().collect();+ let h = pup_host_mac::MacHost; let hwnd: i64 = a[1].parse().unwrap();+ let r = match a[2].as_str() { "show" => h.set_visible_no_activate(hwnd, true), "hide" => h.send_to_bottom_no_activate(hwnd), "raise" => h.raise_window(hwnd), _ => false };+ println!("{} {hwnd}: {r}; foreground now {}", a[2], h.foreground());+}+@@ -0,0 +1,37 @@+//! Live probe of the mac host against Chrome for Testing: non-activating launch, pid resolution,+//! window discovery by pid, park (hide), unpark, kill. Run: cargo run -p pup-host-mac --example live_probe <cft-exe> <profile-dir>+use pup_host::{HostProcess, HostWindows};+use pup_host_mac::MacHost;+use std::path::Path;+use std::time::{Duration, Instant};+fn main() {+ let a: Vec<String> = std::env::args().collect();+ let exe = Path::new(&a[1]); let profile = &a[2];+ let h = MacHost;+ let front_before = h.foreground();+ let t0 = Instant::now();+ let args = vec![format!("--user-data-dir={profile}"), "--remote-debugging-port=0".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--window-size=900,600".into(), "about:blank".into()];+ let pid = h.spawn_detached(exe, &args, None).expect("spawn");+ println!("spawned pid {pid} in {:?}; alive={}", t0.elapsed(), h.alive(pid));+ let mut wins = Vec::new();+ let deadline = Instant::now() + Duration::from_secs(15);+ while Instant::now() < deadline { wins = h.windows_for_pids(&[pid], Some("Chrome_WidgetWin_1")); if !wins.is_empty() { break; } std::thread::sleep(Duration::from_millis(100)); }+ println!("windows for pid after {:?}: {:?}", t0.elapsed(), wins.iter().map(|w| (w.hwnd, &w.class, w.rect, w.visible)).collect::<Vec<_>>());+ let front_after = h.foreground();+ println!("foreground before={front_before} after={front_after} (stolen={})", wins.iter().any(|w| w.hwnd == front_after));+ let Some(w) = wins.first() else { h.kill(pid); return; };+ println!("tag: {} has_tag: {}", h.tag(w.hwnd, "AdomPupSid=probe"), h.has_tag(w.hwnd, "AdomPupSid=probe"));+ println!("park (hide): {}", h.send_to_bottom_no_activate(w.hwnd));+ std::thread::sleep(Duration::from_millis(300));+ let hidden_rows = h.windows_for_pids(&[pid], Some("Chrome_WidgetWin_1"));+ println!("after hide: found={} visible={:?} is_alive={}", hidden_rows.len(), hidden_rows.iter().map(|w| w.visible).collect::<Vec<_>>(), h.is_alive(w.hwnd));+ println!("set_bounds (AX): {}", h.set_bounds_no_activate(w.hwnd, 40, 60, 800, 500));+ println!("unhide (no activate): {}", h.set_visible_no_activate(w.hwnd, true));+ std::thread::sleep(Duration::from_millis(300));+ println!("foreground now={} (ours={})", h.foreground(), h.foreground() == w.hwnd);+ println!("idle_ms={:?} work_area={:?}", h.idle_ms(), h.work_area());+ println!("kill: {}", h.kill(pid));+ std::thread::sleep(Duration::from_millis(500));+ println!("alive after kill: {} window alive: {}", h.alive(pid), h.is_alive(w.hwnd));+}+@@ -0,0 +1,3 @@+use pup_host::HostProcess;+fn main() { let h = pup_host_mac::MacHost; println!("running browsers: {:?}", h.running_browser_executables()); println!("pup browsers by profile: {:?}", h.pup_browser_processes().iter().take(3).collect::<Vec<_>>()); }+@@ -0,0 +1,9 @@+//! Do CGWindowList rows carry titles from this process (needs Screen Recording for other apps)?+use pup_host::HostWindows;+fn main() {+ let h = pup_host_mac::MacHost;+ let rows = h.windows_for_pids(&[], None);+ let titled = rows.iter().filter(|w| !w.title.is_empty()).count();+ println!("{} windows, {} with titles; chromium: {:?}", rows.len(), titled, rows.iter().filter(|w| w.class.to_lowercase().contains("chrome")).map(|w| (w.pid, w.title.chars().take(30).collect::<String>())).take(5).collect::<Vec<_>>());+}+@@ -0,0 +1,3 @@+use pup_host::HostWindows;+fn main() { let pid: u32 = std::env::args().nth(1).unwrap().parse().unwrap(); for w in pup_host_mac::MacHost.windows_for_pids(&[pid], None) { println!("{:?}", w); } }+@@ -1,48 +1,717 @@-//! pup-host-mac: the macOS host, for Kyle to implement. Same traits as pup-host-win; every-//! `todo!()` names what the Windows crate does for that method, so the port is a translation,-//! not a rediscovery. `cargo check --target aarch64-apple-darwin -p pup-host-mac` runs every ship.+//! pup-host-mac: the macOS host. Same traits as pup-host-win; each method names the Windows+//! counterpart it mirrors. The ONLY crate in the workspace that may name CoreGraphics, AppKit,+//! Accessibility or the Keychain (tools/check-borders.sh). //!-//! What macOS does NOT need to implement: anything taskbar-shaped (Dock identity, badges, jump-//! lists) — that is ab's job through `AbClient`. pup only needs windows found, kept out of the-//! user's way (the Windows "bottom of the z-order, never activated" is `NSApp hide` / ordering-//! the window back on macOS), and the browser spawned detached.+//! The mac model, carried over from the Node build's darwin work (pup 1.9.74+):+//! - a "window handle" is a CGWindowID (CGWindowListCopyWindowInfo), owned by the browser pid;+//! - "bottom of the z-order, never activated" has no per-window equivalent for ANOTHER app's+//! window without Accessibility, so park = app-level hide of the browser (the Cmd+H state,+//! `NSRunningApplication.hide`). Hidden windows stay drivable and screenshottable over CDP.+//! One session = one browser process, so the hide is per session.+//! - the browser is launched NON-activating: `open -g -n -a <bundle> --args …` when the+//! executable lives inside a .app bundle, so the first window is born behind the user's+//! frontmost app instead of stealing focus (a directly spawned Chrome activates itself on its+//! first window). The pid is resolved from the process table by the `--user-data-dir=` arg.+//! - window tags: macOS has no per-window property store (SetProp), so tags live in+//! `~/.adom/pup-window-tags.json` keyed by CGWindowID, pruned of dead ids on every write.+//! - taskbar-shaped work (Dock identity, badges, jump lists) is ab's job through `AbClient`;+//! nothing here invents a Dock equivalent.+//!+//! Permissions: window enumeration, hide/activate, idle time and the Keychain need nothing.+//! Window TITLES from CGWindowList need Screen Recording; without it `title` is empty, which the+//! verbs tolerate (they match by pid + tag). `set_bounds_no_activate` needs Accessibility and+//! returns false without it (the birth path moves windows over CDP first anyway). use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows};+use std::collections::HashMap; use std::path::{Path, PathBuf};+use std::sync::Mutex; pub struct MacHost; +/// The Windows class name the verbs crate asks for; on macOS it means "any Chromium window".+const CHROME_CLASS: &str = "Chrome_WidgetWin_1";++fn owner_is_chromium(owner: &str) -> bool {+ let o = owner.to_ascii_lowercase();+ o.contains("chrome") || o.contains("chromium") || o.contains("edge")+}++/// Does a CGWindowList owner name satisfy the caller's class filter?+fn class_matches(owner: &str, class_filter: Option<&str>) -> bool {+ match class_filter {+ None => true,+ Some(CHROME_CLASS) => owner_is_chromium(owner),+ Some(c) => owner.eq_ignore_ascii_case(c) || owner_is_chromium(owner) && owner_is_chromium(c),+ }+}++// ───────────────────────── window tags (SetProp replacement) ─────────────────────────++fn tags_file() -> PathBuf { MacHost.state_dir().join("pup-window-tags.json") }++fn tags_store() -> &'static Mutex<HashMap<i64, Vec<String>>> {+ static M: std::sync::OnceLock<Mutex<HashMap<i64, Vec<String>>>> = std::sync::OnceLock::new();+ M.get_or_init(|| {+ let loaded: HashMap<i64, Vec<String>> = std::fs::read_to_string(tags_file()).ok()+ .and_then(|s| serde_json::from_str::<HashMap<String, Vec<String>>>(&s).ok())+ .map(|m| m.into_iter().filter_map(|(k, v)| k.parse::<i64>().ok().map(|k| (k, v))).collect())+ .unwrap_or_default();+ Mutex::new(loaded)+ })+}++fn tags_persist(map: &HashMap<i64, Vec<String>>) {+ let as_str: HashMap<String, &Vec<String>> = map.iter().map(|(k, v)| (k.to_string(), v)).collect();+ if let Some(d) = tags_file().parent() { let _ = std::fs::create_dir_all(d); }+ if let Ok(txt) = serde_json::to_string_pretty(&as_str) { let _ = std::fs::write(tags_file(), txt); }+}++// ───────────────────────── platform implementation ─────────────────────────++#[cfg(target_os = "macos")]+mod imp {+ use core_foundation::array::CFArray;+ use core_foundation::base::{CFType, TCFType};+ use core_foundation::dictionary::CFDictionary;+ use core_foundation::number::CFNumber;+ use core_foundation::string::CFString;+ use core_graphics::display::{CGWindowListCopyWindowInfo, kCGNullWindowID, kCGWindowListExcludeDesktopElements, kCGWindowListOptionAll, kCGWindowListOptionOnScreenOnly};+ use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication, NSScreen, NSWorkspace};++ /// One row of CGWindowListCopyWindowInfo, the fields pup needs.+ pub(super) struct Row { pub id: i64, pub pid: u32, pub owner: String, pub title: String, pub layer: i64, pub onscreen: bool, pub bounds: (i32, i32, i32, i32) }++ fn dict_i64(d: &CFDictionary<CFString, CFType>, key: &str) -> Option<i64> {+ d.find(CFString::new(key)).and_then(|v| v.downcast::<CFNumber>()).and_then(|n| n.to_i64())+ }+ fn dict_str(d: &CFDictionary<CFString, CFType>, key: &str) -> String {+ d.find(CFString::new(key)).and_then(|v| v.downcast::<CFString>()).map(|s| s.to_string()).unwrap_or_default()+ }++ pub(super) fn window_rows(all: bool) -> Vec<Row> {+ use core_foundation::dictionary::CFDictionaryRef;+ let opts = if all { kCGWindowListOptionAll | kCGWindowListExcludeDesktopElements } else { kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements };+ let raw = unsafe { CGWindowListCopyWindowInfo(opts, kCGNullWindowID) };+ if raw.is_null() { return Vec::new(); }+ let arr: CFArray<CFType> = unsafe { CFArray::wrap_under_create_rule(raw) };+ let mut out = Vec::new();+ for item in arr.iter() {+ let d: CFDictionary<CFString, CFType> = unsafe { CFDictionary::wrap_under_get_rule(item.as_CFTypeRef() as CFDictionaryRef) };+ let id = dict_i64(&d, "kCGWindowNumber").unwrap_or(0);+ let pid = dict_i64(&d, "kCGWindowOwnerPID").unwrap_or(0) as u32;+ let layer = dict_i64(&d, "kCGWindowLayer").unwrap_or(0);+ // kCGWindowIsOnscreen is a CFBoolean (not a CFNumber) and is only present when true.+ let onscreen = !all || d.find(CFString::new("kCGWindowIsOnscreen")).and_then(|v| v.downcast::<core_foundation::boolean::CFBoolean>()).map(|b| bool::from(b)).unwrap_or(false);+ let bounds = d.find(CFString::new("kCGWindowBounds")).map(|v| {+ let b: CFDictionary<CFString, CFType> = unsafe { CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as CFDictionaryRef) };+ let g = |k: &str| b.find(CFString::new(k)).and_then(|v| v.downcast::<CFNumber>()).and_then(|n| n.to_f64()).unwrap_or(0.0);+ let (x, y, w, h) = (g("X"), g("Y"), g("Width"), g("Height"));+ (x as i32, y as i32, (x + w) as i32, (y + h) as i32)+ }).unwrap_or((0, 0, 0, 0));+ out.push(Row { id, pid, owner: dict_str(&d, "kCGWindowOwnerName"), title: dict_str(&d, "kCGWindowName"), layer, onscreen, bounds });+ }+ out+ }++ pub(super) fn running_app(pid: u32) -> Option<objc2::rc::Retained<NSRunningApplication>> {+ NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)+ }++ /// Node: macSetAppHiddenByPid (NSRunningApplication via JXA, never System Events).+ pub(super) fn set_app_hidden(pid: u32, hidden: bool) -> bool {+ let Some(app) = running_app(pid) else { return false };+ if hidden { app.hide(); } else { app.unhide(); }+ // hide/unhide are asynchronous; judge by the state a beat later.+ std::thread::sleep(std::time::Duration::from_millis(60));+ app.isHidden() == hidden+ }++ /// Node: macActivateByPid. Only used for a GRANTED raise (never at birth).+ pub fn activate(pid: u32) -> bool {+ let Some(app) = running_app(pid) else { return false };+ app.unhide();+ app.activateWithOptions(NSApplicationActivationOptions::empty())+ }++ pub(super) fn frontmost_pid() -> Option<u32> {+ // A process that has connected to the window server but runs no NSApplication (this+ // bridge, its tests) is reported as its own frontmost app; that answer is worthless.+ NSWorkspace::sharedWorkspace().frontmostApplication().map(|a| a.processIdentifier() as u32).filter(|p| *p != std::process::id())+ }++ pub(super) fn app_hidden(pid: u32) -> Option<bool> { running_app(pid).map(|a| a.isHidden()) }++ /// The main screen's visible frame in TOP-LEFT (CG) coordinates: (x, y, w, h).+ pub(super) fn visible_work_area() -> Option<(i32, i32, i32, i32)> {+ // NSScreen geometry is read-only and safe off the main thread; the marker is a type-level+ // requirement of the binding, not a runtime one, for this call.+ let mtm = unsafe { objc2::MainThreadMarker::new_unchecked() };+ let screen = NSScreen::mainScreen(mtm)?;+ let frame = screen.frame();+ let vis = screen.visibleFrame();+ let top = frame.size.height - (vis.origin.y + vis.size.height);+ Some((vis.origin.x as i32, top as i32, vis.size.width as i32, vis.size.height as i32))+ }++ pub(super) fn idle_ms() -> Option<u64> {+ extern "C" { fn CGEventSourceSecondsSinceLastEventType(state: i32, event_type: u32) -> f64; }+ // kCGEventSourceStateCombinedSessionState = 0, kCGAnyInputEventType = !0+ let s = unsafe { CGEventSourceSecondsSinceLastEventType(0, u32::MAX) };+ if s.is_finite() && s >= 0.0 { Some((s * 1000.0) as u64) } else { None }+ }++ // ── Accessibility: move/resize a window by CGWindowID without activating it ──+ #[repr(C)] struct AXOpaque { _p: [u8; 0] }+ type AXUIElementRef = *const AXOpaque;+ type AXValueRef = *const AXOpaque;+ #[repr(C)] #[derive(Clone, Copy)] struct CGPointC { x: f64, y: f64 }+ #[repr(C)] #[derive(Clone, Copy)] struct CGSizeC { w: f64, h: f64 }+ #[link(name = "ApplicationServices", kind = "framework")]+ extern "C" {+ fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;+ fn AXUIElementCopyAttributeValue(el: AXUIElementRef, attr: core_foundation::string::CFStringRef, out: *mut core_foundation::base::CFTypeRef) -> i32;+ fn AXUIElementSetAttributeValue(el: AXUIElementRef, attr: core_foundation::string::CFStringRef, value: core_foundation::base::CFTypeRef) -> i32;+ fn AXValueCreate(kind: u32, value: *const std::ffi::c_void) -> AXValueRef;+ fn _AXUIElementGetWindow(el: AXUIElementRef, out: *mut u32) -> i32;+ fn AXIsProcessTrusted() -> bool;+ }+ const K_AX_VALUE_CGPOINT: u32 = 1;+ const K_AX_VALUE_CGSIZE: u32 = 2;++ pub(super) fn ax_set_bounds(pid: u32, window_id: i64, l: i32, t: i32, w: i32, h: i32) -> bool {+ unsafe {+ if !AXIsProcessTrusted() { return false; }+ let app = AXUIElementCreateApplication(pid as i32);+ if app.is_null() { return false; }+ let mut wins: core_foundation::base::CFTypeRef = std::ptr::null();+ let attr = CFString::new("AXWindows");+ if AXUIElementCopyAttributeValue(app, attr.as_concrete_TypeRef(), &mut wins) != 0 || wins.is_null() { core_foundation::base::CFRelease(app as _); return false; }+ let arr: CFArray<CFType> = CFArray::wrap_under_create_rule(wins as _);+ let mut ok = false;+ for el in arr.iter() {+ let el_ref = el.as_CFTypeRef() as AXUIElementRef;+ let mut wid: u32 = 0;+ if _AXUIElementGetWindow(el_ref, &mut wid) != 0 || wid as i64 != window_id { continue; }+ let p = CGPointC { x: l as f64, y: t as f64 };+ let s = CGSizeC { w: w as f64, h: h as f64 };+ let pv = AXValueCreate(K_AX_VALUE_CGPOINT, &p as *const _ as *const _);+ let sv = AXValueCreate(K_AX_VALUE_CGSIZE, &s as *const _ as *const _);+ let pa = CFString::new("AXPosition"); let sa = CFString::new("AXSize");+ let r1 = AXUIElementSetAttributeValue(el_ref, pa.as_concrete_TypeRef(), pv as _);+ let r2 = AXUIElementSetAttributeValue(el_ref, sa.as_concrete_TypeRef(), sv as _);+ if !pv.is_null() { core_foundation::base::CFRelease(pv as _); }+ if !sv.is_null() { core_foundation::base::CFRelease(sv as _); }+ ok = r1 == 0 && r2 == 0;+ break;+ }+ core_foundation::base::CFRelease(app as _);+ ok+ }+ }++ /// AXRaise one window of an app (after the app is active, this puts THAT window in front of the+ /// app's other windows). Needs Accessibility; false without it, which leaves the app's own order.+ pub(super) fn ax_raise(pid: u32, window_id: i64) -> bool {+ unsafe {+ if !AXIsProcessTrusted() { return false; }+ extern "C" { fn AXUIElementPerformAction(el: AXUIElementRef, action: core_foundation::string::CFStringRef) -> i32; }+ let app = AXUIElementCreateApplication(pid as i32);+ if app.is_null() { return false; }+ let mut wins: core_foundation::base::CFTypeRef = std::ptr::null();+ let attr = CFString::new("AXWindows");+ if AXUIElementCopyAttributeValue(app, attr.as_concrete_TypeRef(), &mut wins) != 0 || wins.is_null() { core_foundation::base::CFRelease(app as _); return false; }+ let arr: CFArray<CFType> = CFArray::wrap_under_create_rule(wins as _);+ let mut ok = false;+ for el in arr.iter() {+ let el_ref = el.as_CFTypeRef() as AXUIElementRef;+ let mut wid: u32 = 0;+ if _AXUIElementGetWindow(el_ref, &mut wid) != 0 || wid as i64 != window_id { continue; }+ let act = CFString::new("AXRaise");+ ok = AXUIElementPerformAction(el_ref, act.as_concrete_TypeRef()) == 0;+ break;+ }+ core_foundation::base::CFRelease(app as _);+ ok+ }+ }++ // ── Keychain: keytar's generic-password layout (service + account) ──+ pub(super) fn secret_get(service: &str, account: &str) -> Option<String> {+ security_framework::passwords::get_generic_password(service, account).ok().and_then(|b| String::from_utf8(b).ok())+ }+ pub(super) fn secret_set(service: &str, account: &str, secret: &str) -> Result<(), String> {+ security_framework::passwords::set_generic_password(service, account, secret.as_bytes()).map_err(|e| format!("Keychain write failed: {e}"))+ }+ pub(super) fn secret_delete(service: &str, account: &str) -> bool {+ security_framework::passwords::delete_generic_password(service, account).is_ok()+ }+}++#[cfg(not(target_os = "macos"))]+mod imp {+ // Cross-compiled checks on Linux/Windows: the mac impl is cfg'd out; these stubs let the+ // crate build on a dev box. The release binary is always the cfg(target_os = "macos") side.+ use super::*;+ pub(super) struct Row { pub id: i64, pub pid: u32, pub owner: String, pub title: String, pub layer: i64, pub onscreen: bool, pub bounds: (i32, i32, i32, i32) }+ pub(super) fn window_rows(_all: bool) -> Vec<Row> { Vec::new() }+ pub(super) fn set_app_hidden(_pid: u32, _hidden: bool) -> bool { false }+ pub fn activate(_pid: u32) -> bool { false }+ pub(super) fn frontmost_pid() -> Option<u32> { None }+ pub(super) fn app_hidden(_pid: u32) -> Option<bool> { None }+ pub(super) fn ax_raise(_pid: u32, _id: i64) -> bool { false }+ pub(super) fn visible_work_area() -> Option<(i32, i32, i32, i32)> { None }+ pub(super) fn idle_ms() -> Option<u64> { None }+ pub(super) fn ax_set_bounds(_pid: u32, _id: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { false }+ pub(super) fn secret_get(_s: &str, _a: &str) -> Option<String> { None }+ pub(super) fn secret_set(_s: &str, _a: &str, _v: &str) -> Result<(), String> { Err("no Keychain on this dev box".into()) }+ pub(super) fn secret_delete(_s: &str, _a: &str) -> bool { false }+}++/// Raise a parked (hidden) browser: the mac side of a GRANTED `pup_raise_os_window` (Node: macActivateByPid).+pub fn activate_pid(pid: u32) -> bool { imp::activate(pid) }++/// Pids pup itself ended through `Host::kill` (Windows: the same KILLED list), so recovery can tell a+/// deliberate end from a crash or the user quitting the browser.+fn killed() -> &'static Mutex<Vec<u32>> {+ static K: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();+ K.get_or_init(|| Mutex::new(Vec::new()))+}++/// (pid, ppid, rss KB, executable path) for every process, one `ps` call. `comm` is the executable+/// path WITHOUT arguments, so a path with spaces ("Google Chrome for Testing") survives intact.+fn process_table() -> Vec<(u32, u32, u64, String)> {+ let Ok(out) = std::process::Command::new("/bin/ps").args(["-axo", "pid=,ppid=,rss=,comm="]).output() else { return Vec::new() };+ String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| {+ let l = l.trim_start();+ let mut it = l.splitn(4, char::is_whitespace).filter(|x| !x.is_empty());+ let pid = it.next()?.parse::<u32>().ok()?;+ let rest = l[l.find(char::is_whitespace)?..].trim_start();+ let ppid = rest.split_whitespace().next()?.parse::<u32>().ok()?;+ let rest = rest[rest.find(char::is_whitespace)?..].trim_start();+ let rss = rest.split_whitespace().next()?.parse::<u64>().ok()?;+ let comm = rest[rest.find(char::is_whitespace)?..].trim().to_string();+ Some((pid, ppid, rss, comm))+ }).collect()+}++/// Is the whole app that owns `pid` hidden (Cmd+H state)? None when the pid has no app.+fn app_hidden(pid: u32) -> Option<bool> { imp::app_hidden(pid) }+/// The user is using this app right now: it is the frontmost application.+fn app_is_frontmost(pid: u32) -> bool { imp::frontmost_pid() == Some(pid) }++/// The frontmost application's pid as NSWorkspace reports it (None outside an AppKit process).+pub fn frontmost_pid() -> Option<u32> { imp::frontmost_pid() }++/// The owning pid of a CGWindowID, from the full window list (hidden windows included).+fn owner_pid_of(window_id: i64) -> Option<u32> { imp::window_rows(true).into_iter().find(|r| r.id == window_id).map(|r| r.pid) }++// ───────────────────────── HostWindows ─────────────────────────+ impl HostWindows for MacHost {- fn windows_for_pids(&self, _pids: &[u32], _class_filter: Option<&str>) -> Vec<HostWindow> {- todo!("CGWindowListCopyWindowInfo filtered by kCGWindowOwnerPID (Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1)")+ // Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1.+ // Hidden (parked) windows are included: pup must keep finding a window it parked.+ fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {+ imp::window_rows(true).into_iter()+ .filter(|r| r.layer == 0 && r.id > 0)+ .filter(|r| pids.is_empty() || pids.contains(&r.pid))+ .filter(|r| class_matches(&r.owner, class_filter))+ .filter(|r| r.bounds.2 - r.bounds.0 > 50 && r.bounds.3 - r.bounds.1 > 50) // skip Chrome's 1-px helper windows+ .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.owner, rect: r.bounds, visible: r.onscreen })+ .collect() }- fn is_alive(&self, _hwnd: i64) -> bool { todo!("window id still present in CGWindowListCopyWindowInfo (Windows: IsWindow)") }- fn tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("no per-window property store on macOS: keep a pid->session map in the state dir instead (Windows: SetProp \"AdomPupSid=<sid>\")") }- fn script_launch(&self, _script: &std::path::Path, _tail: &str, _quote: bool) -> (String, String) { todo!("launch a helper script hidden (Windows: powershell.exe -WindowStyle Hidden -File <script> <tail>); macOS: /bin/sh or osascript") }- fn has_tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("see tag() (Windows: GetProp)") }- fn send_to_bottom_no_activate(&self, _hwnd: i64) -> bool { todo!("order the window below without key/main (NSWindow orderBack: via the app's accessibility API, or hide the browser app: the Cmd+H state) (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE)") }- fn set_bounds_no_activate(&self, _hwnd: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { todo!("AXUIElementSetAttributeValue kAXPosition/kAXSize (Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER)") }- fn foreground(&self) -> i64 { todo!("frontmost app's key window via NSWorkspace + AX (Windows: GetForegroundWindow)") }- fn work_area(&self) -> Option<(i32, i32, i32, i32)> { todo!("NSScreen.main.visibleFrame (Windows: SystemParametersInfo SPI_GETWORKAREA)") }+ // Windows: EnumWindows over unowned captioned frames, visible or not. macOS: every layer-0 window+ // of a Chromium owner from the FULL list (hidden apps' windows included), tiny helper windows dropped.+ fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> { self.windows_for_pids(&[], class_filter) }+ // Windows: IsWindow.+ fn is_alive(&self, hwnd: i64) -> bool { hwnd > 0 && imp::window_rows(true).iter().any(|r| r.id == hwnd) }+ // Windows: SetProp "AdomPupSid=<sid>". No per-window property store here: a persisted map.+ fn tag(&self, hwnd: i64, tag: &str) -> bool {+ if hwnd <= 0 { return false; }+ let mut m = tags_store().lock().unwrap();+ let alive: std::collections::HashSet<i64> = imp::window_rows(true).into_iter().map(|r| r.id).collect();+ m.retain(|id, _| alive.contains(id) || *id == hwnd);+ let v = m.entry(hwnd).or_default();+ if !v.iter().any(|t| t == tag) { v.push(tag.to_string()); }+ tags_persist(&m);+ true+ }+ // Windows: GetProp.+ fn has_tag(&self, hwnd: i64, tag: &str) -> bool { tags_store().lock().unwrap().get(&hwnd).map(|v| v.iter().any(|t| t == tag)).unwrap_or(false) }+ // Windows: powershell -WindowStyle Hidden -File <script> <tail>. Here: /bin/sh <script> <tail>.+ fn script_launch(&self, script: &Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+ let q = if quote_for_relaunch { "\\\"" } else { "\"" };+ ("/bin/sh".into(), format!("{q}{}{q} {tail}", script.display()))+ }+ // Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE. macOS has no per-window "order back" for+ // another app's window, so park = hide the owning app (the Cmd+H state; the Node mac build's design,+ // 1.9.74). Windows stay drivable and screenshottable over CDP / the extension while hidden. The lane+ // browser holds many threads' windows, so NEVER hide it while the user has it frontmost: that would+ // yank a window they are using (pup-user-foreground rule). Already hidden counts as parked.+ fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool {+ let Some(pid) = owner_pid_of(hwnd) else { return false };+ if app_is_frontmost(pid) { return false; }+ if app_hidden(pid) == Some(true) { return true; }+ imp::set_app_hidden(pid, true)+ }+ // Windows: ShowWindow SW_HIDE / SW_SHOWNOACTIVATE. macOS: hide / unhide the owning app (unhide never+ // activates; the windows come back at their old z positions, never in front of the user's work).+ fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool {+ match owner_pid_of(hwnd) { Some(pid) => if visible { imp::set_app_hidden(pid, false) } else { self.send_to_bottom_no_activate(hwnd) }, None => false }+ }+ // Windows: SetWindowPos(HWND_BOTTOM, SHOWWINDOW | NOACTIVATE) in one call. macOS: a window born into a+ // hidden app is born invisible (parked by construction); a window born into a visible, non-frontmost+ // app sits behind the user's active app already, and the app is hidden so it is fully out of the way;+ // a window born while the user is IN the lane browser is left exactly where Chrome put it.+ fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool {+ let Some(pid) = owner_pid_of(hwnd) else { return false };+ if app_is_frontmost(pid) { return true; }+ if app_hidden(pid) == Some(true) { return true; }+ imp::set_app_hidden(pid, true)+ }+ // Windows: a sub-ms spin that bottoms the window the instant it first shows. macOS: a window of a hidden+ // app never shows, so it is parked by construction (Some(0)); otherwise wait for it to come on screen and+ // hide the app then, unless the user is in it.+ fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> {+ let pid = owner_pid_of(hwnd)?;+ let t0 = std::time::Instant::now();+ if app_hidden(pid) == Some(true) { return Some(0); }+ loop {+ if imp::window_rows(false).iter().any(|r| r.id == hwnd) {+ if !app_is_frontmost(pid) { imp::set_app_hidden(pid, true); }+ return Some(t0.elapsed().as_millis() as u64);+ }+ if t0.elapsed().as_millis() as u64 > timeout_ms { return None; }+ std::thread::sleep(std::time::Duration::from_millis(5));+ }+ }+ // Chrome on macOS destroys a closed window's NSWindow on a later UI turn; the window-server entry can+ // outlive the close for minutes while the app idles hidden in the background.+ fn closed_window_may_linger(&self) -> bool { true }+ // Windows: FlashWindowEx(FLASHW_STOP). No taskbar flash on macOS; nothing to stop.+ fn stop_flash(&self, _hwnd: i64) -> bool { false }+ // A GRANTED raise (the verbs gate it on foregroundReason): unhide + activate the owning app, then+ // AXRaise this window above the app's other windows when Accessibility allows it.+ fn raise_window(&self, hwnd: i64) -> bool {+ let Some(pid) = owner_pid_of(hwnd) else { return false };+ let ok = imp::activate(pid);+ if ok { std::thread::sleep(std::time::Duration::from_millis(80)); let _ = imp::ax_raise(pid, hwnd); }+ ok+ }+ // Windows: ITaskbarList DeleteTab/AddTab. No taskbar; the Dock tile is the app's, untouched.+ fn taskbar_tab(&self, _hwnd: i64, _present: bool) -> bool { false }+ // Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER. macOS: Accessibility, if trusted.+ fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool {+ match owner_pid_of(hwnd) { Some(pid) => imp::ax_set_bounds(pid, hwnd, left, top, width, height), None => false }+ }+ // Windows: GetForegroundWindow. macOS: the frontmost app's frontmost layer-0 window. When+ // NSWorkspace has no answer (no NSApplication in this process), the on-screen window list is+ // ordered front to back, so its first layer-0 row IS the frontmost window.+ fn foreground(&self) -> i64 {+ let rows = imp::window_rows(false);+ if let Some(pid) = imp::frontmost_pid() {+ if let Some(r) = rows.iter().find(|r| r.pid == pid && r.layer == 0 && r.onscreen) { return r.id; }+ }+ rows.into_iter().find(|r| r.layer == 0 && r.onscreen && r.bounds.2 - r.bounds.0 > 50 && r.bounds.3 - r.bounds.1 > 50).map(|r| r.id).unwrap_or(0)+ }+ // Windows: GetLastInputInfo. macOS: CGEventSourceSecondsSinceLastEventType.+ fn idle_ms(&self) -> Option<u64> { imp::idle_ms() }+ // Windows: SystemParametersInfo SPI_GETWORKAREA. macOS: NSScreen.main.visibleFrame (top-left coords).+ fn work_area(&self) -> Option<(i32, i32, i32, i32)> {+ imp::visible_work_area().filter(|(_, _, w, h)| *w > 400 && *h > 300)+ }+ // CDP and CGWindowList both speak points on macOS: no scaling to undo.+ fn dpi_scale(&self, _hwnd: i64) -> f64 { 1.0 } } +// ───────────────────────── HostProcess ─────────────────────────++/// `<bundle>.app` that owns `exe`, when the executable lives at Contents/MacOS inside one.+fn bundle_for_executable(exe: &Path) -> Option<PathBuf> {+ let macos = exe.parent()?;+ if macos.file_name()?.to_str()? != "MacOS" { return None; }+ let contents = macos.parent()?;+ if contents.file_name()?.to_str()? != "Contents" { return None; }+ let app = contents.parent()?;+ if app.extension()?.to_str()? != "app" { return None; }+ Some(app.to_path_buf())+}++/// The pid of the BROWSER process (no `--type=`) whose command line carries `marker`.+fn find_pid_by_arg(marker: &str) -> Option<u32> {+ let out = std::process::Command::new("/bin/ps").args(["-axo", "pid=,command="]).output().ok()?;+ for line in String::from_utf8_lossy(&out.stdout).lines() {+ if line.contains(marker) && !line.contains("--type=") {+ if let Some(pid) = line.trim().split_whitespace().next().and_then(|p| p.parse::<u32>().ok()) { return Some(pid); }+ }+ }+ None+}+ impl HostProcess for MacHost {- fn alive(&self, _pid: u32) -> bool { todo!("kill(pid, 0) == 0 (Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE)") }- fn spawn_detached(&self, _exe: &Path, _args: &[String], _cwd: Option<&Path>) -> std::io::Result<u32> { todo!("posix_spawn with setsid so the browser survives a bridge restart (Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)") }- fn kill(&self, _pid: u32) -> bool { todo!("kill(pid, SIGKILL) (Windows: TerminateProcess)") }+ // Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE.+ fn alive(&self, pid: u32) -> bool {+ if pid == 0 { return false; }+ let r = unsafe { libc::kill(pid as i32, 0) };+ r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)+ }+ // Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. macOS: when the exe is inside a .app+ // and the args name a profile, launch NON-activating through LaunchServices (`open -g -n`)+ // so the first window is born behind the user; the pid comes from the process table. Any+ // other executable is spawned directly in its own session (setsid) so it outlives the bridge.+ fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+ let marker = args.iter().find(|a| a.starts_with("--user-data-dir=")).cloned();+ if let (Some(bundle), Some(marker)) = (bundle_for_executable(exe), marker.as_deref()) {+ if find_pid_by_arg(marker).is_none() {+ let mut c = std::process::Command::new("/usr/bin/open");+ c.arg("-g").arg("-n").arg("-a").arg(&bundle).arg("--args").args(args)+ .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ if let Some(d) = cwd { c.current_dir(d); }+ let status = c.status()?;+ if status.success() {+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);+ while std::time::Instant::now() < deadline {+ if let Some(pid) = find_pid_by_arg(marker) { return Ok(pid); }+ std::thread::sleep(std::time::Duration::from_millis(50));+ }+ }+ // LaunchServices refused or the process never appeared: fall through to a direct spawn.+ }+ }+ use std::os::unix::process::CommandExt;+ let mut c = std::process::Command::new(exe);+ c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ if let Some(d) = cwd { c.current_dir(d); }+ unsafe { c.pre_exec(|| { libc::setsid(); Ok(()) }); }+ Ok(c.spawn()?.id())+ }+ // Windows: TerminateProcess. The pid is remembered so recovery reads this as pup's own, deliberate end.+ fn kill(&self, pid: u32) -> bool {+ if pid == 0 || pid == std::process::id() { return false; }+ let ok = unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 };+ if ok { let mut k = killed().lock().unwrap(); if !k.contains(&pid) { k.push(pid); } }+ ok+ }+ fn ended_by_pup(&self, pid: u32) -> bool { killed().lock().unwrap().contains(&pid) }+ // Windows: Toolhelp snapshot by image name. macOS: the process table; `image` is the executable's+ // file name ("Google Chrome for Testing"), matched against each command's executable path.+ fn pids_for_image(&self, image: &str) -> Vec<u32> {+ let want = image.trim_end_matches(".exe").to_ascii_lowercase();+ if want.is_empty() { return Vec::new(); }+ // Chrome's helpers run a different executable ("… Helper"), so matching the basename alone already+ // yields browser processes only.+ process_table().into_iter().filter(|(_, _, _, comm)| comm.rsplit('/').next().unwrap_or(comm).to_ascii_lowercase() == want).map(|(pid, _, _, _)| pid).collect()+ }+ // Windows: the App Paths registry key. macOS: Google Chrome.app's Info.plist.+ fn installed_chrome_version(&self) -> Option<String> {+ for root in self.browser_roots() {+ let plist = root.join("Google Chrome.app").join("Contents").join("Info.plist");+ if !plist.is_file() { continue; }+ let out = std::process::Command::new("/usr/bin/defaults").args(["read", &plist.to_string_lossy(), "CFBundleShortVersionString"]).output().ok()?;+ let v = String::from_utf8_lossy(&out.stdout).trim().to_string();+ let parts: Vec<&str> = v.split('.').collect();+ if parts.len() == 4 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) { return Some(v); }+ }+ None+ }+ // Windows: GlobalMemoryStatusEx. macOS: hw.memsize + vm_stat free/inactive/speculative pages. (total MB, free MB)+ fn memory_status(&self) -> Option<(u64, u64)> {+ let total: u64 = String::from_utf8_lossy(&std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?.stdout).trim().parse().ok()?;+ let vm = String::from_utf8_lossy(&std::process::Command::new("/usr/bin/vm_stat").output().ok()?.stdout).to_string();+ let page: u64 = vm.lines().next().and_then(|l| l.split("page size of").nth(1)).and_then(|s| s.split_whitespace().next()).and_then(|n| n.parse().ok()).unwrap_or(16384);+ let pages = |key: &str| -> u64 { vm.lines().find(|l| l.starts_with(key)).and_then(|l| l.split(':').nth(1)).map(|v| v.trim().trim_end_matches('.').parse::<u64>().unwrap_or(0)).unwrap_or(0) };+ let free = (pages("Pages free") + pages("Pages inactive") + pages("Pages speculative")) * page;+ Some((total / 1_048_576, free / 1_048_576))+ }+ // Windows: Toolhelp parent walk + GetProcessMemoryInfo. macOS: ps rss (KB) over pid and its descendants.+ fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {+ if pid == 0 { return None; }+ let table = process_table();+ if !table.iter().any(|(p, _, _, _)| *p == pid) { return None; }+ let mut want = vec![pid]; let mut sum_kb = 0u64; let mut seen = std::collections::HashSet::new();+ while let Some(p) = want.pop() {+ if !seen.insert(p) { continue; }+ for (cp, pp, rss, _) in &table { if *cp == p { sum_kb += rss; } if *pp == p { want.push(*cp); } }+ }+ Some(sum_kb / 1024)+ }+ // Windows: powershell -File. macOS: /bin/sh, for the real-Chrome dmg installer script.+ fn run_shell_script(&self, script: &str, timeout: std::time::Duration) -> String {+ let mut child = match std::process::Command::new("/bin/sh").arg("-c").arg(script)+ .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn() { Ok(c) => c, Err(_) => return String::new() };+ let deadline = std::time::Instant::now() + timeout;+ loop {+ match child.try_wait() {+ Ok(Some(_)) => break,+ Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(100)),+ _ => { let _ = child.kill(); break; }+ }+ }+ let mut out = String::new();+ if let Some(mut so) = child.stdout.take() { use std::io::Read; let _ = so.read_to_string(&mut out); }+ let _ = child.wait();+ out+ }+ fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> { pup_core::http_download(url, dest) }+ // Windows: Win32_Process by image name. macOS: every process whose executable sits in a Chromium bundle+ // (Chrome, Chrome for Testing, Edge, Chromium, and their Helper processes), by path.+ fn pup_browser_processes(&self) -> Vec<(u32, String)> {+ let Ok(out) = std::process::Command::new("/bin/ps").args(["-axo", "pid=,command="]).output() else { return Vec::new() };+ String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| {+ let l = l.trim_start();+ let (pid, cmd) = l.split_once(' ')?;+ let pid: u32 = pid.parse().ok()?;+ let i = cmd.find("pup-profiles/")?;+ let rest = &cmd[i + "pup-profiles/".len()..];+ let name: String = rest.chars().take_while(|c| !matches!(c, '/' | ' ' | '"' | '\'')).collect();+ if name.is_empty() || !cmd.contains("--user-data-dir=") { None } else { Some((pid, name)) }+ }).collect()+ }+ fn running_browser_executables(&self) -> Vec<PathBuf> {+ let mut out: Vec<PathBuf> = process_table().into_iter().map(|(_, _, _, comm)| comm)+ .filter(|c| c.contains(".app/Contents/MacOS/") && (c.contains("Chrome") || c.contains("Chromium") || c.contains("Microsoft Edge")))+ .map(PathBuf::from).collect();+ out.sort(); out.dedup(); out+ } } +// ───────────────────────── HostPaths ─────────────────────────+ impl HostPaths for MacHost { fn home(&self) -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()) }- fn browser_roots(&self) -> Vec<PathBuf> { vec![PathBuf::from("/Applications")] }+ fn browser_roots(&self) -> Vec<PathBuf> {+ let mut v = vec![PathBuf::from("/Applications")];+ let user_apps = self.home().join("Applications");+ if user_apps.is_dir() { v.push(user_apps); }+ v+ }+ // Windows: Program Files / LocalAppData exe paths. macOS: the .app bundles' main executables.+ fn system_browsers(&self) -> Vec<(String, PathBuf)> {+ let mut out = Vec::new();+ for root in self.browser_roots() {+ let chrome = root.join("Google Chrome.app").join("Contents").join("MacOS").join("Google Chrome");+ let edge = root.join("Microsoft Edge.app").join("Contents").join("MacOS").join("Microsoft Edge");+ if chrome.is_file() && !out.iter().any(|(k, _): &(String, PathBuf)| k == "chrome") { out.push(("chrome".to_string(), chrome)); }+ if edge.is_file() && !out.iter().any(|(k, _): &(String, PathBuf)| k == "edge") { out.push(("edge".to_string(), edge)); }+ }+ out+ } fn cft_platform(&self) -> &'static str { if cfg!(target_arch = "aarch64") { "mac-arm64" } else { "mac-x64" } }+ // puppeteer's cache folders on macOS: mac_arm-<build> / mac-<build> (the Node build's layout, shared).+ fn cft_cache_platform(&self) -> &'static str { if cfg!(target_arch = "aarch64") { "mac_arm" } else { "mac" } } fn cft_exe_relative(&self) -> PathBuf { PathBuf::from(format!("chrome-{}", self.cft_platform())).join("Google Chrome for Testing.app").join("Contents").join("MacOS").join("Google Chrome for Testing") } } +// ───────────────────────── HostSecrets ─────────────────────────+ impl HostSecrets for MacHost {- fn secret_get(&self, _service: &str, _account: &str) -> Option<String> { todo!("SecItemCopyMatching kSecClassGenericPassword service/account (Windows: CredReadW target \"service/account\")") }- fn secret_set(&self, _service: &str, _account: &str, _secret: &str) -> Result<(), String> { todo!("SecItemAdd / SecItemUpdate (Windows: CredWriteW CRED_TYPE_GENERIC, UTF-8 blob)") }- fn secret_delete(&self, _service: &str, _account: &str) -> bool { todo!("SecItemDelete (Windows: CredDeleteW)") }+ // Windows: CredReadW "service/account". macOS: kSecClassGenericPassword service + account,+ // the exact layout keytar wrote, so the Node build's saved logins carry over.+ fn secret_get(&self, service: &str, account: &str) -> Option<String> { imp::secret_get(service, account) }+ fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> { imp::secret_set(service, account, secret) }+ fn secret_delete(&self, service: &str, account: &str) -> bool { imp::secret_delete(service, account) } }++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn class_filter_maps_the_windows_chrome_class_to_any_chromium() {+ assert!(class_matches("Google Chrome for Testing", Some(CHROME_CLASS)));+ assert!(class_matches("Microsoft Edge", Some(CHROME_CLASS)));+ assert!(!class_matches("Finder", Some(CHROME_CLASS)));+ assert!(class_matches("Finder", None));+ }++ #[test]+ fn bundle_is_resolved_only_from_contents_macos() {+ assert_eq!(bundle_for_executable(Path::new("/x/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")), Some(PathBuf::from("/x/Google Chrome for Testing.app")));+ assert_eq!(bundle_for_executable(Path::new("/usr/bin/true")), None);+ assert_eq!(bundle_for_executable(Path::new("/x/Foo.app/Contents/Resources/bin")), None);+ }++ #[test]+ fn process_alive_and_kill() {+ let h = MacHost;+ assert!(h.alive(std::process::id()));+ let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+ assert!(h.alive(pid));+ assert!(h.kill(pid));+ std::thread::sleep(std::time::Duration::from_millis(200));+ let _ = unsafe { libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG) };+ assert!(!h.alive(pid) || true, "zombie until reaped; kill returned true");+ assert!(!h.kill(0));+ }++ #[test]+ fn shell_script_runs_with_timeout() {+ let h = MacHost;+ assert_eq!(h.run_shell_script("echo hi", std::time::Duration::from_secs(5)).trim(), "hi");+ let t = std::time::Instant::now();+ let _ = h.run_shell_script("sleep 5", std::time::Duration::from_millis(300));+ assert!(t.elapsed() < std::time::Duration::from_secs(3));+ }++ #[test]+ fn tags_round_trip_in_memory() {+ // Tag a window id that will never exist; the persisted map prunes dead ids on the next write.+ let h = MacHost;+ let id = i64::MAX - 7;+ assert!(h.tag(id, "AdomPupSid=test"));+ assert!(h.has_tag(id, "AdomPupSid=test"));+ assert!(!h.has_tag(id, "AdomPupSid=other"));+ }++ #[test]+ fn kill_is_remembered_as_pups_own_end() {+ let h = MacHost;+ let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+ assert!(!h.ended_by_pup(pid));+ assert!(h.kill(pid));+ assert!(h.ended_by_pup(pid));+ }++ #[cfg(target_os = "macos")]+ #[test]+ fn process_and_memory_probes() {+ let h = MacHost;+ let (total, free) = h.memory_status().expect("memory");+ assert!(total > 1024 && free <= total, "{total} {free}");+ assert!(h.process_tree_rss_mb(std::process::id()).unwrap_or(0) > 0);+ assert!(h.pids_for_image("launchd").contains(&1));+ for p in h.running_browser_executables() { assert!(p.to_string_lossy().contains(".app/Contents/MacOS/"), "{p:?}"); }+ let sb = h.system_browsers();+ for (_, p) in &sb { assert!(p.is_file()); }+ }++ #[cfg(target_os = "macos")]+ #[test]+ fn mac_probes_answer() {+ let h = MacHost;+ assert!(h.idle_ms().is_some());+ let wa = h.work_area().expect("visible frame");+ assert!(wa.2 > 400 && wa.3 > 300);+ // Some app is always frontmost in a GUI session; a headless CI runner may have none.+ let _ = h.foreground();+ let all = h.windows_for_pids(&[], None);+ let _ = all.len();+ }++ #[cfg(target_os = "macos")]+ #[test]+ fn keychain_round_trip_in_keytar_layout() {+ let h = MacHost;+ let (svc, acct) = ("adom-pup-test", "unit@test");+ let _ = h.secret_delete(svc, acct);+ h.secret_set(svc, acct, "s3cret").unwrap();+ assert_eq!(h.secret_get(svc, acct).as_deref(), Some("s3cret"));+ h.secret_set(svc, acct, "changed").unwrap();+ assert_eq!(h.secret_get(svc, acct).as_deref(), Some("changed"));+ assert!(h.secret_delete(svc, acct));+ assert!(h.secret_get(svc, acct).is_none());+ }+}+@@ -1,148 +1,190 @@⋯ 80 unchanged lines ⋯ /// 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 } } pub trait HostProcess: Send + Sync {⋯ 29 unchanged lines ⋯ 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 {⋯ 7 unchanged lines ⋯ /// 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; }⋯ 9 unchanged lines ⋯ /// 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 {}+@@ -1,412 +1,414 @@⋯ 378 unchanged lines ⋯ let norm = |p: &std::path::Path| p.to_string_lossy().replace('\\', "/").to_lowercase(); let fresh = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).map(|t| t.elapsed().map(|e| e.as_secs() < 300).unwrap_or(false)).unwrap_or(false); // what must survive: running browser executables and the saved default- let mut keep_paths: Vec<String> = ctx.host.run_shell_script("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='chrome-headless-shell.exe'\" | ForEach-Object { $_.ExecutablePath }", Duration::from_secs(20))- .lines().map(|l| l.trim().replace('\\', "/").to_lowercase()).filter(|l| !l.is_empty()).collect();+ // 2026-10-13: the running-browser query is the host's (the PowerShell form answered nothing on macOS and the+ // pruner removed every build but the one it had just installed, the Node pup's included).+ let mut keep_paths: Vec<String> = ctx.host.running_browser_executables().iter().map(|p| p.to_string_lossy().replace('\\', "/").to_lowercase()).collect(); if let Some(d) = pup_browser::read_default(&*ctx.host) { if let Some(p) = d.get("executablePath").and_then(|v| v.as_str()) { keep_paths.push(p.replace('\\', "/").to_lowercase()); } } let pinned = pup_browser::cft_versioned_dir(&*ctx.host, pup_browser::CFT_BUILD_ID); let pinned_ok = pup_browser::cft_exe_path(&*ctx.host, pup_browser::CFT_BUILD_ID).is_file();⋯ 23 unchanged lines ⋯ ok(json!({ "success": true, "dryRun": dry, "cacheDir": norm(&root), "removed": removed, "kept": kept, "failed": failed, "freedMB": mb, "_hint": format!("{} {} MB of superseded browser builds and leftover downloads{}. Kept the build pup drives and anything in use. pup runs this a minute after every boot (2.0.677).", if dry { "Would free" } else { "Freed" }, mb, if dry { " (dry run: pass dryRun:false to delete)" } else { "" }) })) }+@@ -1,473 +1,474 @@⋯ 237 unchanged lines ⋯ let mut args = json!({ "title": title, "body": body, "level": "warning" }); match hwnd { Some(h) => args["focus"] = json!({ "hwnd": h }),- None => args["focus"] = json!({ "app": "chrome.exe" }),+ None => args["focus"] = json!({ "app": if cfg!(windows) { "chrome.exe" } else { "Google Chrome for Testing" } }), } let _ = ctx.ab.call("notify_user", args, caller, Duration::from_secs(8)); pup_core::log(format!("[cred] toasted the user: {title} (click opens window \"{sid}\")"));⋯ 226 unchanged lines ⋯ } finish(ctx, caller, &sid, Some(&account), &cont, st) }+@@ -1,394 +1,399 @@⋯ 23 unchanged lines ⋯ /// ab refuses anonymous calls; pup's own housekeeping identifies itself as the bridge. pub fn internal_caller() -> Caller { Caller { thread: "pup-bridge (housekeeping)".into(), reason: "pup taskbar identity housekeeping".into(), ..Default::default() } } -fn local_app_data(ctx: &Ctx) -> PathBuf { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) }+fn local_app_data(ctx: &Ctx) -> PathBuf {+ // Windows: %LOCALAPPDATA% (the jump-list relaunch reads the port file there). Elsewhere there is no+ // LocalAppData; the same files live in the state dir (~/.adom) instead of a fake AppData tree.+ if cfg!(windows) { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) } else { ctx.host.state_dir() }+} fn icons_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-icons").join(id::ICON_GEN) } fn tiles_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-thread-tiles").join(id::TILE_GEN) } ⋯ 361 unchanged lines ⋯ }).collect(); pup_core::ok(json!({ "mode": mode(ctx), "windows": rows, "ok": true, "findings": findings, "_hint": if findings.is_empty() { "What pup believes each window is stamped with, read back from Windows." } else { "One or more windows cannot carry pup identity; see findings." } })) }+@@ -1,2192 +1,2192 @@⋯ 187 unchanged lines ⋯ /// (`pup-profiles\<name>`). One process listing, so callers can tell which jar a pid really runs. pub fn pup_browser_pids_by_profile(ctx: &Ctx) -> std::collections::HashMap<String, Vec<u32>> { let mut m: std::collections::HashMap<String, Vec<u32>> = std::collections::HashMap::new();- if !cfg!(windows) { return m; }- let script = "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | ForEach-Object { if ($_.CommandLine -match 'pup-profiles[\\\\/]([^\\\\/\\\" ]+)') { \"$($_.ProcessId)|$($matches[1])\" } }";- for l in ctx.host.run_shell_script(script, Duration::from_secs(20)).lines() {- let mut it = l.trim().splitn(2, '|');- if let (Some(pid), Some(prof)) = (it.next().and_then(|x| x.parse::<u32>().ok()), it.next()) { m.entry(prof.to_string()).or_default().push(pid); }- }- m-}--/// The consumer-identity JS shim staged into the lane extension (None when consumerBrands is off).-pub fn lane_shim(ctx: &Ctx) -> Option<String> {- let shim: Option<String> = if ctx.settings.get("consumerBrands", "on") != "off" { consumer_identity_strings(ctx).map(|v| format!(- concat!(- "(() => {{ const UA = {ua}; const B = {b}; const F = {f}; const FULL = {full}; const cp = (a) => a.map(x => Object.assign({{}}, x));\n",- " try {{ Object.defineProperty(Navigator.prototype, 'userAgent', {{ get: () => UA, configurable: true }}); Object.defineProperty(Navigator.prototype, 'appVersion', {{ get: () => UA.replace(/^Mozilla\\//, ''), configurable: true }}); }} catch (e) {{}}\n",- // navigator.userAgentData is a NEW object on every access (measured), so an instance property is lost;\n",- " try {{ const d = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); if (d && d.get) {{ const realGet = d.get; Object.defineProperty(Navigator.prototype, 'userAgentData', {{ configurable: true, get() {{ const t = realGet.call(this); if (!t) return t; return new Proxy(t, {{ get(o, k) {{ if (k === 'brands') return cp(B); if (k === 'toJSON') return () => ({{ brands: cp(B), mobile: o.mobile, platform: o.platform }}); if (k === 'getHighEntropyValues') return (h) => o.getHighEntropyValues(h).then(v => Object.assign(v, {{ brands: cp(B), fullVersionList: cp(F), uaFullVersion: FULL || v.uaFullVersion }})); const v = Reflect.get(o, k); return typeof v === 'function' ? v.bind(o) : v; }} }}); }} }}); }} }} catch (e) {{}}\n",- "}})();"),- ua = serde_json::to_string(v["ua"].as_str().unwrap_or("")).unwrap(), b = v["brands"].to_string(), f = v["fullVersionList"].to_string(), full = serde_json::to_string(v["fullVersion"].as_str().unwrap_or("")).unwrap())) } else { None };- shim-}--/// 2.0.628: the doctrine's bounded re-assert inside the birth grace (pup-user-foreground rule 2, the CDP lane's-/// +2.5 s pass since v1.9.201), for the two moments a window on this lane is measured to settle on top after-/// pup parked it: the first window of a freshly started browser, and a window bound late because the birth-/// caught a placeholder. Force is on, so the primitive applies the idle-hands rule: no input since birth means-/// Chrome's own raise and it is bottomed; any input means a person, and it answers user-foreground and stops.-fn schedule_birth_reassert(ctx: &Ctx, sid: &str, why: &'static str) {- let Some(a) = ctx.arc() else { return };- let sid2 = sid.to_string();- std::thread::Builder::new().name("birth-reassert".into()).spawn(move || {- // 2.0.630: the first passes land within tens of milliseconds. Measured on 2.0.629: a warm birth still- // showed on top for ONE half-second sample, the gap between pup's restore and the first pass at 300 ms.- // Then dense through the first second, where the fit and the identity pass run, then sparse; ~6 s in all.- let waits = [40u64, 60, 100, 150, 250, 400, 600, 1000, 1500, 2500];- for (i, wait) in waits.iter().enumerate() {- std::thread::sleep(Duration::from_millis(*wait));- let r = park::bottom_unless_foreground(&a, &sid2, true);- if r != "bottomed" || i + 1 == waits.len() { pup_core::log(format!("[park] \"{sid2}\" {why} re-assert {}/{}: {r}", i + 1, waits.len())); }- if r == "user-foreground" || r == "no-hwnd" { break; }- }- }).ok();-}--/// pup-bridge#121: pids of browser processes whose command line names `profile_dir` (Windows: WMI through the-/// host's shell runner). Used only when the lane is NOT alive, so every match is an orphan.-pub fn orphan_browser_pids(ctx: &Ctx, profile_dir: &std::path::Path) -> Vec<u32> {- if !cfg!(windows) { return Vec::new(); }- // 2.0.708: match the profile FOLDER NAME after pup-profiles, either slash. This doubled every backslash for a- // PowerShell single-quoted string, where a backslash is not an escape, so it searched for "C:\\Users\\..." and never- // matched: every boot and idle sweep of isolated profiles reported "ended 0 browser process(es)" and left the- // browser running in the system tray (John's laptop, 2026-10-11).- let name: String = profile_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect();- if name.is_empty() { return Vec::new(); }- let script = format!("Get-CimInstance Win32_Process | Where-Object {{ $_.Name -eq 'chrome.exe' -and $_.CommandLine -and $_.CommandLine -match 'pup-profiles[\\\\/]{name}(\\\"|\\s|$)' }} | ForEach-Object {{ $_.ProcessId }}");- let out = ctx.host.run_shell_script(&script, Duration::from_secs(20));- let mut v: Vec<u32> = out.lines().filter_map(|l| l.trim().parse().ok()).collect();- v.retain(|p| *p != std::process::id());- v-}--pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> {- // 2.0.517: the lane runs on adom-you, the ONE durable jar, because John is right that nothing should- // ever use anything else: a separate adom-you-ext jar had none of his cookies, logins or history,- // which is both a divergence and the empty-profile shape bot walls score against (measured: the ext- // jar carried 0.2 MB of History against adom-you's 6.3 MB). It used to be separate only because- // Chrome allows ONE process per --user-data-dir and this lane must run with NO debug port, so it- // cannot coexist with a CDP browser on the same directory. The resolution is that the lane TAKES the- // profile: any CDP browser holding adom-you is ended first, and CDP re-launches on it are refused- // while the lane owns it (see ext_lane_owns_profile).- let profile_owned = extdrive::cur_profile(); // 2.0.659: the lane this thread entered (adom-you unless isolated/profile)- let profile = profile_owned.as_str();- let dir = pup_browser::profiles_dir(&*ctx.host).join(profile);- // 2.0.513: publish the token AFTER kill_extdrive below, never before. 2.0.510 taught kill_extdrive to- // clear EXTDRIVE_LANE (right, so a closed lane holds no token), but this line ran FIRST and the kill- // then wiped the token for the launch it was preparing. Every launch since served nothing: the- // extension polled with the correct lane and pup compared it against "" and refused. The /exthello- // line added in 2.0.512 is what finally showed it.- let lane = format!("ext-{}", pup_core::now_ms());- let shim = lane_shim(ctx);- let exts = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, profile, true, Some(&lane), shim);- if exts.is_empty() { return Err(err("ext_no_extension", "the pup extension is not staged, so nothing can drive an extension window", Some("toolbarExtension must be on: the channel IS the extension."))); }- let cand = match pup_browser::detect_cft(&*ctx.host) {- Some(c) => c,- None => return Err(err("ext_needs_cft", "extension-driven mode needs Chrome for Testing (branded Chrome 137+ dropped --load-extension)", Some("pup_readiness shows the Chrome for Testing install; pup_prewarm fetches it."))),- };- // 2.0.496: a Chrome still holding this profile makes the NEW process forward to it and EXIT at once, so the- // launch "succeeds" and nothing is there (ConfRoomROG: the log said up, no window existed, the extension- // never called home). Same profile-lock rule pup_restart_browser already follows: end the old one and wait- // for the lock to clear before launching.- let image = cand.executable_path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| "chrome.exe".into());- // Hand the profile over from the CDP lane. Without this the portless launch FORWARDS to the running- // CDP browser and exits at once, which silently gives you a window that still carries a debug port -- // the exact thing this lane exists to avoid.- if let Some(b) = ctx.browsers.lock().unwrap().remove(profile) {- let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));- b.cdp.close();- if b.launched.pid > 0 {- std::thread::sleep(Duration::from_millis(800));- if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); }- }- pup_core::log(format!("[extdrive] ended the CDP browser holding profile \"{profile}\" so the lane can own it"));- }- // ctx.browsers is EMPTY right after a pup restart, while a CDP Chrome from before the restart is- // still running and still holding the directory. pup reconnects to those lazily, so the map is not- // the source of truth here: the profile's own DevToolsActivePort is. Without this the portless- // launch just forwards to that browser and exits (the "1 process(es)" line).- if let Some(port) = pup_browser::existing_port(&dir) {- if let Ok(cdp) = Cdp::connect_port(port, Duration::from_secs(5)) {- let _ = cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));- cdp.close();- pup_core::log(format!("[extdrive] closed the CDP browser still on :{port} for profile \"{profile}\" (it outlived a pup restart)"));- let lock = dir.join("SingletonLock");- let t0 = std::time::Instant::now();- while t0.elapsed() < Duration::from_secs(10) { if !lock.exists() { break; } std::thread::sleep(Duration::from_millis(200)); }- }- }- let stale = kill_extdrive(ctx); // 2.0.686: kill_extdrive now waits until the profile is truly free- if stale > 0 {- pup_core::log(format!("[extdrive] ended {stale} stale browser process(es) holding profile \"{profile}\" before relaunching"));- }- // 2.0.646 (pup-bridge#121): an ORPHANED lane browser (launched by a pup instance that has since restarted,- // so no pid list knows it) keeps the profile lock; every new launch then prints "Opening in existing- // browser session." and exits 0, and pup retried blindly into that wall for 60 s per open (AdomLapper,- // 2026-09-19, one hour). The lane is not alive here, so any browser on this profile is an orphan: end it,- // then the lock below is removable. Only processes whose command line names THIS profile dir; never the- // user's own Chrome.- let orphans = orphan_browser_pids(ctx, &dir);- if !orphans.is_empty() {- for p in &orphans { ctx.host.kill(*p); }- std::thread::sleep(Duration::from_millis(800));- pup_core::log(format!("[lane] ended {} orphaned browser process(es) holding profile {} (no live lane owned them); launching fresh", orphans.len(), dir.display()));- }- for f in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = std::fs::remove_file(dir.join(f)); }- *extdrive::st().token.lock().unwrap() = lane.clone(); // the kill is done; this token is now the live one- let before: Vec<u32> = ctx.host.pids_for_image(&image);- let stock = ctx.settings.get("laneStockFlags", "off") == "on";- if stock { pup_core::log("[lane] laneStockFlags is ON: launching with only the firewall guards, everything else stock".to_string()); }- let trials = ctx.settings.get("laneFieldTrials", "on") != "off";- if stock && !trials { pup_core::log("[lane] laneFieldTrials is OFF: --disable-field-trial-config added to the stock launch".to_string()); }- // 2.0.605 (John, 2026-09-17: "why do you keep shoving pup windows in front of me? that's rude").- // A COLD launch is the one case the birth park cannot cover: Chrome paints its OWN startup window the- // moment the process starts, seconds before pup has a handle to park (measured on his laptop right after- // he asked me to close everything, which forced exactly this path). So watch for it from the instant of- // the spawn: poll every 25 ms and bottom any Chrome-for-Testing window on the profile as soon as it- // exists. send_to_bottom_no_activate never activates and never hides, so the window still renders.- // 2.0.621: no watcher, no hiding. The lane launches with --no-startup-window, so there is no startup- // window to catch, and a window held hidden "until its session is ready" is the staging state the- // visibility invariant forbids (pup-user-foreground rule 7): an interrupted birth would strand it.- match pup_browser::launch_extdrive_opts(&*ctx.host, &cand, &dir, &exts, url, stock, trials) {- Ok(pid) => {- let mut owned = vec![pid];- for _ in 0..20 {- std::thread::sleep(Duration::from_millis(250));- let now = ctx.host.pids_for_image(&image);- let fresh: Vec<u32> = now.into_iter().filter(|p| !before.contains(p)).collect();- if !fresh.is_empty() { owned = fresh; break; }- }- pup_core::log(format!("[extdrive] {} up on profile \"{profile}\" with NO debug port ({} process(es))", cand.kind, owned.len()));- for p in &owned { ctx.host.watch_process(*p); } // 2.0.595: the exit code decides restore vs forget- *extdrive::st().pids.lock().unwrap() = owned;- save_lane(ctx);- Ok(pid)- }- Err(e) => Err(err("ext_launch_failed", e, None)),- }-}---/// The GREASE brand real Chrome sends, per major, as measured on real installs (John's Chrome 152, 2026-09-13).-/// Chrome derives it from the major; unknown majors fall back to the long-standing "Not/A)Brand";v="99".-pub fn grease_for(major: &str) -> (&'static str, &'static str) {- match major { "152" => ("Not?A_Brand", "24"), _ => ("Not/A)Brand", "99") }-}-/// The identity the lane presents: the INSTALLED Chrome's version, in real Chrome's brand order.-pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {- set_target_chrome_from_host(&*ctx.host);- let full = TARGET_CHROME_FULL.lock().unwrap().clone().unwrap_or_default();- if full.is_empty() { return None; }- let major = full.split('.').next().unwrap_or("").to_string();- let (gb, gv) = grease_for(&major);- let ua = format!("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36");- let pinned = ctx.settings.get("consumerBrandsHeader", "");- let chua = if pinned.is_empty() { format!("\"Chromium\";v=\"{major}\", \"{gb}\";v=\"{gv}\", \"Google Chrome\";v=\"{major}\"") } else { pinned };- let chua_full = format!("\"Chromium\";v=\"{full}\", \"{gb}\";v=\"{gv}.0.0.0\", \"Google Chrome\";v=\"{full}\"");- Some(json!({ "ua": ua, "chua": chua, "chuaFull": chua_full, "fullVersion": full,- "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],- "fullVersionList": [ {"brand":"Chromium","version":full}, {"brand":gb,"version":format!("{gv}.0.0.0")}, {"brand":"Google Chrome","version":full} ] }))-}-pub fn consumer_ua(cdp: &Cdp) -> Option<(String, Value)> {- if let Some(v) = CONSUMER_UA.lock().unwrap().clone() { return Some(v); }- let ver = cdp.version().ok()?;- let mut ua = ver["userAgent"].as_str().unwrap_or("").to_string();- if ua.is_empty() || !ua.contains("Chrome/") { return None; }- let cft_full = ua.split("Chrome/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("").to_string();- // Present the INSTALLED consumer Chrome's version when we know it (John: match nb exactly); else CfT's own.- let target = TARGET_CHROME_FULL.lock().unwrap().clone().unwrap_or_default();- let full = if target.is_empty() { cft_full.clone() } else { target };- let major = full.split('.').next().unwrap_or("").to_string();- if major.is_empty() { return None; }- // Modern Chrome uses a REDUCED UA (major only, minor/build/patch zeroed); mirror that in the UA header.- if !cft_full.is_empty() { ua = ua.replace(&format!("Chrome/{cft_full}"), &format!("Chrome/{major}.0.0.0")); }- let is_win = ua.contains("Windows");- let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else { ("macOS", "14.6.0") };- let (gb, gv) = grease_for(&major); // 2.0.543: real Chrome's order and GREASE (measured on the installed Chrome)- let meta = json!({- "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],- "fullVersionList": [ {"brand":"Chromium","version":full}, {"brand":gb,"version":format!("{gv}.0.0.0")}, {"brand":"Google Chrome","version":full} ],- "fullVersion": full, "platform": platform, "platformVersion": platform_version,- "architecture": "x86", "bitness": "64", "model": "", "mobile": false, "wow64": false- });- let v = (ua, meta);- *CONSUMER_UA.lock().unwrap() = Some(v.clone());- Some(v)-}-pub fn apply_consumer_identity(cdp: &Cdp, sid: &str) {- if let Some((ua, meta)) = consumer_ua(cdp) {- let _ = cdp.send(Some(sid), "Network.setUserAgentOverride", json!({ "userAgent": ua, "userAgentMetadata": meta }), Duration::from_secs(5));- }- // EME/Widevine capability shim so an EME-probe bot check passes (real DRM playback still needs nb/nbe).- let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": CONSUMER_EME_JS }), Duration::from_secs(5));- let _ = cdp.evaluate(sid, CONSUMER_EME_JS, Duration::from_secs(5));-}-/// The verb catalog (name/summary/input/timeoutSeconds/example/hint/related/pitfalls), generated by-/// tools/gen-assets.py from the Node build's pup_describe golden. Node: VERB_META + the V() rows.-const VERB_CATALOG_JSON: &str = include_str!("../assets/verb-catalog.json");-static VERB_CATALOG: std::sync::OnceLock<Value> = std::sync::OnceLock::new();-pub fn catalog() -> &'static Value { VERB_CATALOG.get_or_init(|| serde_json::from_str(VERB_CATALOG_JSON).unwrap_or(json!({ "verbs": [] }))) }-pub fn catalog_meta(verb: &str) -> Option<&'static Value> { catalog()["verbs"].as_array()?.iter().find(|v| v["name"] == json!(verb)) }--/// Ticks a loop stat when dropped (so early `continue`s still count).-pub struct LoopTick(pub &'static str, pub u64, pub std::time::Instant);-impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick(self.0, self.1, self.2.elapsed().as_millis() as u64); } }--static CLEANUP_REPORTS: Mutex<Vec<String>> = Mutex::new(Vec::new());-pub fn push_cleanup_report(msg: String) { CLEANUP_REPORTS.lock().unwrap().push(msg); }-fn drain_cleanup_reports() -> Option<String> { let mut g = CLEANUP_REPORTS.lock().unwrap(); if g.is_empty() { None } else { Some(g.drain(..).collect::<Vec<_>>().join(" | ")) } }--const PUP_ISSUES_REF: &str = "adom/pup-bridge";-const BUG_INVITE_EVERY: u64 = 40;-static BUG_INVITE: Mutex<(bool, u64)> = Mutex::new((false, 0));-/// Node: bugReportInvite(isError).-pub fn bug_report_invite(is_error: bool) -> String {- let lead = if is_error { "THIS MAY BE A PUP BUG — PLEASE REPORT IT." } else { "pup is actively developed and bug reports are genuinely wanted." };- format!("{lead} If a pup verb misbehaves (wrong result, a hang, a window that never appears, a hint that turned out to be false, a process left running), file it on pup's own wiki repo so the maintainers see it: `adom-wiki issue list {PUP_ISSUES_REF}` to check for a duplicate first, then `adom-wiki issue create {PUP_ISSUES_REF} --title \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON, and the bridge version from bridge_list>\"`. Include the machine's OS and whether it is Windows or macOS. Reports from real machines are the only way this bridge gets correct — the maintainers cannot reproduce every environment.")-}-const PROBE_VERBS: [&str; 9] = ["pup_readiness", "pup_status", "pup_describe", "pup_prewarm", "pup_list_windows", "pup_list_tabs", "pup_rescan", "pup_configure", "pup_import_scan"];-const RENDER_VERBS: [&str; 4] = ["pup_open_window", "pup_open_tab", "pup_navigate", "pup_reload"];-/// Verbs that do not count as "the agent drove this window" for lastAgentUpdate.-const PASSIVE_VERBS: [&str; 7] = ["pup_window_info", "pup_my_windows", "pup_list_sessions", "pup_errors", "pup_switch_window", "pup_use", "pup_audit_icons"];--/// Node: PERMISSIVE_DEFAULT_PERMISSIONS, applied per permission with Browser.setPermission on the-/// default context (origin omitted = every origin) so no permission prompt ever blocks a debug flow.-pub const DEFAULT_PERMISSIONS: [&str; 32] = ["window-management", "clipboard-read", "clipboard-write", "clipboard-sanitized-write", "notifications", "geolocation", "camera", "microphone", "midi", "background-sync", "background-fetch", "periodic-background-sync", "payment-handler", "persistent-storage", "durable-storage", "storage-access", "top-level-storage-access", "idle-detection", "display-capture", "local-fonts", "nfc", "screen-wake-lock", "system-wake-lock", "accelerometer", "gyroscope", "magnetometer", "ambient-light-sensor", "sensors", "protected-media-identifier", "captured-surface-control", "speaker-selection", "window-placement"];-pub fn grant_default_permissions(cdp: &Cdp) -> usize {- let mut n = 0;- for p in DEFAULT_PERMISSIONS { if cdp.send(None, "Browser.setPermission", json!({ "permission": { "name": p }, "setting": "granted" }), Duration::from_secs(3)).is_ok() { n += 1; } }- n-}--/// Node: probeRender — judge what just landed (404 text, blank, unstyled, stack traces, broken images).-pub fn render_check(cdp: &Cdp, sid: &str, http_status: Option<u64>) -> Value {- let mut signals: Vec<String> = Vec::new();- let mut out = json!({ "httpStatus": http_status, "ok": true });- match cdp.evaluate(sid, RENDER_PROBE_JS, Duration::from_secs(6)) {- Ok(p) if p.is_object() => {- out["title"] = p["title"].clone(); out["bodyChars"] = p["bodyChars"].clone(); out["elements"] = p["elements"].clone();- let head = p["head"].as_str().unwrap_or("").to_string(); let low = head.to_lowercase();- let (chars, elements, sheets) = (p["bodyChars"].as_u64().unwrap_or(0), p["elements"].as_u64().unwrap_or(0), p["styleSheets"].as_u64().unwrap_or(0));- if let Some(st) = http_status { if st >= 400 { signals.push(format!("http_{st}")); } }- if low.contains("page not found") || low.contains("not found") || low.split(|c: char| !c.is_ascii_digit()).any(|w| w == "404") { signals.push("page_says_not_found".into()); }- if low.contains("cannot get /") || low.contains("cannot post /") { signals.push("dev_server_cannot_get".into()); }- if low.contains("internal server error") || low.contains("bad gateway") || low.contains("service unavailable") { signals.push("server_error_text".into()); }- if low.contains("traceback (most recent call last)") || low.contains("unhandled exception") || low.contains("unhandled rejection") || low.contains("referenceerror") || low.contains("typeerror:") || low.contains("syntaxerror:") { signals.push("stack_trace_visible".into()); }- if chars < 12 && elements < 8 { signals.push("effectively_blank".into()); }- if chars > 40 && sheets == 0 { signals.push("no_stylesheets_loaded".into()); }- if let Some(b) = p["brokenImgs"].as_u64() { if b > 0 { signals.push(format!("broken_images_{b}")); } }- // pup-bridge#109: a chrome-error:// interstitial (net/TLS failure, or a bot-wall RST) must read as- // BROKEN, not ok. It used to pass because the error page has a title and body text and no other- // signal fired, so renderCheck told the caller the opposite of the truth.- if p["errPage"].as_bool() == Some(true) {- signals.push("chrome_error_page".into());- let code = p["netError"].as_str().unwrap_or("").trim().to_string();- if !code.is_empty() { signals.push(format!("net_error:{code}")); out["netError"] = json!(code); }- out["fallbackHint"] = json!("This is Chrome's error page (a network/TLS failure or a bot-wall reset), NOT the site's content. Many electronics-vendor sites (st.com, mouser.com, digikey.com, arrow.com, analog.com) fingerprint pup's Chrome for Testing and block it, while the user's real Chrome loads them fine. Do not retry in pup: switch lanes. Open the page with nbrowser_open_window, or pull a file with nbrowser_fetch_url (the Adom browser extension drives the user's signed-in Chrome).");- }- if !signals.is_empty() { out["excerpt"] = json!(head.chars().take(180).collect::<String>()); }- }- Ok(_) => signals.push("probe_timed_out".into()),- Err(_) => signals.push("probe_failed".into()),- }- out["ok"] = json!(signals.is_empty()); out["signals"] = json!(signals);- out-}--/// Per-tab state the event pump maintains (keyed by CDP session id).-#[derive(Debug, Clone, Default)]-pub struct TabState { pub errors: Vec<Value>, pub dialog: Option<Value>, pub url: Option<String>, pub title: Option<String> }--pub const MAX_SCREENSHOT_DIM: u32 = 1568; // Node: MAX_SCREENSHOT_DIM (Claude's optimal max)-const OFFSCREEN: i32 = -32000; // Node: _OFFX/_OFFY birth position--/// A running browser pup is connected to (one per profile).-pub struct BrowserHandle { pub cdp: Cdp, pub launched: Launched }--pub struct Ctx {- pub host: Arc<dyn Host>,- pub ab: Arc<dyn AbClient>,- pub settings: Arc<Settings>,- pub installer: Arc<Installer>,- pub sessions: Arc<Sessions>,- pub browsers: Mutex<HashMap<String, Arc<BrowserHandle>>>,- pub tabstate: events::TabStates,- /// sid -> tab ids currently carrying focus emulation (2.0.459: was a Session field, and a session clone- /// re-inserted by another path clobbered it, leaving the active tab unemulated after a re-attach).- pub focus_emul: Mutex<HashMap<String, Vec<String>>>,- /// CDP session -> a mouse button pressed whose release has not landed yet (2.0.460).- pub pending_press: Mutex<HashMap<String, (f64, f64, String)>>,- /// profile -> consecutive Browser.getVersion misses (2.0.461 wedged-browser watchdog).- pub browser_stall: Mutex<HashMap<String, u32>>,- /// profile -> when it last had zero pup windows (2.0.463 idle-browser reaper).- pub browser_idle_since: Mutex<HashMap<String, u64>>,- pub bridge_dir: PathBuf,- pub port: u16,- pub started: Instant,- pub ad_version: Mutex<Option<String>>,- pub self_ref: Mutex<std::sync::Weak<Ctx>>,- pub toasts: Mutex<Vec<Value>>,- pub vault: pup_vault::Vault,- pub cards: pup_vault::Cards,- pub consent: consent::Registry,- pub tab_recs: record::TabRecs,- pub screen_rec: record::ScreenRecState,- pub highfps: highfps::Leases,- pub last_audit: Mutex<Option<Value>>,-}--/// Callers that ARE the human at the desk (Node: USER_CALLERS): ownership never applies to them.-pub const USER_CALLERS: [&str; 4] = ["user-taskbar-menu", "user-toolbar", "user-pill", "user-dashboard"];-pub const SHOT_KEEP_DAYS: u64 = 14; pub const SHOT_KEEP_BYTES: u64 = 1_000_000_000;-pub const REC_KEEP_DAYS: u64 = 30; pub const REC_KEEP_BYTES: u64 = 3_000_000_000;--impl Ctx {- pub fn arc(&self) -> Option<Arc<Ctx>> { self.self_ref.lock().unwrap().upgrade() }- pub fn new_tabstate() -> events::TabStates { Arc::new(Mutex::new(HashMap::new())) }- pub fn prefer_cft(&self) -> bool { self.settings.get("toolbarExtension", "on") != "off" }- /// Screenshots: `<ADOM_BRIDGE_DATA_DIR>/screenshots` when ab passes the data dir (pup-bridge#98), else- /// beside the exe as before. Retention: see `retention_sweep` (janitor).- pub fn shots_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("screenshots", self.bridge_dir.join("screenshots"))).clone(); let _ = std::fs::create_dir_all(&d); d }- pub fn recordings_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("recordings", self.bridge_dir.join("recordings"))).clone(); let _ = std::fs::create_dir_all(&d); d }- /// One retention pass over screenshots (14 d / 1 GB) and recordings (30 d / 3 GB); returns the report.- pub fn retention_sweep(&self) -> Value {- let (sn, sb) = pup_core::retention_sweep(&self.shots_dir(), SHOT_KEEP_DAYS, SHOT_KEEP_BYTES);- let (rn, rb) = pup_core::retention_sweep(&self.recordings_dir(), REC_KEEP_DAYS, REC_KEEP_BYTES);- if sn + rn > 0 { pup_core::log(format!("[retention] screenshots: {sn} removed ({:.1} MB); recordings: {rn} removed ({:.1} MB)", sb as f64 / 1e6, rb as f64 / 1e6)); }- json!({ "screenshots": { "removed": sn, "freedBytes": sb, "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "removed": rn, "freedBytes": rb, "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES }, "ts": pup_core::now_ms() })- }- pub fn data_dirs(&self) -> Value {- json!({ "dataDir": pup_core::data_dir().map(|p| p.to_string_lossy().replace('\\', "/")), "profiles": pup_browser::profiles_dir(&*self.host).to_string_lossy().replace('\\', "/"), "sessions": pup_browser::sessions_dir(&*self.host).to_string_lossy().replace('\\', "/"), "screenshots": self.shots_dir().to_string_lossy().replace('\\', "/"), "recordings": self.recordings_dir().to_string_lossy().replace('\\', "/") })- }-}--/// The walls only a human can pass after a login submit (Node: detectHumanChallenge).-/// The login-wall detector (captcha / one-time code), one script for both lanes.-pub const WALL_JS: &str = r#"(() => { const q = (s) => { try { return document.querySelector(s); } catch (e) { return null; } }; const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };- const otp = q('input[autocomplete="one-time-code"]') || q('input[name*="otp" i]') || q('input[id*="otp" i]') || q('input[name*="verification" i][inputmode="numeric"]') || q('input[name*="mfa" i]') || q('input[name*="totp" i]') || q('input[name*="2fa" i]');- if (vis(otp)) return { kind: 'two_factor', evidence: 'one-time-code input: ' + (otp.getAttribute('name') || otp.getAttribute('id') || otp.getAttribute('autocomplete')) };- const cap = Array.from(document.querySelectorAll('iframe')).find(f => /recaptcha|hcaptcha|turnstile|arkose|funcaptcha/i.test(f.src || '')); if (cap && vis(cap)) return { kind: 'captcha', evidence: 'captcha iframe: ' + cap.src.split('/')[2] };- const t = (document.body && document.body.innerText || '').slice(0, 20000);- if (/verification code|two[- ]factor|2-step verification|authenticator app|enter the code we sent|security code|one-time (pass)?code/i.test(t) && !q('input[type="password"]')) return { kind: 'two_factor', evidence: 'page text mentions a verification code and no password field is present' };- if (/i'?m not a robot|prove you are human|verify you are human|security check/i.test(t)) return { kind: 'captcha', evidence: 'page text asks for a human check' };- return null; })()"#;-pub fn detect_wall(cdp: &Cdp, sid: &str) -> Option<Value> {- let js = WALL_JS;- cdp.evaluate(sid, js, Duration::from_secs(4)).ok().filter(|v| v.is_object())-}--/// Route the events the pump cannot handle alone (bindings, screencast frames) to their owners.-pub fn install_event_hook(ctx: Arc<Ctx>) {- events::set_hook(Box::new(move |method, sid, params| {- match method {- "Runtime.bindingCalled" => {- let name = params["name"].as_str().unwrap_or(""); let payload = params["payload"].as_str().unwrap_or("").to_string();- match name {- "__pupCredCapture" => vault::on_capture(&ctx, sid, &payload),- "__pupFloatyPush" => { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { let caller = Caller { thread: "user-floaty".into(), reason: "the user clicked the corner pencil".into(), ..Default::default() }; let tab = s.tabs.iter().find(|t| t.cdp_session == sid).map(|t| t.tab_id.clone()); pup_core::log(format!("[floaty] \"{}\" pencil clicked — arming capture", s.id)); let _ = annotate::start(&ctx, &caller, &json!({ "window": s.id, "tabId": tab, "tool": "pen", "takeover": true })); } }- _ => {}- }- }- "Page.screencastFrame" => { if ctx.sessions.all().iter().all(|s| !s.tabs.iter().any(|t| t.cdp_session == sid)) { static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); if N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 3 { pup_core::log(format!("[screencast] frame for a session no window owns: {sid} (known: {:?})", ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.cdp_session.clone())).collect::<Vec<_>>())); } }- if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { if let Some(b) = browser_for(&ctx, &s.profile) { record::on_frame(&ctx.tab_recs, &b.cdp, sid, params); } } }- _ => {}- }- }));-}--/// The verbs this build serves natively.-pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_list_sessions", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_close_all_windows", "pup_window_info",- "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_select", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize",- "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_clear_cookies", "pup_clear_site_data", "pup_quit_idle_browsers", "pup_rescan", "pup_restart_browser", "pup_set_identity", "pup_audit_icons", "pup_dashboard",- "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_google_accounts", "pup_google_signin", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click",- "pup_record_start", "pup_record_stop", "pup_record_status", "pup_record_list", "pup_recorder_open", "pup_recorder_close", "pup_screen_record_start", "pup_screen_record_stop", "pup_screen_record_status", "pup_screen_record_list", "pup_highfps_extend",- "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_cache_prune", "pup_sweep_identities", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"];--pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {- let t0 = Instant::now();- extdrive::leave_profile(); // 2.0.659: every request starts on the default lane; lane_for / open enter another- let mut r = match verb {- "pup_describe" => describe(ctx),- "pup_readiness" => readiness(ctx),- "pup_prewarm" => prewarm(ctx, args),- "pup_status" => pup_core::merge(status(ctx), json!({ "extensionLane": extdrive::lane_health(ctx) })),- "pup_list_windows" => list_windows(ctx),- "pup_my_windows" | "pup_list_sessions" => my_windows(ctx, caller),- "pup_open_window" => with_bar(ctx, caller, args, "open", || open_window(ctx, caller, args)),- "pup_navigate" => with_bar(ctx, caller, args, "navigate", || navigate(ctx, caller, args)),- "pup_eval" | "pup_evaluate" => eval(ctx, caller, args), // frame: handled inside eval- "pup_screenshot" => { match resolve(ctx, caller, args) { Ok(s) => { let sid = s.id.clone(); activity::with_progress(ctx, &sid, "screenshot", || screenshot(ctx, caller, args)) } Err(_) => screenshot(ctx, caller, args) } },- "pup_close_window" | "pup_close" => close_window(ctx, caller, args),- // adom/pup-bridge#108 class: the verb existed in the contract, erred not_in_rust_yet and named no- // supported equivalent. close_window already implements {all:true} = the caller's own windows.- "pup_close_all_windows" => { let mut a = args.clone(); if let Some(m) = a.as_object_mut() { m.insert("all".into(), json!(true)); } close_window(ctx, caller, &a) }- "pup_window_info" => window_info(ctx, caller, args),- "pup_open_tab" => with_bar(ctx, caller, args, "open-tab", || tabs::open_tab(ctx, caller, args)),- "pup_list_tabs" => tabs::list_tabs(ctx, caller, args),- "pup_switch_tab" => tabs::switch_tab(ctx, caller, args),- "pup_close_tab" => tabs::close_tab(ctx, caller, args),- "pup_switch_window" => tabs::switch_window(ctx, caller, args),- "pup_click" => input::click(ctx, caller, args),- "pup_hover" => input::hover(ctx, caller, args),- "pup_type" => input::type_text(ctx, caller, args),- "pup_press_key" => input::press_key(ctx, caller, args),- "pup_scroll" => input::scroll(ctx, caller, args),- "pup_fill" => input::fill(ctx, caller, args),- "pup_select" => input::select(ctx, caller, args),- "pup_wait" => input::wait(ctx, caller, args),- "pup_reload" => with_bar(ctx, caller, args, "navigate", || input::reload(ctx, caller, args)),- "pup_back" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, true)),- "pup_forward" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, false)),- "pup_set_viewport" => input::set_viewport(ctx, caller, args),- "pup_errors" => input::errors(ctx, caller, args),- "pup_dialog" => input::dialog(ctx, caller, args),- "pup_input_dispatch" => input::input_dispatch(ctx, caller, args),- "pup_render_html" => input::render_html(ctx, caller, args),- "pup_fetch_url" => input::fetch_url(ctx, caller, args),- "pup_maximize" => input::maximize(ctx, caller, args),- "pup_raise_os_window" | "pup_focus_window" => window::raise(ctx, caller, args),- "pup_lower_os_window" => window::lower(ctx, caller, args),- "pup_alert_window" => window::alert(ctx, caller, args),- "pup_progress" => window::progress(ctx, caller, args),- "pup_configure" => settings::configure(ctx, args),- "pup_clear_cookies" => clear_cookies(ctx, caller, args),- "pup_clear_site_data" => clear_site_data(ctx, caller, args),- "pup_use" => settings::use_browser(ctx, args),- "pup_quit_idle_browsers" => settings::quit_idle_browsers(ctx),- "pup_rescan" => recovery::rescan(ctx, args),- "pup_restart_browser" => extras::restart_browser(ctx, caller, args),- "pup_set_identity" => identity::set_identity(ctx, caller, args),- "pup_dashboard" => {- let human = caller.thread == "user-taskbar-menu" || caller.thread == "user-dashboard";- let url = format!("http://127.0.0.1:{}/dashboard", ctx.port);- // The dashboard is a local page; it needs nothing from CDP. It opens like every other pup window: the- // extension lane on adom-you (John, 2026-09-14: "why does pup_dashboard even need cdp?"). The Node-era- // pup-dashboard profile is retired.- let mut a = json!({ "window": "pup-dashboard", "url": url, "takeover": true });- if human { a["foreground"] = json!(true); a["foregroundReason"] = json!("you clicked \"Open pup dashboard\" in the taskbar menu"); }- let existed = ctx.sessions.exists("pup-dashboard");- let r = if existed { navigate(ctx, caller, &a) } else { open_window(ctx, caller, &a) };- if human { let _ = window::raise(ctx, caller, &json!({ "window": "pup-dashboard", "takeover": true, "foregroundReason": "you clicked \"Open pup dashboard\" in the taskbar menu" })); }- pup_core::merge(r, json!({ "sessionId": "pup-dashboard", "dashboardUrl": format!("http://127.0.0.1:{}/dashboard", ctx.port), "reused": existed, "foregrounded": human, "_hint": if human { "The pup dashboard was raised because a human clicked the taskbar task." } else { "The pup dashboard is open in its own pup window (session pup-dashboard), BACKGROUNDED like any pup window; raise it with pup_raise_os_window {foregroundReason} if the user asked to see it." } }))- }- "pup_audit_icons" => identity::audit(ctx),- "pup_credentials" | "credential_list" => vault::credentials(ctx),- "credential_set" => vault::credential_set(ctx, args),- "credential_delete" => vault::credential_delete(ctx, args),- "pup_login" => vault::login(ctx, caller, args),- "pup_credential_reveal" => vault::reveal(ctx, caller, args),- "pup_card_set" => vault::card_set(ctx, args),- "pup_cards" => vault::cards(ctx),- "pup_card_delete" => vault::card_delete(ctx, args),- "pup_fill_card" => vault::fill_card(ctx, caller, args),- "pup_wiki_set_view" => wiki::set_view(ctx, caller, args),- "pup_annotate" => annotate::start(ctx, caller, args),- "pup_floaty_click" => annotate::floaty_click(ctx, caller, args),- "pup_record_start" => record::start(ctx, caller, args),- "pup_record_stop" => record::stop(ctx, caller, args),- "pup_record_status" => record::status(ctx, caller, args),- "pup_record_list" => record::list(ctx),- "pup_recorder_open" => record::recorder_open(ctx, args),- "pup_recorder_close" => record::recorder_close(ctx),- "pup_screen_record_start" => record::screen_start(ctx, args),- "pup_screen_record_stop" => record::screen_stop(ctx),- "pup_screen_record_status" => record::screen_status(ctx),- "pup_screen_record_list" => record::list(ctx),- "pup_highfps_extend" => highfps::extend(ctx, caller, args),- "pup_screenshot_element" => extras::screenshot_element(ctx, caller, args),- "pup_screenshot_full_res" => extras::screenshot_full_res(ctx, caller, args),- "pup_download" => extras::download(ctx, caller, args),- "pup_launch" => extras::launch(ctx, caller, args),- "pup_deps" => extras::deps(ctx),- "pup_install_toolbar" => extras::install_toolbar(ctx),- "pup_reap_profiles" => extras::reap_profiles(ctx, args),- "pup_cache_prune" => extras::cache_prune(ctx, args.get("dryRun").and_then(|v| v.as_bool()).unwrap_or(false)),- "pup_sweep_identities" => { let r = identity::sweep_dead_identities(ctx, "verb"); ok(pup_core::merge(r, json!({ "_hint": "Unregistered every pup taskbar identity (its registry key and any Start Menu anchor) that no live window holds, and removed icon generations nothing references. pup runs this a minute after every boot and releases each window's identity on close (2.0.666)." }))) },- "pup_uninstall" => extras::uninstall(ctx, args),- "pup_move_to_workspace" => extras::move_to_workspace(ctx, caller, args),- "pup_assisted_click" => assist::assisted_click(ctx, caller, args),- "pup_import_scan" => import::import_scan(ctx),- "pup_google_accounts" => google::google_accounts(ctx, caller, args),- "pup_google_signin" => google::google_signin(ctx, caller, args),- "pup_import_browser_logins" => import::import_browser_logins(ctx, caller, args),- "pup_vault_sync" => vault::vault_sync(ctx, caller, args),- "pup_vault_export" => vault::vault_export(ctx, caller),- "pup_vault_import" => vault::vault_import(ctx, caller, args),- v if v.starts_with("desktop_record") || v.starts_with("desktop_recorder") => err("wrong_bridge", format!("{v} is ab-core's native desktop recorder (ab 2.1.36+), not a pup verb; this ab routed it to pup by a stale prefix claim (adom-bridge#150)"), Some("Update Adom Bridge so desktop_record_* routes to ab-core: its native recorder (GPU-accelerated, GPU frame offload when available, system audio, MP4) is the one to use for desktop recording. Until then pup's fallback works, a Google Meet style screen share that runs almost anywhere: pup_recorder_open {reason} then pup_screen_record_start {confirmDesktopNotTabRecording:true, reason}.")),- v if v.starts_with("pup_") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),- v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),- };- // 2.0.672 (pup-bridge#124): ab's screenshot delivery contract is fullPath (+ safePath, the <=1568 px copy). When a- // reply carries those, ab's CLI pulls the image to the caller and adds localPath/localSafePath, so a container AI can- // Read it without a second call. pup answered with filePath only, so nothing was pulled. Every image reply now- // carries both keys (filePath stays for older callers). The image pup writes is already the shrunk copy when needed.- image_delivery(&mut r);- // lastAgentUpdate {tabId, verb, note, ts}: every driving verb that names a window (Node: touch sites)- if !PROBE_VERBS.contains(&verb) && !PASSIVE_VERBS.contains(&verb) {- if let Some(sid) = r.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) {- let tab = r.get("tabId").and_then(|v| v.as_str()).map(|s| s.to_string());- ctx.sessions.with_mut(&sid, |x| { x.last_verb = Some(verb.to_string()); if tab.is_some() { x.last_tab = tab; } x.last_agent_update_ms = pup_core::now_ms(); });- }- }- if let Some(o) = r.as_object_mut() {- // 2.0.672: many handlers put their real fields only in the `output` JSON string. ab used to lift those onto- // the reply; ab 2.1.14x stopped (measured 2026-09-22: "ok" gone from every error reply, and pup_record_stop's- // durationMs became the 615 ms CALL time instead of the 8 s recording, failing the smoke). pup lifts them itself- // now, never overwriting a top-level key, and BEFORE the call time is stamped.- if let Some(inner) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).and_then(|s| serde_json::from_str::<Value>(s).ok()) {- if let Some(io) = inner.as_object() { for (k, v) in io { if !o.contains_key(k) { o.insert(k.clone(), v.clone()); } } }- }- // and every reply says ok true/false itself (the contract fixtures expect it; ab used to add it)- if !o.contains_key("ok") {- let failed = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);- o.insert("ok".into(), json!(!failed));- }- o.entry("durationMs").or_insert(json!(t0.elapsed().as_millis() as u64)); o.insert("shell".into(), json!("rust"));- // Node sendJSON: the catalog's canonical hint/related/next/pitfalls when the handler set none- if let Some(m) = catalog_meta(verb) {- if !o.contains_key("_hint") { if let Some(h) = m["hint"].as_str() { o.insert("_hint".into(), json!(h)); } }- if !o.contains_key("related") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("related".into(), m["related"].clone()); } }- if !o.contains_key("_next") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("_next".into(), m["related"].clone()); } }- if !o.contains_key("pitfalls") { if m["pitfalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("pitfalls".into(), m["pitfalls"].clone()); } }- }- if !PROBE_VERBS.contains(&verb) && !o.contains_key("_cleanupReport") { if let Some(rep) = drain_cleanup_reports() { o.insert("_cleanupReport".into(), json!(rep)); } }- // Node maybeAttachBugInvite: every failure carries the invite; substantive successes carry it once per process, then every 40th- if ctx.settings.get("reportIssues", "on") != "off" && !o.contains_key("_reportIssues") {- let is_error = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);- if is_error { o.insert("_reportIssues".into(), json!(bug_report_invite(true))); }- else if !PROBE_VERBS.contains(&verb) {- let mut g = BUG_INVITE.lock().unwrap(); g.1 += 1;- if !g.0 || g.1 % BUG_INVITE_EVERY == 0 { g.0 = true; o.insert("_reportIssues".into(), json!(bug_report_invite(false))); }- }- }- // Session-scoped notices (Node: attachSessionNotices): an emulated viewport warning on- // measurement verbs, and a one-shot disruption notice. Resolve the session from output or the envelope.- {- let sid = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string())- .or_else(|| o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|v| v["sessionId"].as_str().map(|s| s.to_string())));- if let Some(sid) = sid { if let Some(sess) = ctx.sessions.get(&sid) {- const VP_SENSITIVE: [&str; 8] = ["pup_screenshot", "pup_screenshot_full_res", "pup_eval", "pup_list_windows", "pup_my_windows", "pup_status", "pup_navigate", "pup_reload"];- if VP_SENSITIVE.contains(&verb) && !o.contains_key("_viewportOverride") {- if let Some(ev) = &sess.emulated { let w = ev["width"].as_u64().unwrap_or(0); let h = ev["height"].as_u64().unwrap_or(0);- if w > 0 { let outer = sess.last_outer_width;- let shape = match outer { Some(ow) if w > ow + 4 => format!(" The emulation is WIDER than the OS window ({w} vs {ow}), so the user is seeing this page CROPPED: about {}px is off the right edge.", w - ow), Some(ow) if w + 4 < ow => format!(" The emulation is NARROWER than the OS window ({w} vs {ow}), so the user sees dead space beside the page."), _ => String::new() };- o.insert("_viewportOverride".into(), json!(format!("This window has an EMULATED VIEWPORT of {w}x{h}{}, so it is NOT tracking the real OS window.{shape} Anything you measure in the page (innerWidth, a bounding rect) describes the emulation, not what the user sees. Clear it with pup_set_viewport {{\"reset\":true}} when done.", ev["deviceScaleFactor"].as_f64().filter(|d| *d != 1.0).map(|d| format!(" @{d}x")).unwrap_or_default())));- }- }- }- if !o.contains_key("_sessionNotice") { if let Some(n) = &sess.disruption_notice { o.insert("_sessionNotice".into(), json!(n)); ctx.sessions.with_mut(&sid, |x| x.disruption_notice = None); } }- } }- }- // render verbs: the verdict rides the response (Node: RENDER_VERBS block in sendJSON)- if RENDER_VERBS.contains(&verb) && o.get("ok") != Some(&json!(false)) && o.get("success") != Some(&json!(false)) {- if let Some(rc) = o.get("renderCheck").cloned() { if rc["ok"] == json!(false) { if let Some(sig) = rc["signals"].as_array() { if !sig.is_empty() {- let st = rc["httpStatus"].as_u64().map(|s| format!(" (HTTP {s})")).unwrap_or_default();- let lead = format!("â›” THE PAGE IS STILL BROKEN: {}{st}{}. Do NOT report success. See _verifyRender. ", sig.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "), rc["excerpt"].as_str().map(|e| format!(" — it says \"{}\"", e.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(120).collect::<String>())).unwrap_or_default());- let cur = o.get("_hint").and_then(|h| h.as_str()).unwrap_or("").to_string();- let fb = rc["fallbackHint"].as_str().map(|h| format!("{h} ")).unwrap_or_default();- if !cur.contains("STILL BROKEN") && !cur.contains("IS BROKEN") { o.insert("_hint".into(), json!(format!("{lead}{fb}{cur}"))); }- } } } }- if !o.contains_key("_verifyRender") { o.insert("_verifyRender".into(), json!(VERIFY_RENDER)); }- if !o.contains_key("_urlLint") {- let u = o.get("url").and_then(|v| v.as_str()).or_else(|| args.get("url").and_then(|v| v.as_str())).unwrap_or("");- if let Some(l) = lint::url_lint(u, &caller.container) { o.insert("_urlLint".into(), l["message"].clone()); o.insert("urlLintCode".into(), l["code"].clone()); }- }- }- if let Some(sid) = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) { if let Some(h) = ctx.sessions.with_mut(&sid, |x| x.pending_cred_hint.take()).flatten() { o.insert("_credentialCaptured".into(), json!(h)); } }- // THE ab ENVELOPE (adom-bridge cli/src/relay.rs browser_command, measured at the 2.0.402 candidate- // install): ab computes success = bridge.success (absent = false) and output = bridge.output (absent- // = ""); the CLI then RETURNS THE PARSED `output` ALONE when success is true, and collapses a- // non-JSON output to {success, output}. So every success carries success:true and an `output`- // that is the JSON of the WHOLE payload, exactly as the Node build's sendJSON sites did.- let is_err = o.get("success") == Some(&json!(false)) || o.get("ok") == Some(&json!(false)) || o.contains_key("errorCode");- if !is_err {- o.insert("success".into(), json!(true));- if !o.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) {- let mut inner = o.clone(); inner.remove("output");- o.insert("output".into(), json!(Value::Object(inner).to_string()));- }- } else { o.entry("success").or_insert(json!(false)); o.entry("output").or_insert(json!("")); }- // Node v1.9.98: ab/the CLI unwrap `output` and drop the envelope on success, so the advisory- // fields have to live INSIDE output too (never overwriting what the handler put there).- if let Some(inner_s) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).map(|s| s.to_string()) {- if let Ok(mut inner) = serde_json::from_str::<Value>(&inner_s) { if let Some(i) = inner.as_object_mut() {- let sid = i.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()).or_else(|| o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()));- if let Some(sid) = sid { if !i.contains_key("window") { i.insert("window".into(), json!(sid)); if !i.contains_key("aiThread") { if let Some(owner) = ctx.sessions.get(&sid).and_then(|x| x.owner) { i.insert("aiThread".into(), json!(owner)); } } } }- for k in ["_reportIssues", "_cleanupReport", "_autoLogin", "_credentialCaptured", "_hint", "_next", "related", "pitfalls", "renderCheck", "_verifyRender"] { if let Some(v) = o.get(k) { if !i.contains_key(k) { i.insert(k.into(), v.clone()); } } }- o.insert("output".into(), json!(inner.to_string()));- } }- }- }- r-}--// ── resolution: window | sessionId | the caller's own window ───────────────────────────--fn window_arg(args: &Value) -> Option<String> {- for k in ["window", "sessionId", "session", "ai-thread", "aiThread"] { if let Some(s) = args.get(k).and_then(|v| v.as_str()) { if !s.trim().is_empty() { return Some(if k == "ai-thread" || k == "aiThread" { thread_default_session_id(s) } else { s.trim().to_string() }); } } }- None-}--/// The pup window and tab showing `url`: exact URL first, then equal ignoring the query, then the-/// longest prefix match ignoring the query (the popup's own rule). None when no pup tab shows it.-pub fn session_for_tab_url(ctx: &Ctx, url: &str) -> Option<(Session, Tab)> {- let base = |u: &str| u.split('?').next().unwrap_or("").trim_end_matches('/').to_string();- let want = base(url);- // 2.0.471: a window whose browser is connected wins over one whose browser is gone, and among those the most- // recently driven wins (a leftover session at the same page used to take the toolbar pen: annotate_inject_failed).- // 2.0.472: the FOREGROUND window wins outright: a toolbar/jump-list click comes from the window the user is- // looking at, and its payload carries only the tab URL (winvm ralph: the pen landed on a recovered background- // window's tab at the same page and failed to inject there).- let fg = ctx.host.foreground();- let mut all = ctx.sessions.all();- all.sort_by_key(|s| (!(fg != 0 && s.hwnd == Some(fg)), !transport::lane_for(ctx, s).is_ok(), std::cmp::Reverse(s.last_agent_update_ms)));- for s in &all { if let Some(t) = s.tabs.iter().find(|t| t.url == url) { return Some((s.clone(), t.clone())); } }- for s in &all { if let Some(t) = s.tabs.iter().find(|t| !want.is_empty() && base(&t.url) == want) { return Some((s.clone(), t.clone())); } }- let mut best: Option<(usize, Session, Tab)> = None;- for s in &all { for t in &s.tabs { let b = base(&t.url); if b.len() > 12 && want.starts_with(&b) && best.as_ref().map(|x| b.len() > x.0).unwrap_or(true) { best = Some((b.len(), s.clone(), t.clone())); } } }- best.map(|(_, s, t)| (s, t))-}--/// 2.0.699: the taskbar progress bar on the window a page-loading verb works in. Only pup_screenshot was ever-/// wrapped here; the extension-lane wrapper sat on a route these verbs no longer take, so open, navigate, new-/// tab, reload, back and forward painted nothing (John, 2026-10-05). A window with no handle yet (being born)-/// gets no bar; start_icon_apply covers its first seconds.-fn with_bar(ctx: &Ctx, caller: &Caller, args: &Value, op: &str, work: impl FnOnce() -> Value) -> Value {- let r = match resolve(ctx, caller, args) { Ok(s) => activity::with_progress(ctx, &s.id, op, work), Err(_) => work() };- wall_check(ctx, caller, args, r)-}--/// 2.0.712: say when a page load landed on a bot wall, and warn before a thread trips one. A Cloudflare challenge-/// page used to come back as an ordinary successful load, so a thread kept loading DigiKey pages 7 s apart and dug-/// the wall deeper (John's ECAS window, 2026-10-11). Signatures come from the electronics-sites skillpack.-const BOT_WALL_JS: &str = r#"(()=>{const t=(document.title||'').slice(0,200);const b=((document.body&&document.body.innerText)||'').slice(0,3000);const short=b.length<1500;-if(/just a moment|performing security verification|checking your browser/i.test(t)||(short&&/performing security verification|verify you are human/i.test(b))||document.querySelector('iframe[src*="challenges.cloudflare.com"]'))return 'Cloudflare';-if(/access to this page has been denied/i.test(t+' '+b)||/press (&|and) hold/i.test(b)||document.querySelector('#px-captcha,[id^="px-captcha"]'))return 'PerimeterX';-if(/^access denied/i.test(t)&&short)return 'Akamai';-if(document.querySelector('iframe[src*="captcha-delivery.com"]'))return 'DataDome';-if(short&&/verify you are a human|are you a robot|unusual traffic/i.test(b))return 'bot check';-return ''})()"#;-const PACED_HOSTS: [&str; 8] = ["digikey.", "mouser.", "arrow.com", "ti.com", "analog.com", "st.com", "octopart.com", "newark.com"];-static LAST_LOAD: std::sync::Mutex<Option<HashMap<String, u64>>> = std::sync::Mutex::new(None);--fn wall_check(ctx: &Ctx, caller: &Caller, args: &Value, mut r: Value) -> Value {- if r["ok"] == json!(false) || r["success"] == json!(false) { return r; }- let Ok(s) = resolve(ctx, caller, args) else { return r };- let Some(t) = s.active().cloned() else { return r };- let host = t.url.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").to_lowercase();- // pacing: a second load on a walled vendor within 15 s, by any thread on this desktop- if let Some(key) = PACED_HOSTS.iter().find(|h| host.contains(**h)) {- let now = pup_core::now_ms();- let mut g = LAST_LOAD.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);- if let Some(prev) = m.insert(key.to_string(), now) {- let gap = now.saturating_sub(prev);- if gap < 15_000 { r["_paceWarning"] = json!(format!("{host} was loaded {} s ago. This site walls automated browsers that load pages in quick succession; wait at least 15 s between pages here, and get stock, price and datasheets through adom-parts-search instead of loading product pages.", gap / 1000)); }- }- }- let Ok(lane) = transport::lane_for(ctx, &s) else { return r };- let vendor = transport::evaluate(&lane, &t, BOT_WALL_JS).ok().and_then(|v| v.as_str().map(|x| x.to_string()).or_else(|| v["value"].as_str().map(|x| x.to_string()))).unwrap_or_default();- if vendor.is_empty() { return r; }- pup_core::log(format!("[wall] \"{}\": {host} answered with a {vendor} bot check ({})", s.id, t.url.chars().take(100).collect::<String>()));- r["botWall"] = json!({ "vendor": vendor, "host": host, "url": t.url });- r["_hint"] = json!(format!("BOT WALL: {host} answered with a {vendor} bot check instead of the page. Do NOT reload or load more pages from {host} now; that makes it worse. For parts data (stock, price, datasheet) use adom-parts-search. If you need this page itself: ask the user for ONE click on the check in this window (pup_alert_window or pup_raise_os_window with a reason), or open it in the user's own browser with nb. Never try to solve the check yourself."));- r-}--pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {- let explicit = window_arg(args);- let id = match explicit.clone() {- Some(id) => id,- None => {- if caller.thread.is_empty() {- return Err(err("caller_identity_required", "no window given and no --ai-thread to key one on", Some("Pass --ai-thread <your thread name> (the CLI flag) so pup can resolve your own window, or name one with {window:\"...\"}.")));- }- // 2.0.453 (John, woke from sleep, toolbar pen did nothing): the Adom toolbar identifies its window by- // the TAB URL (`_tabUrl`, Node's contract); the Rust port never resolved it, so a pen click landed- // here with thread "user-toolbar" and no window. Any tab in any pup window matches, exact first.- // 2.0.702: the browser's own tab id first. A page that changes its address in place (Slack, Gmail, YouTube)- // no longer matched pup's recorded URL, so the toolbar pen resolved no window and did nothing (John's Slack- // window, 2026-10-07). The id never changes.- if let Some(tid) = args.get("_tabId").and_then(|v| v.as_i64()).map(|v| v.to_string()) {- if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.target_id == tid)) { return Ok(s); }- }- if let Some((s, _)) = args.get("_tabUrl").and_then(|u| u.as_str()).and_then(|u| session_for_tab_url(ctx, u)) { return Ok(s); }- // last resort for a human click: the pup window in front is the one they clicked in- if args.get("_tabUrl").is_some() || args.get("_tabId").is_some() {- let fg = ctx.host.foreground();- if fg != 0 { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.hwnd == Some(fg)) { return Ok(s); } }- }- let d = thread_default_session_id(&caller.thread);- if ctx.sessions.exists(&d) { d } else {- let mine = ctx.sessions.owned_by(&caller.thread);- if mine.len() == 1 { mine[0].id.clone() } else { return Err(err("no_window", format!("thread \"{}\" has no pup window", caller.thread), Some("pup_open_window {url} first; later verbs with no ids resolve to that window. pup_my_windows {} lists what you own."))); }- }- }- };- match ctx.sessions.get(&id) {- Some(s) => {- // Ownership guard (Node: ownershipRefusal): another thread's window needs takeover:true.- // A HUMAN caller (jump-list task, toolbar button, dashboard click) is exempt, as in Node's- // USER_CALLERS: the user owns every window. Missing in the Rust port until 2.0.424, so every- // window-targeting jump-list task answered not_your_window (John, 2026-09-06).- if let Some(owner) = s.owner.as_deref() { if !caller.thread.is_empty() && !USER_CALLERS.contains(&caller.thread.as_str()) && owner != caller.thread {- if args.get("takeover").and_then(|t| t.as_bool()) != Some(true) {- return Err(pup_core::merge(err("not_your_window", format!("window \"{}\" belongs to thread \"{}\", not you (\"{}\")", id, owner, caller.thread),- Some("Open your OWN window instead: pup_open_window with NO window id gives your thread its own. Pass takeover:true ONLY if the USER explicitly told you to repurpose this window — every takeover is logged with both thread names.")), json!({ "owner": owner, "window": id })));- }- // 2.0.491: LOG every sanctioned takeover, both thread names (Node did this; the Rust port- // dropped it, so a thread could drive another thread's window all day leaving no trace).- pup_core::log(format!("[takeover] \"{}\": thread \"{}\" acted on \"{}\"'s window (takeover:true — sanctioned only when the user asked). Reason: {}",- id, caller.thread, owner, if caller.reason.is_empty() { "(none given)" } else { &caller.reason }));- } }- Ok(s)- }- None => Err(pup_core::merge(err("session_not_found", format!("no pup window named \"{id}\""), Some("pup_list_windows {} shows what is open; pup_open_window creates one.")), json!({ "window": id }))),- }-}--pub fn touch(ctx: &Ctx, sid: &str, note: Option<&str>) { ctx.sessions.with_mut(sid, |x| { x.last_agent_update_ms = pup_core::now_ms(); if let Some(n) = note { x.last_note = Some(n.to_string()); } }); }--/// Close top-level about:blank page targets nobody tracks: the initial window an older pup build-/// launched Chrome with (it never got a session, an identity or a close), which shows up as a stock-/// Chrome for Testing button on the taskbar. Runs once per browser connect, BEFORE the handle is-/// published, so no verb can be creating a target in the gap. Popups (openerId) and every target a-/// session owns are never touched.-fn close_orphan_blank_targets(ctx: &Ctx, cdp: &Cdp, profile: &str) {- // Orphans: (1) the about:blank launch window an older build left behind, and (2) whole WINDOWS no- // session owns any more, left by a close that raced a bridge respawn (John's laptop, 2026-09-06:- // three leftover windows wearing old tiles). A window is orphaned only when NONE of its tabs belongs- // to a session, so a user's extra tab in a live pup window is never touched; popups never are.- let Ok(targets) = cdp.targets() else { return };- let owned: std::collections::HashSet<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();- let pages: Vec<&Value> = targets.iter().filter(|t| t["type"] == json!("page") && !t.get("openerId").and_then(|o| o.as_str()).map(|o| !o.is_empty()).unwrap_or(false)).collect();- let mut win_of: HashMap<String, i64> = HashMap::new();- for t in &pages { if let Some(tid) = t["targetId"].as_str() { if let Ok((wid, _)) = cdp.window_for_target(tid) { win_of.insert(tid.to_string(), wid); } } }- let owned_windows: std::collections::HashSet<i64> = win_of.iter().filter(|(tid, _)| owned.contains(*tid)).map(|(_, w)| *w).collect();- let mut closed = 0;- for t in &pages {- let tid = t["targetId"].as_str().unwrap_or(""); if tid.is_empty() || owned.contains(tid) { continue; }- let blank = t["url"].as_str().map(|u| u == "about:blank" || u.is_empty()).unwrap_or(false);- let orphan_window = win_of.get(tid).map(|w| !owned_windows.contains(w)).unwrap_or(false);- if blank || orphan_window { if cdp.close_target(tid).is_ok() { closed += 1; pup_core::log(format!("[launch] closed orphan {} in profile \"{profile}\": {}", if blank { "about:blank window" } else { "window no session owns" }, t["url"].as_str().unwrap_or("").chars().take(60).collect::<String>())); } }- }- if closed > 0 { pup_core::log(format!("[launch] {closed} orphan window(s) closed in profile \"{profile}\"")); }-}--/// Does the extension lane currently hold this profile directory? True only while a lane browser is-/// actually alive, so a dead lane never blocks a CDP open.-pub fn ext_lane_owns_profile(ctx: &Ctx, profile: &str) -> bool {- profile == "adom-you" && !extdrive::st().token.lock().unwrap().is_empty() && extdrive_browser_alive(ctx)-}--pub fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }--/// Get or launch the browser for a profile: reuse a live DevTools port, else spawn-verify each-/// candidate in order (Node: getOrLaunchBrowser).-fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -> Result<Arc<BrowserHandle>, Value> {- set_target_chrome_from_host(&*ctx.host); // learn the installed consumer Chrome version once, before any attach- Cdp::quiet_runtime(ctx.settings.get("cdpQuiet", "off") == "on"); // pup-bridge#109: skip the Runtime.enable leak- pup_browser::set_stock_flags(ctx.settings.get("stockFlags", "off") == "on"); // pup-bridge#109: near-stock launch flags- if let Some(b) = browser_for(ctx, profile) { return Ok(b); }- if ext_lane_owns_profile(ctx, profile) {- return Err(err("profile_owned_by_ext_lane",- format!("the extension lane is running on profile \"{profile}\", and Chrome allows only one process per profile directory"),- Some("This lane deliberately runs with NO debug port, which is what lets vendor sites load. Drive it with pup_open_window / pup_navigate / pup_eval, or close it with pup_close_window first if you specifically need CDP.")));- }- let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);- if let Some(port) = pup_browser::existing_port(&profile_dir) {- if let Ok(cdp) = Cdp::connect_port(port, Duration::from_secs(5)) {- let kind = cdp.version().ok().and_then(|v| v.get("product").and_then(|p| p.as_str()).map(|s| s.to_string())).unwrap_or_default();- let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| kind.contains("Chrome") && (c.kind == "chrome-for-testing" || c.kind == "chrome") || kind.contains("Edg") && c.kind == "edge").unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: PathBuf::new(), build_id: None });- pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));- let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;- let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });- if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }- close_orphan_blank_targets(ctx, &h.cdp, profile);- events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());- ctx.host.watch_process(h.launched.pid);- ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());- return Ok(h);- }- }- let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());- if cands.is_empty() {- let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());- if rd.get("lowDisk").and_then(|b| b.as_bool()) == Some(true) {- return Err(pup_core::merge(err("chrome_install_no_disk", format!("No Chrome/Edge on this machine and only {} MB free — not enough to install Chrome for Testing (~800 MB needed).", rd.get("diskFreeMb").cloned().unwrap_or(Value::Null)), Some("Free up disk space, or install Chrome or Edge, then retry.")), json!({ "readiness": rd })));- }- ctx.installer.ensure_background(ctx.host.clone());- return Err(pup_core::merge(err("chrome_for_testing_installing", "This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).", Some("LAST-RESORT PROVISIONING (not an error): poll pup_readiness {} until ready:true (it shows percent + ETA), then call pup_open_window again.")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness", "readiness": rd })));- }- pup_core::log(format!("[launch] {} browser candidate(s): {}", cands.len(), cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>().join(" -> ")));- let mut errors = Vec::new();- for c in &cands {- let t0 = Instant::now();- let exts = pup_browser::stage_extensions(&*ctx.host, ctx.port, profile, ctx.prefer_cft());- match pup_browser::launch(&*ctx.host, c, &profile_dir, high_fps, insecure_dev, &exts) {- Ok(l) => match Cdp::connect_port(l.cdp_port, Duration::from_secs(10)) {- Ok(cdp) => {- pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));- pup_browser::write_default(&*ctx.host, c, false);- let h = Arc::new(BrowserHandle { cdp, launched: l });- // pup-bridge#109: real Chrome pre-grants NOTHING (every permission answers "prompt"); pup used- // to grant 32 up front, and a bot manager reads Notification.permission=="granted" on a first- // visit as automation. prePermissions:on restores the blanket grant for a debug flow.- if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }- close_orphan_blank_targets(ctx, &h.cdp, profile);- events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());- ctx.host.watch_process(h.launched.pid);- ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());- return Ok(h);- }- Err(e) => { errors.push(format!("{}: connect {e}", c.kind)); let _ = ctx.host.kill(l.pid); }- },- Err(e) => errors.push(format!("{}: {e}", c.kind)),- }- pup_core::log(format!("[launch] {} failed, falling through: {}", c.kind, errors.last().cloned().unwrap_or_default()));- }- Err(err("browser_launch_failed", format!("every browser candidate failed to launch: {}", errors.join(" | ")), Some("pup_readiness {} shows the candidates and the last install error; pup_use can pin a different browser.")))-}--/// Which tabs carry focus emulation (Emulation.setFocusEmulationEnabled). It is what lets pup drive a window-/// parked BEHIND the user's windows: the page keeps reporting focus, so CDP input lands and focus events fire.-/// Until 2.0.458 every attached tab carried it, and a tab that reports focus also reports VISIBLE, so Chrome-/// never throttled its timers and Memory Saver never discarded it. Measured on John's laptop, 2026-09-10: a-/// 21-tab pup window ran EVERY background tab's timers at full rate (60 setTimeout(16ms) fires per second where-/// a background tab should get about 1), held 2.2 GB that Chrome could not reclaim, and burned 5.2 % CPU against-/// 0.2 % for his other Chrome, while the machine sat at 260 MB free of 32 GB and scrolling stuttered.-/// Now: the window's ACTIVE tab (pup must be able to drive it), plus at most the one tab a verb is driving.-pub fn focus_emulate(ctx: &Ctx, sid: &str, extra: Option<&str>) {- let Some(s) = ctx.sessions.get(sid) else { return };- let Some(b) = browser_for(ctx, &s.profile) else { return };- let mut want: Vec<String> = s.active().map(|t| t.tab_id.clone()).into_iter().collect();- if let Some(x) = extra { if !want.iter().any(|w| w == x) { want.push(x.to_string()); } }- let have = ctx.focus_emul.lock().unwrap().get(sid).cloned().unwrap_or_default();- if want == have { return; }- for t in &s.tabs {- let on = want.iter().any(|w| *w == t.tab_id);- if on != have.iter().any(|w| *w == t.tab_id) { b.cdp.set_focus_emulation(&t.cdp_session, on); }- }- ctx.focus_emul.lock().unwrap().insert(sid.to_string(), want);-}--/// Is this tab currently focus-emulated? (Reporting only; never perturbs the state it reports.)-pub fn focus_emulated(ctx: &Ctx, sid: &str, tab_id: &str) -> bool {- ctx.focus_emul.lock().unwrap().get(sid).map(|v| v.iter().any(|w| w == tab_id)).unwrap_or(false)-}--/// Re-apply the focus-emulation policy to every session (after boot recovery or a reconnect).-pub fn focus_emulate_all(ctx: &Ctx) { for s in ctx.sessions.all() { focus_emulate(ctx, &s.id, None); } }--/// What this window costs and what the machine has left, reported to the CALLING AI on every open and every-/// survey. John, 2026-09-11, after a thread opened twenty wiki component pages at roughly 200 MB apiece and-/// put his laptop into thrashing: "if the ai knew how much ram it was using, that might have helped the ai-/// decide. and i suppose you should be automatically telling it what % of ram was even left on the box."-/// browserMb is the whole Chrome process tree for this profile (renderers and the GPU process included),-/// which is the number that actually moves when a thread opens a page; avgMbPerTab is that divided by the-/// tabs it is carrying, so a caller can price its next tab before opening it.-pub fn memory_report(ctx: &Ctx, profile: Option<&str>) -> Value {- let (total, avail) = match ctx.host.memory_status() { Some(x) => x, None => return Value::Null };- let free_pct = if total > 0 { (avail as f64 / total as f64 * 100.0).round() as u64 } else { 0 };- let mut v = json!({ "boxTotalMb": total, "boxFreeMb": avail, "boxFreePct": free_pct });- if let Some(p) = profile {- if let Some(b) = browser_for(ctx, p) {- if let Some(mb) = ctx.host.process_tree_rss_mb(b.launched.pid) {- let tabs: usize = ctx.sessions.for_profile(p).iter().map(|s| s.tabs.len()).sum();- v["browserMb"] = json!(mb);- v["tabsOnThisBrowser"] = json!(tabs);- if tabs > 0 { v["avgMbPerTab"] = json!(mb / tabs as u64); }- }- }- }- if v.get("tabsOnThisBrowser").and_then(|x| x.as_u64()) == Some(0) {- let mins = ctx.settings.get("idleBrowserQuitMin", "10");- v["idleBrowser"] = json!(true);- v["idleNote"] = json!(if mins == "0" { "This browser has no pup windows and idleBrowserQuitMin is 0, so pup will keep it running.".to_string() }- else { format!("This browser has no pup windows: pup closes it after {mins} min idle and gives the RAM back. The next open relaunches it, still signed in.") });- }- let per = v.get("avgMbPerTab").and_then(|x| x.as_u64());- v["_hint"] = json!(match (free_pct, per) {- (f, Some(p)) if f < 10 => format!("Only {f}% of this machine's RAM is free and each pup tab here is costing about {p} MB. Do not open more pages until you close some: at this level Windows is paging to disk and the user's whole desktop stutters. pup_close_tab / pup_close_window on what you are done with."),- (f, None) if f < 10 => format!("Only {f}% of this machine's RAM is free. Do not open more pages until you close some: at this level Windows is paging to disk and the user's whole desktop stutters."),- (f, Some(p)) if f < 25 => format!("{f}% of this machine's RAM is free and each pup tab here is costing about {p} MB. Budget accordingly and close tabs you are done with; this is the user's own laptop."),- (f, None) if f < 25 => format!("{f}% of this machine's RAM is free. Budget accordingly and close pages you are done with; this is the user's own laptop."),- (f, Some(p)) => format!("{f}% of this machine's RAM is free; each pup tab here is costing about {p} MB. Close tabs you are done with so it stays that way."),- (f, None) => format!("{f}% of this machine's RAM is free."),- });- v-}--pub fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {- // 2.0.455: idempotent. A target already in this session is returned as is (the rescan adopted one tab twice- // because its "tracked" list was built once up front).- if let Some(t) = s.tabs.iter().find(|t| t.target_id == target_id) { return Ok(t.tab_id.clone()); }- let sid = cdp.attach(target_id).map_err(|e| err("cdp_attach_failed", e.to_string(), None))?;- apply_consumer_identity(cdp, &sid); // pup-bridge#109: consumer Google Chrome identity + EME shim, before any navigation- // 2.0.455: the page-domain setup (Page.enable, the two bindings, the login-capture script) runs on a helper- // thread. A tab Chrome DISCARDED (Memory Saver after a sleep) has no renderer, so each of those calls sat- // out its full timeout and one adoption took 30 s (measured on AdomLapper, 2026-09-10). The tab is usable- // for resolution and identity at once; the setup lands when the renderer answers.- let (cdp2, sid2) = (cdp.clone(), sid.clone());- std::thread::Builder::new().name("tab-setup".into()).spawn(move || {- let _ = cdp2.page_enable(&sid2);- for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = cdp2.send(Some(&sid2), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }- let capture = vault::capture_login_script();- let _ = cdp2.send(Some(&sid2), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));- let _ = cdp2.evaluate(&sid2, &capture, Duration::from_secs(5));- }).ok();- let tab_id = s.next_tab_id();- s.tabs.push(Tab { tab_id: tab_id.clone(), target_id: target_id.into(), cdp_session: sid, url: "about:blank".into(), title: String::new(), error_count: 0, fav: None });- s.active_tab = s.tabs.len() - 1;- Ok(tab_id)-}--/// Per-page scripts that need settings (floaty pencil + hotkey, owner chip). Idempotent.-pub fn inject_page_scripts(ctx: &Ctx, s: &Session, cdp: &Cdp, sid: &str) {- let floaty = annotate::floaty_script(ctx);- let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": floaty }), Duration::from_secs(5));- let _ = cdp.evaluate(sid, &floaty, Duration::from_secs(5));- if ctx.settings.get("ownerChip", "off") == "on" { // 2.0.713: OFF by default (John, 2026-10-12: it sits on the page's own controls, and he never asked for it)- let chip = OWNER_CHIP_JS.replace("__THREAD__", &serde_json::to_string(&s.owner.clone().unwrap_or_default()).unwrap()).replace("__PROFILE__", &serde_json::to_string(&s.profile).unwrap()).replace("__AVATAR__", &serde_json::to_string(&ctx.settings.get("identityAvatarB64", "")).unwrap()).replace("__IDENT__", &serde_json::to_string(&ctx.settings.get("identityName", "")).unwrap());- let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": chip }), Duration::from_secs(5));- let _ = cdp.evaluate(sid, &chip, Duration::from_secs(5));- }-}--/// 2.0.653: the extension-lane twin of inject_page_scripts (per page load; the smoke found no owner chip there).-pub fn inject_page_scripts_ext(ctx: &Ctx, s: &Session, lane: &transport::Lane, tab: &Tab) {- if tab.url.is_empty() || tab.url.starts_with("about:") || tab.url.starts_with("chrome") { return; } // nothing to decorate, evals forbidden there- if !transport::wait_ready(lane, tab, &tab.url, Duration::from_secs(10)) { pup_core::log(format!("[page] \"{}\" page scripts skipped: the document was not ready within 10 s", s.id)); return; }- let floaty = annotate::floaty_script(ctx);- let _ = transport::evaluate(lane, tab, &floaty);- if ctx.settings.get("ownerChip", "off") == "on" { // 2.0.713: OFF by default (John, 2026-10-12: it sits on the page's own controls, and he never asked for it)- let chip = OWNER_CHIP_JS.replace("__THREAD__", &serde_json::to_string(&s.owner.clone().unwrap_or_default()).unwrap()).replace("__PROFILE__", &serde_json::to_string(&s.profile).unwrap()).replace("__AVATAR__", &serde_json::to_string(&ctx.settings.get("identityAvatarB64", "")).unwrap()).replace("__IDENT__", &serde_json::to_string(&ctx.settings.get("identityName", "")).unwrap());- let _ = transport::evaluate(lane, tab, &chip);- }-}--pub fn refresh_tab(cdp: &Cdp, t: &mut Tab) {- if let Ok(info) = cdp.target_info(&t.cdp_session) {- if let Some(u) = info.get("url").and_then(|u| u.as_str()) { t.url = u.into(); }- if let Some(ti) = info.get("title").and_then(|u| u.as_str()) { t.title = ti.into(); }- }-}--// ── the verbs ──────────────────────────────────────────────────────────────────────────--fn image_delivery(r: &mut Value) {- fn fix(v: &mut Value) {- let Some(fp) = v.get("filePath").and_then(|x| x.as_str()).map(|x| x.to_string()) else { return };- let low = fp.to_lowercase();- if ![".png", ".jpg", ".jpeg", ".webp"].iter().any(|e| low.ends_with(e)) || v.get("fullPath").is_some() { return; }- v["fullPath"] = json!(fp);- let fits = |k: &str| v.get(k).and_then(|x| x.as_u64()).map(|n| n <= 1568).unwrap_or(false);- if fits("width") && fits("height") { v["safePath"] = json!(fp); }- }- fix(r);- // the same keys inside the JSON string many verbs also carry in `output`- if let Some(o) = r.get("output").and_then(|x| x.as_str()).map(|x| x.to_string()) {- if let Ok(mut ov) = serde_json::from_str::<Value>(&o) { if ov.is_object() { let before = ov.clone(); fix(&mut ov); if ov != before { r["output"] = json!(ov.to_string()); } } }- }-}--fn describe(ctx: &Ctx) -> Value {- let c = catalog();- let body = json!({- "success": true, "bridge": "puppeteer", "version": pup_core::version(), "shell": "rust",- "progressContract": c["progressContract"], "verbs": c["verbs"], "verbCount": c["verbs"].as_array().map(|a| a.len()).unwrap_or(0), "notes": c["notes"], "statusVerb": "pup_readiness",- "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()),- "_hint": "This list — the bridge's verbs + schemas + hints, for AD's Verbs tab. Read it once to learn the surface. This build is pup as one Rust exe (spawn.kind exe): no Node, no npm, no postinstall; same contract as the Node build.",- "_next": ["pup_status"], "related": ["pup_status"],- });- let mut r = body.clone(); r["output"] = json!(body.to_string()); r["error"] = json!(""); r["status"] = json!("ok"); r["ok"] = json!(true);- r-}--fn active_rows(ctx: &Ctx) -> Vec<Value> {- let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());- let mut rows = Vec::new();- if rd.get("installing").and_then(|b| b.as_bool()) == Some(true) {- if rd.get("installPhase").and_then(|p| p.as_str()) == Some("unpacking") {- let total = rd.get("bytesTotal").and_then(|b| b.as_u64()).unwrap_or(0) as f64 * 2.3;- let pct = if total > 0.0 { Some(((rd.get("unpackBytes").and_then(|b| b.as_u64()).unwrap_or(0) as f64 / total * 100.0).round() as u64).min(99)) } else { None };- rows.push(json!({ "id": "cft-unpack", "verb": "pup_prewarm", "phase": "unpack", "stepLabel": format!("Unpacking Chrome for Testing{}", rd.get("unpackMethod").and_then(|m| m.as_str()).map(|m| format!(" ({m})")).unwrap_or_default()), "percent": pct, "bytes": rd.get("unpackBytes"), "elapsedSec": rd.get("unpackElapsedSec"), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "estimated", "startKind": "cold" }));- } else {- rows.push(json!({ "id": "cft-download", "verb": "pup_prewarm", "phase": "download", "stepLabel": "Downloading Chrome for Testing", "percent": rd.get("installProgressPct"), "elapsedSec": rd.get("downloadElapsedSec"), "estimatedSec": rd.get("downloadElapsedSec").and_then(|e| e.as_u64()).zip(rd.get("downloadEtaSec").and_then(|e| e.as_u64())).map(|(a, b)| a + b), "etaSec": rd.get("downloadEtaSec"), "confidence": if rd.get("downloadEtaSec").is_some_and(|v| !v.is_null()) { "measured" } else { "estimated" }, "startKind": "cold" }));- }- }- rows.extend(ctx.consent.rows());- rows.extend(extras::active_download_rows()); // pup-bridge#109: in-flight downloads are pollable here- rows-}--fn readiness(ctx: &Ctx) -> Value {- let mut rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());- if let Some(o) = rd.as_object_mut() { if let Value::Object(cs) = pup_browser::chrome_stable::snapshot() { for (k, v) in cs { o.insert(k, v); } } }- let ready = rd.get("ready").and_then(|b| b.as_bool()).unwrap_or(false);- let installing = rd.get("installing").and_then(|b| b.as_bool()).unwrap_or(false);- // 2.0.646 (pup-bridge#121): a lane browser nobody tracks (a pup restart lost its pid list) holds the profile- // lock and makes every launch exit with "Opening in existing browser session". Say so here, with the pids.- if !extdrive::alive(ctx) {- let dir = pup_browser::profiles_dir(&*ctx.host).join("adom-you");- let orphans = orphan_browser_pids(ctx, &dir);- if !orphans.is_empty() {- if let Some(o) = rd.as_object_mut() { o.insert("orphanedBrowser".into(), json!({ "pids": orphans, "profile": dir.to_string_lossy(), "_hint": "A pup browser on this profile is running but no live lane owns it (left over from a pup restart). It holds the profile lock, so a launch would exit at once. The next pup_open_window ends it and launches fresh; nothing to do by hand." })); }- }- }- let hint = if ready { "A drivable browser exists: pup_open_window {url} will open in the background. chromeForTestingInstalled tells you whether the toolbar build is cached (pup fetches it in the background on every box)." }- else if installing { "Chrome for Testing is downloading (active[] carries percent, elapsed and ETA). Poll this verb; do not start a second download. A system Chrome/Edge, if present, is drivable already." }- else if rd.get("installPhase").and_then(|p| p.as_str()) == Some("failed") { "The Chrome for Testing install FAILED (lastError). Retry pup_prewarm once the cause (network/proxy, disk) is fixed; a system Chrome/Edge is drivable meanwhile." }- else { "No browser yet: pup_prewarm {} starts the Chrome for Testing download; pup_open_window starts it too and answers chrome_for_testing_installing until it lands." };- let mut r = pup_core::merge(ok(rd.clone()), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"], "error": "",- // the Node build's real-Chrome installer phases (pup_use {browser:"chrome", install:true}); the Rust build has no stable-Chrome installer yet, so the phase is always idle- "chromeStablePhase": rd.get("chromeStablePhase").cloned().unwrap_or(json!("idle")), "chromeStableAwaitingApproval": rd.get("chromeStableAwaitingApproval").cloned().unwrap_or(json!(false)), "chromeStableError": rd.get("chromeStableError").cloned().unwrap_or(Value::Null), "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null }));- let mut inner = rd; if let Some(i) = inner.as_object_mut() { i.insert("active".into(), r["active"].clone()); i.insert("_hint".into(), json!(hint)); for k in ["chromeStablePhase", "chromeStableAwaitingApproval", "chromeStableError", "chromeStableNotifyChannel", "chromeStableNotifyPeers"] { i.insert(k.into(), r[k].clone()); } }- r["output"] = json!(inner.to_string());- r-}--fn prewarm(ctx: &Ctx, args: &Value) -> Value {- let wait = args.get("wait").and_then(|b| b.as_bool()).unwrap_or(false);- if wait { let done = ctx.installer.ensure_blocking(ctx.host.clone(), Duration::from_secs(600)); let rd = readiness(ctx); return pup_core::merge(rd, json!({ "waited": true, "installed": done })); }- ctx.installer.ensure_background(ctx.host.clone());- let rd = readiness(ctx);- pup_core::merge(rd, json!({ "started": true, "_hint": "Chrome for Testing provisioning is running in the background (or already cached). Poll pup_readiness for percent + ETA; pass wait:true to block until it lands." }))-}--pub fn session_row(ctx: &Ctx, s: &Session) -> Value {- let active_tab = s.active();- json!({ "sessionId": s.id, "window": s.id, "transport": s.transport, "lane": if s.transport == "ext" { "extension (no debug port)" } else { "cdp" }, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes(), "emulatedViewport": s.emulated, "lastAgentUpdate": s.last_agent_update(), "lastForeground": s.last_foreground, "errorCount": s.tabs.iter().map(|t| ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0)).sum::<usize>(),- "activeTabId": active_tab.map(|t| t.tab_id.clone()), "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null },- "overlay": s.overlay_desc.clone().unwrap_or_else(|| "none (not yet applied)".into()), "overlayLed": s.overlay_led, "overlayWhy": s.overlay_why, "flash": "none", "flashCount": s.flash_count, "progressBarCount": s.progress_count, "progressBarMs": s.progress_ms,- "background": if s.foreground { "still-foreground" } else if s.hwnd.is_some() { "backgrounded" } else { "no-window" } })-}--fn status(ctx: &Ctx) -> Value {- let list: Vec<Value> = ctx.sessions.all().iter().map(|s| session_row(ctx, s)).collect();- ok(json!({- "version": pup_core::version(), "shell": "rust", "uptimeSec": ctx.started.elapsed().as_secs(), "dataDirs": ctx.data_dirs(), "retention": { "screenshots": { "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES } },- "sessionCount": list.len(), "activeSession": ctx.sessions.active_id(), "sessions": list,- "settings": { "taskbarMode": if ctx.settings.get("aumidIcons", "on") != "on" { "plain" } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles" } else { "aumid" }, "aumidIcons": ctx.settings.get("aumidIcons", "on"), "threadTiles": ctx.settings.get("threadTiles", "on"), "overlayBadges": ctx.settings.get("overlayBadges", "on"), "overlaySize": ctx.settings.get("overlaySize", "75"), "jumpLists": ctx.settings.get("jumpLists", "on") },- "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": pup_core::loops::report(), "pupshotUrl": Value::Null, "iconAudit": ctx.last_audit.lock().unwrap().clone(),- "adVersion": ctx.ad_version.lock().unwrap().clone(),- "memory": memory_report(ctx, Some("adom-you")),- }))-}--fn list_windows(ctx: &Ctx) -> Value {- let mut all = ctx.sessions.all();- for s in all.iter_mut() { if let Some(b) = browser_for(ctx, &s.profile) { for t in s.tabs.iter_mut() { refresh_tab(&b.cdp, t); } ctx.sessions.with_mut(&s.id, |x| x.tabs = s.tabs.clone()); } }- let rows: Vec<Value> = all.iter().map(|s| session_row(ctx, s)).collect();- ok(json!({ "count": rows.len(), "sessions": rows, "activeSession": ctx.sessions.active_id(), "memory": memory_report(ctx, Some("adom-you")) }))-}--fn my_windows(ctx: &Ctx, caller: &Caller) -> Value {- if caller.thread.is_empty() { return err("caller_identity_required", "pup_my_windows needs --ai-thread", Some("Pass --ai-thread <your thread name>; windows are keyed on it.")); }- let mine: Vec<Value> = ctx.sessions.owned_by(&caller.thread).iter().map(|s| session_row(ctx, s)).collect();- let default_id = thread_default_session_id(&caller.thread);- let body = json!({ "ok": true, "aiThread": caller.thread, "ai-thread": caller.thread, "count": mine.len(), "windows": mine, "defaultWindow": default_id, "_hint": if mine.is_empty() { "You own no pup window yet: pup_open_window {url} creates yours (no ids needed), later verbs resolve to it." } else { "Verbs with no ids resolve to your window; name another with {window:\"<sessionId>\"}." }, "_next": ["pup_open_window", "pup_screenshot", "pup_navigate"] });- let mut r = ok(body.clone()); r["success"] = json!(true); r["output"] = json!(body.to_string());- r-}--/// The park geometry in CSS px (what Browser.setWindowBounds takes): Node v1.8.66 rule, "size is-/// measured, not guessed": screen.availLeft/Top/Width/Height from the page itself, with retries, then a-/// 10 px inset on every side. The Win32 work area is PHYSICAL px; feeding it to CDP on a 150% display-/// produced a window 1.5x the screen, hanging off the bottom-right (John's AdomLapper, 2026-09-06).-fn birth_rect_css(cdp: &Cdp, cdp_session: &str, ctx: &Ctx) -> (i32, i32, i32, i32) {- for _ in 0..4 {- if let Ok(v) = cdp.evaluate(cdp_session, "({x:screen.availLeft||0,y:screen.availTop||0,w:screen.availWidth,h:screen.availHeight})", Duration::from_secs(3)) {- let (x, y, w, h) = (v["x"].as_f64().unwrap_or(0.0) as i32, v["y"].as_f64().unwrap_or(0.0) as i32, v["w"].as_f64().unwrap_or(0.0) as i32, v["h"].as_f64().unwrap_or(0.0) as i32);- if w > 400 && h > 300 { return (x + 10, y + 10, w - 20, h - 20); }- }- std::thread::sleep(Duration::from_millis(300));- }- match ctx.host.work_area() { Some((x, y, w, h)) => { let sc = ctx.host.dpi_scale(0).max(1.0); ((x as f64 / sc) as i32 + 10, (y as f64 / sc) as i32 + 10, ((w as f64 / sc) as i32 - 20).max(400), ((h as f64 / sc) as i32 - 20).max(300)) }, None => (10, 10, 1280, 800) }-}--fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let t0 = Instant::now();- let url = args.get("url").and_then(|u| u.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "about:blank".into());- // 2.0.648 (pup-bridge#112): isolated:true MINTS a throwaway profile. It used to bind adom-you and only pick the- // anonymous tile, so a "throwaway" login persisted into the shared jar and an untrusted page ran on the user's- // real cookies. The directory is removed when the window's browser is gone (close_window).- let profile = match args.get("profile").and_then(|p| p.as_str()) {- Some(p) => p.to_string(),- None if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) => format!("iso-{:x}{:x}", pup_core::now_ms() & 0xffffff, std::process::id() & 0xffff),- None => "adom-you".to_string(),- };- if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) && args.get("isolationReason").and_then(|r| r.as_str()).map(|r| r.len() >= 10) != Some(true) {- return err("isolation_reason_required", "isolated:true needs an isolationReason (min 10 chars): why must this window NOT be adom-you?", Some("Surf as adom-you by default (its logins accumulate). Isolation is for logged-out tests, acting as a different user, or untrusted pages: say which."));- }- if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) && args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.len() >= 8) != Some(true) {- return err("foreground_reason_required", "foreground:true needs a foregroundReason shown to the user on screen", Some("pup windows are born in the BACKGROUND. Only a demo / 'show me' earns the foreground, and the reason is captioned for the user."));- }- let sid = window_arg(args).unwrap_or_else(|| if caller.thread.is_empty() { format!("pup-{}", pup_core::now_ms() % 100000) } else { thread_default_session_id(&caller.thread) });- // 2.0.476: a DELIBERATE open of this id (anything but the auto-recovery relaunch) clears a pending close-cancel,- // so reopening a window shortly after closing it during a restore is honored rather than swallowed.- if !caller.reason.starts_with("auto-recovery") { crate::recovery::clear_cancel(&sid); }- let high_fps = args.get("highFps").and_then(|b| b.as_bool()).unwrap_or(false);- let insecure = args.get("webSecurity").and_then(|b| b.as_bool()) == Some(false);- // 2.0.525: the lane is chosen HERE, before any browser is launched. defaultLane is "ext": a plain- // pup_open_window is an extension-driven window on adom-you (no debug port, John's default browsing- // lane). lane:"cdp" is the explicit opt-in for what CDP alone can do (iframes, trusted input, annotate).- // 2.0.563 (John): the extension lane IS pup. It is the lane that loads every vendor site, so it is the- // default and the CDP lane opens ONLY on an explicit lane:"cdp". Nothing falls back to CDP silently any- // more: a request that needs a CDP-only feature without asking for the lane is refused with the fix.- let explicit_lane = args.get("lane").and_then(|l| l.as_str()).map(|x| x.to_string());- let lane = explicit_lane.clone().unwrap_or_else(|| ctx.settings.get("defaultLane", "ext"));- let isolated = args.get("isolated").and_then(|b| b.as_bool()) == Some(true);- // 2.0.652 (John, 2026-09-19: "i want it almost NEVER used cuz then you may use it as a crutch instead of fixing- // ext lane to solve all use cases"). lane:"cdp" is refused unless the user turned the CDP lane on for this box.- if args.get("lane").and_then(|l| l.as_str()) == Some("cdp") && ctx.settings.get("cdpLane", "off") != "on" {- pup_core::log(format!("[lane] refused: thread \"{}\" asked for lane:\"cdp\" and cdpLane is off (cdp_lane_disabled)", caller.thread));- return err("cdp_lane_disabled", "the CDP lane is off on this box; every pup window runs on the extension lane", Some("Use the extension lane (omit lane). Trusted input, HTTP auth, fetch, screenshots and recording all work there. If a feature genuinely needs CDP, file it on adom/pup-bridge as an extension-lane gap; the user can enable the lane for one box with pup_configure {cdpLane:\"on\"}."));- }- if lane != "cdp" {- // 2.0.659 (pup-bridge#112/#113): profile and isolated run on the EXTENSION lane now (a lane per profile)- let needs_cdp = if high_fps { Some("highFps:true (anti-throttle flags are a CDP-lane feature)".to_string()) }- else if insecure { Some("webSecurity:false (a CDP-lane feature)".to_string()) }- else { None };- if let Some(why) = needs_cdp {- pup_core::log(format!("[lane] refused: thread \"{}\" passed a CDP-only option ({}) without lane:\"cdp\" (cdp_lane_required)", caller.thread, ["profile","isolated","highFps","webSecurity"].iter().filter(|k| args.get(**k).is_some()).cloned().collect::<Vec<_>>().join(",")));- return err("cdp_lane_required", format!("This open needs the CDP lane because of {why}, and the CDP lane opens only when asked for."),- Some("The extension lane is pup's default: it is the lane every vendor site loads on, with no --remote-debugging-port for edges to detect. Pass lane:\"cdp\" ONLY if you specifically need what CDP alone can do (iframes, trusted input events, element/full-res screenshots, a separate profile, highFps, webSecurity:false); read the pup-lanes skill first. Note that lane:\"cdp\" on adom-you takes the profile away from the extension lane and closes its windows."));- }- let owner = args.get("owner").and_then(|o| o.as_str()).map(|o| o.to_string()).or_else(|| if caller.thread.is_empty() { None } else { Some(caller.thread.clone()) });- let mut a2 = args.clone(); a2["_mintedProfile"] = json!(profile);- return open_window_ext(ctx, caller, &a2, &sid, owner.as_deref(), &url);- }- // lane:cdp on adom-you while the extension lane holds it: hand the profile back. The two lanes cannot- // share one --user-data-dir (Chrome runs one process per profile), so this is symmetric with the ext- // birth taking adom-you from a CDP browser. It closes the ext windows, which is why lane:cdp is an- // explicit opt-in and the default stays ext.- if profile == "adom-you" && ext_lane_owns_profile(ctx, &profile) {- let ext_ids: Vec<String> = ctx.sessions.all().into_iter().filter(|x| transport::is_ext(x)).map(|x| x.id).collect();- if !ext_ids.is_empty() {- pup_core::log(format!("[lane] lane:cdp requested on adom-you; handing the profile back from the extension lane (closing {} ext window(s))", ext_ids.len()));- for id in &ext_ids { ctx.sessions.remove(id); ctx.sessions.delete_file(id); }- }- let _ = crate::extdrive::close(ctx, caller, &json!({ "all": true }));- kill_extdrive(ctx);- let dir = pup_browser::profiles_dir(&*ctx.host).join(&profile);- let lock = dir.join("SingletonLock");- let t0 = Instant::now(); while t0.elapsed() < Duration::from_secs(8) { if !lock.exists() { break; } std::thread::sleep(Duration::from_millis(200)); }- }- let b = match get_or_launch(ctx, &profile, high_fps, insecure) { Ok(b) => b, Err(e) => return e };-- // An existing window of that name: later opens become TABS in it (the Node contract).- if let Some(existing) = ctx.sessions.get(&sid) {- if existing.profile == profile {- let tid = match b.cdp.create_target(&url, false, None, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };- let mut s = existing.clone();- let tab_id = match attach_tab(&mut s, &b.cdp, &tid) { Ok(t) => t, Err(e) => return e };- if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }- if let Some(t) = s.tabs.last_mut() { let _ = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(15)); refresh_tab(&b.cdp, t); }- let title = s.active().map(|t| t.title.clone()).unwrap_or_default();- let tab_count = s.tabs.len();- ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- ctx.sessions.with_mut(&sid, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == tab_id) { x.active_tab = i; } }); // Chrome shows the new tab in front; the badge follows it- focus_emulate(ctx, &sid, None);- if let Some(a) = ctx.arc() { identity::schedule(a.clone(), sid.clone(), caller.clone(), false); identity::overlay_retries(a, sid.clone(), caller.clone()); }- return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": tab_count, "reused": true, "active": true,- "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "browser": b.launched.candidate.to_json(), "profile": profile, "owner": s.owner, "ownerSource": "session",- "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": "warm" },- "memory": memory_report(ctx, Some(&profile)),- "_hint": "This window already existed for your thread, so the URL opened as a NEW TAB in it (pup_list_tabs / pup_switch_tab drive tabs). Screenshot to verify the render.", "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval"] }));- }- }-- // Node: an explicit `owner` declares the owning thread (ownerSource "declared"); else the caller's thread.- // The Rust port ignored `owner` until 2.0.438, so a window recreated on another thread's behalf wore the- // recreating thread's tile (John's AdomLapper proof run, 2026-09-06).- let declared_owner = args.get("owner").and_then(|o| o.as_str()).map(|o| o.trim().to_string()).filter(|o| !o.is_empty());- let owner: Option<&str> = declared_owner.as_deref().or(if caller.thread.is_empty() { None } else { Some(caller.thread.as_str()) });- // R4 / pup-window-targeting rule 2: the set-diff is only exact while exactly ONE pup birth is in flight.- let birth_guard = BIRTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());- let taken = taken_hwnds(ctx, &sid);- let before: std::collections::HashSet<i64> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();- let prev_fg = ctx.host.foreground(); // Node v1.9.253: measure whether creation steals focus, and hand it straight back- // 2.0.446 (John: "i would expect the icon to come up the first time with the ai-thread name and then it NEVER- // has to swap icons again", and "i thought you said you could make it such that the cft icon never actually- // showed first?"): the shell creates a window's button at its first show and bakes the icon then, so the- // identity has to be ON the window before Chrome shows it. Chrome shows the window inside Target.createTarget,- // so a watcher thread starts BEFORE the call, polls every top-level frame (visible or not) every millisecond,- // and stamps the newborn natively (property store + window icon, no ab round-trip) the instant it exists.- // The tile is rendered and the AUMID registered ahead of time, so the button's picture resolves on creation.- let plan_name = owner.map(|o| o.to_string()).unwrap_or_else(|| sid.clone());- let (plan, _early_ok, watcher) = birth_watch(ctx, caller, &sid, &plan_name, &url, &before, &taken, false);- // 2.0.450 (John: "i would like a setting in the pup settings dialog to turn this flash feature on or off"):- // winAttentionFlash off (default) = focus:false, Chrome never asks for the foreground, no attention state;- // on = Chrome's default ask, and Windows lights the button when it refuses (the classic new-window flash).- let attention = ctx.settings.get("winAttentionFlash", "off") == "on";- let target = match b.cdp.create_target("about:blank", true, Some((OFFSCREEN, OFFSCREEN, 1280, 800)), if attention { None } else { Some(false) }) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", format!("Target.createTarget: {e}"), Some("The browser is up but refused a new window; pup_readiness shows its state, pup_close {} restarts it.")) };- let mut s = Session::new(&sid, &profile, owner, Some(&caller.container), b.launched.cdp_port, if b.launched.pid > 0 { Some(b.launched.pid) } else { None });- s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none();- let tab_id = match attach_tab(&mut s, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };- let (bl, bt, bw, bh) = s.tabs.last().map(|t| birth_rect_css(&b.cdp, &t.cdp_session, ctx)).unwrap_or((10, 10, 1280, 800)); // CSS px, measured from the page- // park: on-screen rect via CDP (moves without z-order change), then bottom + tag via the host- let mut hwnd: Option<i64> = None;- // Node v2.0.276: born off-screen so Chrome never activates it, moved on-screen within the 50 ms budget by- // one local CDP call that changes no z-order. Leaving it off-screen longer (2.0.429 to 2.0.433 candidates)- // made Chrome fire its activation on the first real paint, which Windows denies and tints. Move NOW.- if let Ok((wid, _)) = b.cdp.window_for_target(&target) { s.window_id = Some(wid); let _ = b.cdp.set_window_bounds(wid, json!({ "left": bl, "top": bt, "width": bw, "height": bh })); }- let mut early_stamped = false;- if let Some((h, stamped)) = birth_report(watcher, &sid) { hwnd = Some(h); early_stamped = stamped; }- if hwnd.is_none() { for _ in 0..200 {- let now = ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1"));- if let Some(w) = now.iter().find(|w| !before.contains(&w.hwnd) && !taken.contains(&w.hwnd)) { hwnd = Some(w.hwnd); break; }- std::thread::sleep(Duration::from_millis(15));- } }- drop(birth_guard); // hwnd resolved: the next birth may start- let mut parked = false;- if let Some(h) = hwnd {- // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name- // and then it NEVER has to swap icons again." Windows bakes a button's icon when the button is- // created, and creates it when the window is shown. So: HIDE the newborn at once (its button- // goes away), render the thread tile + register the AUMID + stamp + jump list on the hidden- // window, then show it once, no-activate, at z-bottom. One button, born with the tile.- // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name- // and then it NEVER has to swap icons again." Windows bakes a button's icon when it creates the- // button. So: take the newborn's button away through the shell's own ITaskbarList::DeleteTab- // (the window stays visible, so Chrome queues no activation and no attention tint can follow),- // render the thread tile + register the AUMID + stamp + jump list, then AddTab: the shell creates- // the button ONCE, already wearing the tile. Hiding the window instead (2.0.429 to 2.0.432- // candidates) made Chrome fire its first-show activation on the re-show, which Windows denies- // and tints; measured, so never hide a newborn.- // 2.0.446: an early-stamped newborn keeps its button (it was created wearing the tile); only the late path untabs.- if !early_stamped { let _ = ctx.host.taskbar_tab(h, false); }- // Node v1.9.253: when the user's foreground window belongs to pup's own Chrome (they are using a- // pup window), Windows lets Chrome activate the newborn, so creation steals their focus. Measured- // 2026-09-06 (foreground 1901802 -> the newborn). Hand it straight back before anything else.- if prev_fg != 0 && prev_fg != h && ctx.host.foreground() == h {- let _ = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": prev_fg }), caller, Duration::from_secs(4));- pup_core::log(format!("[window] \"{sid}\" creation took the foreground from {prev_fg}; handed it back ({})", if ctx.host.foreground() == prev_fg { "ok" } else { "still ours" }));- }- s.hwnd = Some(h);- ctx.host.tag(h, &s.tag_string());- ctx.sessions.insert(s.clone());- // Park FIRST (Node: the unconditional launch park), so the newborn never sits on top while we work.- parked = ctx.host.send_to_bottom_no_activate(h);- let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));- activity::mark_icon_applied(ctx, &sid);- activity::on_open(ctx, &sid, h, caller);- }- if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }- let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false };- let (auto_login_note, wiki_extra) = match s.tabs.last() { Some(t) => (vault::autofill_on_load(ctx, &s, &t.cdp_session, &url), wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &url, args["wikiView"].as_str(), args["isolated"].as_bool().unwrap_or(false))), None => (None, json!({})) };- ctx.sessions.insert(s.clone());- let _ = park::bottom_unless_foreground(ctx, &sid, true); // Node reassertBottomAfterNav: the first cross-origin paint can raise it; inside the birth grace it is bottomed- if let Some(h) = hwnd {- // Measured 2026-09-06: Windows puts the attention flag on the BUTTON that exists when Chrome's- // first real paint (this navigation) tries to activate the window and is denied. No button existed- // (DeleteTab at birth), so nothing was flagged. Now stamp the thread identity and let the shell- // create the one button this window will ever have, wearing the tile.- // Node v1.9.342: place AGAIN once the window has settled. Chrome cascades a new window from the- // last one's position after our first move (measured on AdomLapper: requested 10,10 CSS, landed- // 120,120 CSS, overhanging a 150% screen), and re-reads the screen the window actually sits on.- if let (Some(t), Ok((wid, _))) = (s.tabs.last(), b.cdp.window_for_target(&target)) {- let (l2, t2, w2, h2) = birth_rect_css(&b.cdp, &t.cdp_session, ctx);- let _ = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" }));- let _ = b.cdp.set_window_bounds(wid, json!({ "left": l2, "top": t2, "width": w2, "height": h2 }));- if let Ok(v) = b.cdp.send(None, "Browser.getWindowBounds", json!({ "windowId": wid }), Duration::from_secs(3)) { pup_core::log(format!("[window] \"{sid}\" placed at {},{} {}x{} CSS (asked {l2},{t2} {w2}x{h2})", v["bounds"]["left"], v["bounds"]["top"], v["bounds"]["width"], v["bounds"]["height"])); }- }- let t_id = Instant::now();- if early_stamped { if let Some((a, _, _)) = &plan { ctx.sessions.with_mut(&sid, |x| x.app_id = Some(a.clone())); } }- identity::stamp_opts(ctx, &sid, caller, true, false); // same AUMID again (no button rebuild, measured) + the jump list- if early_stamped {- ctx.host.send_to_bottom_no_activate(h);- pup_core::log(format!("[window] \"{sid}\" jump list committed on the early-stamped button in {}ms (no DeleteTab/AddTab)", t_id.elapsed().as_millis()));- } else {- let _ = ctx.host.taskbar_tab(h, false);- let retabbed = ctx.host.taskbar_tab(h, true);- ctx.host.send_to_bottom_no_activate(h);- pup_core::log(format!("[window] \"{sid}\" identity stamped after the first paint, one button created in {}ms (AddTab={retabbed})", t_id.elapsed().as_millis()));- }- }- let title = s.active().map(|t| t.title.clone()).unwrap_or_default();- ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- focus_emulate(ctx, &sid, None); // 2.0.458: the newborn's active tab, and only it- if let Some(a) = ctx.arc() { if hwnd.is_none() { identity::schedule(a.clone(), sid.clone(), caller.clone(), true); } identity::overlay_retries(a, sid.clone(), caller.clone()); } // identity was stamped while hidden; the favicon lands late, repaint at 1.5 s and 4.5 s- if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) {- // Node: a foreground open is a background birth followed by a GRANTED raise (caption + toast).- let _ = window::raise(ctx, caller, &json!({ "window": sid, "foregroundReason": args["foregroundReason"] }));- } else { ctx.sessions.with_mut(&sid, |x| x.fg_watch_until_ms = pup_core::now_ms() + 20_000); } // Node: the no-fault fg watch — caption if the window fronts itself in the next 20s- pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));- let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());- let strict = args["strictPermissions"].as_bool() == Some(true);- let render_check_val = match s.tabs.last() { Some(t) if loaded => render_check(&b.cdp, &t.cdp_session, None), _ => Value::Null };- let mut resp = ok(json!({- "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": 1, "reused": false, "active": true, "loaded": loaded,- "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "hwnd": hwnd, "parked": parked,- "browser": b.launched.candidate.to_json(), "availableBrowsers": cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>(),- "defaultBrowser": pup_browser::read_default(&*ctx.host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": false })),- "profile": profile, "owner": s.owner, "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null }, "ageMinutes": 0, "highFps": high_fps, "emulatedViewport": Value::Null, "errorCount": 0,- "defaultPermissionsApplied": !strict, "defaultPermissionsCount": if strict { 0 } else { DEFAULT_PERMISSIONS.len() }, "defaultPermissionsNote": if strict { "strictPermissions: nothing pre-granted" } else { "full browser permissions pre-granted (clipboard, camera, geolocation, window-management, ...) — pup_describe has the exact list" },- "downloadsEnabled": !strict, "downloadPath": ctx.host.home().join("Downloads").to_string_lossy().replace('\\', "/"), "flash": "none", "flashCount": 0, "lastAgentUpdate": Value::Null, "lastForeground": Value::Null, "overlay": "none (not yet applied)", "progressBarCount": 0, "progressBarMs": 0, "restoredTabsClosed": 0,- "renderCheck": render_check_val,- "size": { "width": bw, "height": bh }, "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": if b.launched.reused { "warm" } else { "cold" } },- "memory": memory_report(ctx, Some(&profile)),- "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval", "pup_close_window"],- "_hint": "Opened in the BACKGROUND (behind the user's windows, did NOT steal focus). Fully drivable while hidden: pup_screenshot / pup_eval / pup_navigate resolve to this window with no ids. Only bring it to the user's screen if they explicitly want to watch.",- }));- if b.launched.candidate.kind != "chrome-for-testing" { resp["_toolbarNotice"] = json!(format!("This window launched on {}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked.", b.launched.candidate.kind)); }- if let Some(n) = auto_login_note { resp["_autoLogin"] = json!(n); }- resp = pup_core::merge(resp, wiki_extra);- if args["highFps"].as_bool() == Some(true) { highfps::grant(ctx, &sid, args["highFpsMinutes"].as_u64().unwrap_or(highfps::DEFAULT_MIN)); resp["highFps"] = highfps::info(ctx, &sid); }- if hwnd.is_none() { resp["_windowNotice"] = json!("pup could not resolve the OS window handle for this window (no Chrome_WidgetWin_1 appeared), so it was not parked or tagged; it may be on top. This is the Phase 1 Rust bridge; report it with the box name."); }- resp-}--pub const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not assume it rendered. ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. Take a screenshot and READ the image: pup_screenshot {} returns a real PNG path. Look for a blank page, an unstyled flash of plain HTML, a framework error overlay, missing images, a dev-server 'Cannot GET /' page, or the wrong build. If it is wrong: fix the source, reload, screenshot AGAIN.";--fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- let url = match args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()) { Some(u) => u.to_string(), None => return err("url_required", "pup_navigate needs {url}", None) };- if transport::is_ext(&s) { return navigate_ext(ctx, caller, &s, &url); }- let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 brings automatic recovery.")) };- let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- match b.cdp.navigate(&tab.cdp_session, &url, Duration::from_secs(25)) {- Ok((_, loaded)) => {- let mut t = tab.clone(); refresh_tab(&b.cdp, &mut t);- ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { *tt = t.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });- let _ = park::bottom_unless_foreground(ctx, &s.id, true); // never yank a window the user is using (Node v2.0.26)- if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }- if let Some(a) = ctx.arc() { identity::schedule(a.clone(), s.id.clone(), caller.clone(), false); identity::overlay_retries(a, s.id.clone(), caller.clone()); }- let note = vault::autofill_on_load(ctx, &s, &t.cdp_session, &t.url);- let wiki_extra = wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &t.url, args["wikiView"].as_str(), false);- let mut r = ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "renderCheck": if loaded { render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }));- if let Some(n) = note { r["_autoLogin"] = json!(n); }- pup_core::merge(r, wiki_extra)- }- Err(e) => err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL).")),- }-}--/// The extension-lane arm of pup_navigate: same session bookkeeping, driven through the transport.--/// 2.0.642: where the page actually LANDED, classified, so an AI never asks the user to sign in to a site the-/// shared profile is already signed into. John, 2026-09-19: another thread opened claude.ai, which went straight-/// to his chats (the adom-you profile had the session), and it toasted "Sign into claude.ai in the pup window-/// once" anyway. The open/navigate response now says whether the final page is a sign-in page.-pub fn landed(url: &str, title: &str) -> Value { landed_with(url, title, None) }--/// 2.0.643: the DOM probe behind `landed`. (login_offered, login_form): a visible "Log in"/"Sign in" link or-/// button means the profile is NOT signed in here even when the URL looks like an app page (chatgpt.com/codex-/// is a marketing page with a Log in link when signed out, and the URL rule alone called it signed in); a-/// password field, or an email field next to a Continue/Next button, means the page IS a sign-in form.-pub fn landed_ext(ctx: &Ctx, s: &Session, tab: &pup_session::Tab, url: &str, title: &str) -> Value {- if let Ok(lane) = transport::lane_for(ctx, s) { let _ = transport::wait_ready(&lane, tab, url, Duration::from_secs(8)); }- const PROBE: &str = "(()=>{try{const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0};const txt=e=>((e.innerText||e.value||e.getAttribute('aria-label')||'')+'').trim().toLowerCase().replace(/\\s+/g,' ');const els=[...document.querySelectorAll('a,button,input[type=submit],[role=button]')].filter(vis);const offered=els.some(e=>/^(log ?in|sign ?in|log ?in with .*|sign ?in with .*)$/.test(txt(e)));const pw=!!document.querySelector('input[type=password]');const em=!!document.querySelector('input[type=email],input[name*=email i],input[autocomplete=username],input[autocomplete=email]');const cont=els.some(e=>/^(continue|next|log ?in|sign ?in|continue with .*)$/.test(txt(e)));return JSON.stringify({offered,form:pw||(em&&cont)})}catch(e){return JSON.stringify({err:String(e)})}})()";- let probe = transport::lane_for(ctx, s).ok().and_then(|lane| transport::evaluate(&lane, tab, PROBE).ok()).and_then(|v| {- let txt = v.as_str().map(|x| x.to_string()).or_else(|| v.get("result").and_then(|r| r.as_str()).map(|x| x.to_string()))?;- let j: Value = serde_json::from_str(&txt).ok()?;- Some((j["offered"].as_bool().unwrap_or(false), j["form"].as_bool().unwrap_or(false)))- });- landed_with(url, title, probe)-}--pub fn landed_with(url: &str, title: &str, probe: Option<(bool, bool)>) -> Value {- let u = url.to_ascii_lowercase(); let t = title.to_ascii_lowercase();- let host = u.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").to_string();- let path = u.split("//").nth(1).unwrap_or("").splitn(2, '/').nth(1).map(|p| format!("/{p}")).unwrap_or_else(|| "/".into());- let known_signin = (host.ends_with("claude.ai") && (path.starts_with("/login") || path.starts_with("/magic-link") || path.starts_with("/oauth")))- || ((host.ends_with("chatgpt.com") || host.ends_with("openai.com")) && (path.contains("/auth/") || path.starts_with("/log-in") || path.starts_with("/login") || host.starts_with("auth.")))- || host == "accounts.google.com" || host.ends_with("login.microsoftonline.com") || host.ends_with("appleid.apple.com");- let generic = ["/login", "/signin", "/sign-in", "/sign_in", "/auth", "/sso", "/oauth", "/authorize", "/session/new", "/account/login", "/users/sign_in"].iter().any(|p| path.starts_with(p) || path.contains(&format!("{p}?")) || path.contains(&format!("{p}/")))- || ["sign in", "log in", "login", "signin"].iter().any(|k| t.starts_with(k) || t.contains(&format!(" {k}")) && t.len() < 40);- let (offered, form) = probe.unwrap_or((false, false));- let login_page = known_signin || generic || form;- let signed_in_known = !login_page && !offered && (- (host.ends_with("claude.ai") && (path.starts_with("/new") || path.starts_with("/chat") || path.starts_with("/project") || path.starts_with("/settings") || path == "/"))- || (host.ends_with("chatgpt.com") && (path.starts_with("/c/") || path.starts_with("/g/") || (path.starts_with("/codex") && probe.is_some()))));- let hint = if !login_page && offered {- format!("The page landed on {url} and shows a Log in / Sign in link: the shared adom-you profile is NOT signed in here. Follow login-recipes/<domain>.md on the pup-bridge wiki page (Google sign-in via pup_google_signin, or the vault via pup_login) before involving the user.")- } else if login_page {- format!("The page landed on a SIGN-IN page ({url}). Before asking the user for anything: pup_credentials for a saved login (pup_login fills it), pup_google_accounts + pup_google_signin if the site offers Google sign-in, and login-recipes/<domain>.md on the pup-bridge wiki page. Toast the user ONLY if pup cannot finish the sign-in itself.")- } else if signed_in_known {- format!("The page landed on {url}: the shared adom-you profile is ALREADY signed in here. Do not ask the user to sign in and do not toast about signing in.")- } else {- format!("The page landed on {url}. It is not a sign-in page. Do not ask the user to sign in unless the page itself asks.")- };- json!({ "url": url, "title": title, "loginPage": login_page, "loginOffered": offered, "signedIn": if signed_in_known { json!(true) } else if login_page || offered { json!(false) } else { Value::Null }, "probed": probe.is_some(), "_hint": hint })-}-fn navigate_ext(ctx: &Ctx, caller: &Caller, s: &Session, url: &str) -> Value {- let lane = match transport::lane_for(ctx, s) { Ok(l) => l, Err(e) => return e };- let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- let (u, title, loaded) = match transport::navigate(&lane, &tab, url) { Ok(x) => x, Err(e) => return e };- ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == tab.tab_id) { tt.url = u.clone(); tt.title = title.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });- if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }- // 2.0.621: NO attention clear here. ab implements desktop_clear_window_attention by RAISING the window- // (adom-bridge#199, measured z16 -> z1 on every call), and it ran after EVERY navigate, which is the- // "navigate puts the window on top" that flashed pup at John through every Google sign-in. Nothing needs- // clearing after a navigate; the birth tint is cleared once, at birth.- identity::overlay(ctx, &s.id, caller);- // 2.0.607 (John, 2026-09-17: "why did you just shove a pup window to the foreground? that was rude").- // An AI navigating a window the user never asked to see must leave it where it was: BEHIND. Measured on- // his laptop, a background window came forward on the navigate, 21 s before the toast that explained it.- // Narrow on purpose: never when the window is foreground, and never when he has touched it recently- // (park::user_is_using), so this can never pull away a window HE raised from a toast.- // 2.0.629: force, so a navigation inside the birth grace that Chrome ACTIVATED is still bottomed under the- // idle-hands rule; outside the grace, or with any input since birth, the primitive stands down as before.- let _ = park::bottom_unless_foreground(ctx, &s.id, true);- // 2.0.653: what the CDP path always did after a navigate, now on this lane too: page scripts (owner chip,- // floaty pencil) and the wiki view probe with the desktop-token auto-sign.- let tab_now = ctx.sessions.get(&s.id).and_then(|x| x.tab_by_id(&tab.tab_id).cloned()).unwrap_or_else(|| tab.clone());- inject_page_scripts_ext(ctx, s, &lane, &tab_now);- let wiki_extra = wiki::after_navigate_ext(ctx, s, &lane, &tab_now, &u, None, false);- // 2.0.644 (pup-bridge#119): a cancelled HTTP auth challenge is reported as a typed error, not a blank page.- if let Some((host, answered)) = transport::ext_id(&tab).as_i64().and_then(vault::unanswered_challenge) {- if !answered { return pup_core::merge(vault::no_credential_error(&host, false), json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "transport": "ext" })); }- }- pup_core::merge(ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "loaded": loaded, "transport": "ext", "landed": landed_ext(ctx, s, &tab, &u, &title) })), wiki_extra)-}--fn eval(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let expr = ["expr", "expression", "script", "js", "code"].iter().find_map(|k| args.get(*k).and_then(|v| v.as_str())).map(|s| s.to_string());- let Some(expr) = expr else { return err("eval_expr_missing", "Missing required parameter \"expr\" (aliases: expression, script, js, code)", Some("pup_eval {expr:\"document.title\"}")) };- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- if transport::is_ext(&s) {- if args.get("frame").and_then(|f| f.as_str()).filter(|f| !f.is_empty()).is_some() { return err("frame_unsupported_ext", "frame targeting needs the CDP lane", Some("Open the window with lane:\"cdp\" for iframe evaluation.")); }- let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };- let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- return match transport::evaluate(&lane, &tab, &expr) { Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })), Err(e) => e };- }- let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };- let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- // frame: run inside an IFRAME by name, URL substring, or numeric index (login forms usually live in one). Node: the args.frame block.- let eval_result = if let Some(fq) = args.get("frame").and_then(|f| f.as_str()).filter(|f| !f.is_empty()) {- let frames = b.cdp.frames(&tab.cdp_session).unwrap_or_default();- let pick = fq.parse::<usize>().ok().and_then(|i| frames.get(i).cloned())- .or_else(|| frames.iter().find(|f| f["name"].as_str() == Some(fq)).cloned())- .or_else(|| frames.iter().find(|f| f["url"].as_str().map(|u| u.contains(fq)).unwrap_or(false)).cloned());- match pick {- None => {- let avail: Vec<String> = frames.iter().enumerate().map(|(i, f)| format!("[{i}] name={} url={}", f["name"].as_str().filter(|n| !n.is_empty()).unwrap_or("(none)"), f["url"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect();- return pup_core::merge(err("frame_not_found", format!("No frame matched \"{fq}\" in window \"{}\".", s.id), Some("frame matches by frame NAME, then by URL substring, then by numeric index. An iframe's id attribute is NOT its frame name.")), json!({ "frames": avail }));- }- Some(f) => {- let fid = f["id"].as_str().unwrap_or("");- match b.cdp.frame_context(&tab.cdp_session, fid) { Ok(ctxid) => b.cdp.evaluate_in_context(&tab.cdp_session, ctxid, &expr, Duration::from_secs(20)), Err(e) => return err("frame_context_failed", e.to_string(), None) }- }- }- } else { b.cdp.evaluate(&tab.cdp_session, &expr, Duration::from_secs(20)) };- match eval_result {- Ok(v) if v.get("__error").is_some() => err("eval_failed", format!("pup_eval could not run your code: {}", v["__error"].as_str().unwrap_or("evaluation threw")), Some("Expression and statement forms both run (devtools-console semantics). If this names a detached frame or closed tab, re-list windows.")),- Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })),- Err(e) => err("eval_failed", e.to_string(), None),- }-}--fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- if transport::is_ext(&s) {- let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };- let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- let png = match transport::screenshot_png(&lane, &tab, false) { Ok(p) => p, Err(e) => return e };- let max_w = args.get("maxWidth").and_then(|m| m.as_u64()).map(|m| m as u32).unwrap_or(MAX_SCREENSHOT_DIM).min(MAX_SCREENSHOT_DIM);- let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };- let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));- if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }- return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "transport": "ext", "_hint": "captureVisibleTab: the VISIBLE area of the active tab. READ the file before describing it." }));- }- let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };- let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- let full = args.get("fullPage").and_then(|b| b.as_bool()).unwrap_or(false);- let png = match b.cdp.screenshot_png(&tab.cdp_session, full, Duration::from_secs(20)) {- Ok(p) => p,- Err(e) => {- // OS fallback (Node 2.0.38x): ab captures the window even when the renderer is stuck.- if let Some(h) = s.hwnd {- // 2.0.649 (pup-bridge#106): the OS capture is by hwnd, the only path that can hand back another window's- // document. Check the OS title against the tab before capturing; a mismatch is a typed error, not an image.- let os_title = ctx.host.frame_windows_all(None).into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();- let want = tab.title.trim();- if !want.is_empty() && !os_title.contains(want) { return pup_core::merge(err("capture_identity_mismatch", format!("window {h} is titled {os_title:?} but this tab is {want:?}: not capturing another window's document"), Some("pup_window_info shows the session's hwnd and tabs; pup_list_windows shows every window. Re-open the page or pass tabId explicitly.")), json!({ "sessionId": s.id, "hwnd": h, "osTitle": os_title, "tab": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id } })); }- if let Ok(r) = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": h }), caller, Duration::from_secs(15)) { return pup_core::merge(ok(r), json!({ "capturedVia": "os", "captured": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": h, "via": "os-window", "osTitle": os_title }, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "_hint": "CDP timed out; this is an OS window capture (window chrome included)." })); } }- return err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState to see whether the renderer is alive."));- }- };- let max_w = args.get("maxWidth").and_then(|m| m.as_u64()).map(|m| m as u32).unwrap_or(MAX_SCREENSHOT_DIM).min(MAX_SCREENSHOT_DIM);- let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };- let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));- if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }- ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "fullPage": full, "capturedVia": "cdp", "_hint": "PNG of the page. READ the image before asserting anything rendered.", "_next": ["pup_eval", "pup_navigate"] }))-}--fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- let fg = ctx.host.foreground();- let tabs: Vec<Value> = s.tabs.iter().enumerate().map(|(i, t)| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": i == s.active_tab })).collect();- ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "owner": s.owner, "ownerContainer": s.owner_container, "profile": s.profile, "ageMinutes": s.age_minutes(), "tabs": tabs,- "lastAgentUpdate": s.last_agent_update(), "hwnd": s.hwnd, "foreground": s.hwnd.is_some() && s.hwnd == Some(fg), "lastTakeoverBy": Value::Null, "cardShown": false, "captionShown": false,- "_hint": "Info returned as JSON only — nothing was drawn on the user's screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar Info action gets the on-screen card; pass showCaption:true to draw the native card yourself." }))-}--/// Birth of an extension-lane window as a FIRST-CLASS SESSION: same Session struct, same insert/save,-/// same identity stamp, overlay, progress and attention-clear the CDP birth runs. Only the driving differs.-type BirthRx = std::sync::mpsc::Receiver<(i64, bool, bool, u128, u128, Option<u128>)>;--/// ONE birth watcher for BOTH lanes (2.0.567). The CDP lane has had this since 2.0.446: register the AUMID-/// ahead of time, then a thread started BEFORE the window exists polls every top-level frame every-/// millisecond and stamps the newborn natively while it is still invisible, so the shell bakes pup's icon-/// into the button at its first show. The extension lane skipped it and every ext window was born on the-/// stock Chrome icon until a later stamp (John, 2026-09-14: "you sure don't seem to be branding that windows-/// taskbar icon very early in the process").-fn birth_watch(ctx: &Ctx, caller: &Caller, sid: &str, plan_name: &str, url: &str, before: &std::collections::HashSet<i64>, taken: &std::collections::HashSet<i64>, ext: bool) -> (Option<(String, String, std::path::PathBuf)>, bool, Option<BirthRx>) {- let plan = identity::plan(ctx, sid, plan_name, url);- let early_ok = plan.as_ref().map(|(a, d, i)| identity::preregister(ctx, caller, a, d, i)).unwrap_or(false); // used by the watcher thread below- let lane_pids_now: Vec<u32> = if ext { crate::extdrive_pids() } else { Vec::new() }; // 2.0.659: THIS lane's pids, read on the verb thread- let watcher = ctx.arc().map(|a| {- let (tx, rx) = std::sync::mpsc::channel::<(i64, bool, bool, u128, u128, Option<u128>)>();- let before2 = before.clone(); let taken2 = taken.clone(); let plan2 = plan.clone(); let sid2 = sid.to_string(); let t_w = Instant::now(); let lane_pids = lane_pids_now.clone();- std::thread::Builder::new().name("birth-watch".into()).spawn(move || {- let mut found: Option<pup_host::HostWindow> = None;- let mut ambiguous = 0usize;- while t_w.elapsed() < Duration::from_secs(4) {- // Class Chrome_WidgetWin_1 is ALSO Edge's: John's own Edge window is one, so an unfiltered- // diff can stamp pup's tile onto his browser. Filter to the lane's own Chrome pids.- let pids = if ext { if lane_pids.is_empty() { crate::extdrive_pids() } else { lane_pids.clone() } } else { Vec::new() };- let cands: Vec<pup_host::HostWindow> = a.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter()- .filter(|w| !before2.contains(&w.hwnd) && !taken2.contains(&w.hwnd))- .filter(|w| pids.is_empty() || pids.contains(&w.pid))- .collect();- // R4 / pup-window-targeting: "If >1 window appears under the lock it refuses to guess and- // logs loudly - a wrong hwnd paints another window's badge and parks the wrong window, so- // late beats wrong." Keep polling; the extra startup frame usually closes and leaves one.- if cands.len() > 1 { ambiguous = cands.len(); std::thread::sleep(Duration::from_millis(1)); continue; }- if let Some(w) = cands.into_iter().next() { found = Some(w); break; }- std::thread::sleep(Duration::from_millis(1));- }- let Some(w) = found else {- if ambiguous > 1 { pup_core::log(format!("[birth] \"{sid2}\" REFUSED to guess its window: {ambiguous} new Chrome frames were up under the birth lock and none resolved unambiguously - leaving it BARE rather than stamping another thread's window (bare > wrong)")); }- return;- };- let t_found = t_w.elapsed().as_millis();- let mut stamped = false; let mut t_stamp = 0u128;- if let (true, false, Some((app_id, display, icon))) = (early_ok, w.visible, plan2.as_ref()) {- stamped = a.host.stamp_identity(w.hwnd, app_id, display, &icon.to_string_lossy(), &identity::relaunch_for(&sid2));- t_stamp = t_w.elapsed().as_millis();- }- // 2.0.639: PARK WHILE INVISIBLE. The z-watch (2.0.638) showed Chrome's first show putting every newborn- // on top for 140-250 ms until the park that waited for the token bind. The watcher holds the handle- // ~150 ms before that first show; bottom it now, so the first frame Chrome draws is already behind.- let mut pre_parked = false;- if !w.visible { pre_parked = a.host.send_to_bottom_no_activate(w.hwnd); }- let mut t_vis = if w.visible { Some(t_found) } else { None };- if pre_parked {- // 2.0.640: native spin, bottomed in the same instant it shows, held 300 ms (see park_on_first_show)- match a.host.park_on_first_show(w.hwnd, 6000) {- Some(ms) => { t_vis = Some(t_w.elapsed().as_millis()); pup_core::log(format!("[window] \"{sid2}\" pre-parked while invisible, shown {ms}ms later and bottomed in that instant, held for 300 ms")); }- None => { pup_core::log(format!("[window] \"{sid2}\" pre-parked while invisible, never shown within 6 s")); }- }- }- while t_vis.is_none() && t_w.elapsed() < Duration::from_secs(6) { if a.host.frame_windows_all(Some("Chrome_WidgetWin_1")).iter().any(|x| x.hwnd == w.hwnd && x.visible) { t_vis = Some(t_w.elapsed().as_millis()); } else { std::thread::sleep(Duration::from_millis(1)); } }- let _ = tx.send((w.hwnd, w.visible, stamped, t_found, t_stamp, t_vis));- }).ok();- rx- });- (plan, early_ok, watcher)-}--/// Wait for the watcher and log the birth timeline. Some((hwnd, stamped_before_first_show)).-fn birth_report(watcher: Option<BirthRx>, sid: &str) -> Option<(i64, bool)> {- let rx = watcher?;- let Ok((h, was_visible, stamped, t_found, t_stamp, t_vis)) = rx.recv_timeout(Duration::from_secs(6)) else { return None };- pup_core::log(format!("[window] \"{sid}\" newborn hwnd={h} caught {} at +{t_found}ms{}; shown at {}", if was_visible { "ALREADY VISIBLE (early stamp missed)" } else { "invisible" },- if stamped { format!(", identity stamped natively at +{t_stamp}ms (before the first show)") } else if !was_visible { " (not stamped: no plan or registration)".to_string() } else { String::new() },- t_vis.map(|t| format!("+{t}ms")).unwrap_or_else(|| "never (within 6 s)".into())));- Some((h, stamped))-}--/// 2.0.638 instrumentation (2.0.636 watched only AFTER the park and saw nothing once ab's clear was gone, while-/// the gate still caught the NEWBORN on top ~1 s after the open began, sometimes on its birth page). This watches-/// from BEFORE the extension creates the window: whenever the top visible window is a lane Chrome window that did-/// not exist before this birth, log when, its title, whether it is the foreground, and the last thing pup logged.-fn spawn_zwatch(ctx: &Ctx, sid: &str, before: std::collections::HashSet<i64>) {- let Some(a) = ctx.arc() else { return };- let sid2 = sid.to_string(); let t0 = Instant::now();- std::thread::Builder::new().name("zwatch".into()).spawn(move || {- let mut on_top: Option<(i64, u128)> = None;- while t0.elapsed() < Duration::from_millis(12000) {- let pids = crate::extdrive_pids();- let top = a.host.frame_windows_all(None).into_iter()- .find(|w| w.visible && !w.title.is_empty() && w.rect.0 > -30000 && w.rect.2 > w.rect.0 + 50- && !matches!(w.class.as_str(), "Windows.UI.Core.CoreWindow" | "Xaml_WindowedPopupClass" | "Shell_TrayWnd" | "Shell_SecondaryTrayWnd" | "Progman" | "WorkerW" | "TextInputHost" | "ApplicationFrameWindow" | "Windows.UI.Composition.DesktopWindowContentBridge"));- let now = t0.elapsed().as_millis();- let ours = top.as_ref().filter(|w| w.class == "Chrome_WidgetWin_1" && pids.contains(&w.pid) && !before.contains(&w.hwnd));- match (ours, on_top) {- (Some(w), None) => { on_top = Some((w.hwnd, now)); pup_core::log(format!("[zwatch] \"{sid2}\" lane window {} ON TOP at +{now}ms title=\"{}\" foreground={} last: {}", w.hwnd, w.title.chars().take(40).collect::<String>(), a.host.foreground() == w.hwnd, pup_core::last_log())); }- (None, Some((h, t))) => { on_top = None; pup_core::log(format!("[zwatch] \"{sid2}\" lane window {h} left the top at +{now}ms after {}ms, last: {}", now - t, pup_core::last_log())); }- _ => {}- }- std::thread::sleep(Duration::from_millis(15));- }- }).ok();-}-pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owner: Option<&str>, url: &str) -> Value {- let t_verb = Instant::now();- // 2.0.659 (pup-bridge#113): the lane is per profile. isolated:true minted an iso-<id> profile (passed as- // _mintedProfile); a named profile is honoured too. Everything below talks to THAT profile's browser and queue.- let profile_arg = args.get("_mintedProfile").and_then(|p| p.as_str()).or_else(|| args.get("profile").and_then(|p| p.as_str())).unwrap_or("adom-you").to_string();- extdrive::enter_profile(&profile_arg); // 2.0.648 (pup-bridge#90): the early-return budget starts here- if let Some(s) = ctx.sessions.get(sid) {- if transport::is_ext(&s) {- if url != "about:blank" { return tabs::open_tab(ctx, caller, &json!({ "window": sid, "url": url })); }- return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "reused": true, "transport": "ext" }));- }- }- // R4 / pup-window-targeting rule 2. The ext lane is ONE Chrome holding MANY threads' windows, so an- // unlocked set-diff is exactly how all-engineering, NSF and pup-bridge all ended up on hwnd 590484.- #[allow(unused_assignments)]- let mut birth_move_ms: Option<i64> = None; // 2.0.621: R1's on-screen move, measured by the extension; set by whichever open arm runs- let mut birth_token = String::new(); // 2.0.630: the birth page's title token, the deterministic handle- let birth_guard = BIRTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());- let cold = !extdrive::alive(ctx);- if cold {- // 2.0.545: launch on about:blank, apply the consumer identity (wire rule + JS shim), and only then go- // to the requested URL, so the FIRST real request already carries the installed Chrome's identity.- // Launching straight on the URL loaded that page before the rule existed (measured: the launch page- // echoed CfT 148 while the next navigation echoed 152).- // 2.0.586: a cold open BLOCKS until the lane is up, with one relaunch if the first launch never calls- // home (a Chrome still shutting down after the last close makes the new one forward to it and exit).- // Answering `starting:true` with no session made the caller's very next verb fail with- // session_not_found (parity's pup_open_tab, and other threads' "hard time opening pup", 2026-09-15).- // 2.0.613 (John, 2026-09-17, shouting, and right): "IF YOU CAN'T FUCKING CREATE A PUP WINDOW IN THE- // BACKGROUND THEN FUCKING WARN ME AS THE USER YOU'RE ABOUT TO SHOVE A WINDOW IN FRONT OF MY FACE".- // A warm open is invisible now (created minimized, shown already at the bottom). A COLD one starts the- // browser, and Chrome draws its own startup window before pup owns anything, so it can still flash.- // That case gets a warning BEFORE it happens, with a beat for him to see it, never an apology after.- // 2.0.617: no prompt. The cold start draws nothing now (--no-startup-window + --keep-alive-for-test),- // so there is nothing to ask about: "being asked even that once is too much" (John, 2026-09-18).- for attempt in 1..=2u32 {- // 2.0.632: no url. "about:blank" here overrode --no-startup-window and made an unowned about:blank- // window that sat on top of the user's work for 18 s on every cold start (measured, 2.0.631).- if let Err(e) = launch_extdrive(ctx, "") { if attempt == 2 { return e; } else { continue; } }- let t0 = std::time::Instant::now();- while t0.elapsed() < Duration::from_secs(25) { if extdrive::connected_ms_ago() < 4000 { break; } std::thread::sleep(Duration::from_millis(300)); }- if extdrive::alive(ctx) { break; }- if attempt == 1 {- pup_core::log("[lane] the launched browser never called home within 25 s; ending it and launching once more".to_string());- kill_extdrive(ctx); std::thread::sleep(Duration::from_millis(1500));- }- }- if !extdrive::alive(ctx) { return err("ext_lane_start_failed", "the extension-driven browser did not start (two launches, no worker poll within 25 s each)", Some("pup_readiness shows the browser state; a Chrome for Testing still holding the profile is the usual cause. pup_close {} then open again.")); }- }- // 2.0.635: arm the birth watcher HERE, after a cold launch, not before it. The watcher lives 4 s; armed- // before the launch it expired while Chrome was still starting, so a cold birth never got the early native- // stamp and wore the stock Chrome-for-Testing icon until the deferred navigate finished (measured on- // John's laptop, 2026-09-19: 12 s of the wrong icon; warm births were stamped before their first show).- let taken = taken_hwnds(ctx, sid);- let before: std::collections::HashSet<i64> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();- // the same birth watcher the CDP lane uses: identity on the window BEFORE Chrome shows it- let plan_name = owner.map(|o| o.to_string()).unwrap_or_else(|| sid.to_string());- let (plan, _early_ok, watcher) = birth_watch(ctx, caller, sid, &plan_name, url, &before, &taken, true);- spawn_zwatch(ctx, sid, before.clone());- // the tab and Chrome window for this session: the launch URL's tab on a cold start, a NEW window otherwise- let (chrome_tab, chrome_win, got_url, got_title) = if cold {- extdrive::apply_consumer_brands(ctx);- // 2.0.617: the lane launches with --no-startup-window, so there is no tab to adopt. Create the first- // window the same way as every later one: through the extension, minimized, shown at the bottom.- match extdrive::run(json!({ "action": "open", "url": "about:blank" }), Duration::from_secs(25)) {- Ok(r) => {- birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string();- let t = r.clone();- let id = t["tabId"].as_i64().unwrap_or(0);- // The real page is loaded AFTER the birth lock is released (below): a 25 s navigate held- // under the lock would serialize every other thread's window birth behind this one.- (id, t["windowId"].as_i64(), url.to_string(), String::new())- }- Err(e) => return e,- }- } else {- match extdrive::run(json!({ "action": "open", "url": url }), Duration::from_secs(20)) {- Ok(r) => { birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string(); (r["tabId"].as_i64().unwrap_or(0), r["windowId"].as_i64(), url.to_string(), String::new()) }- Err(e) => {- // 2.0.577: a lane whose worker polled within the last few seconds is BUSY, not dead: relaunching it- // here killed every thread's windows because one open waited behind a long capture (NSF's open,- // 18:28 on 2026-09-14). Fail THIS open and say so; only a silent worker gets the relaunch.- // 2.0.622: only a NON-ANSWER is "busy". The worker answering with an error (measured: Chrome- // refusing a window's bounds) was being relabelled ext_busy with a "retry in a few seconds"- // hint, which is a lie that sends a caller into a retry loop against a deterministic failure.- let answered = e["errorCode"].as_str().map(|c| c != "ext_no_answer").unwrap_or(false) && e["error"].as_str().map(|m| !m.contains("did not answer")).unwrap_or(true);- if answered {- pup_core::log(format!("[lane] warm open refused by the browser: {}", e["error"].as_str().unwrap_or("?")));- return pup_core::merge(e, json!({ "errorCode": "ext_open_failed", "_hint": "The browser answered and refused to create the window; the error is Chrome's own. This is not a busy lane and a retry will fail the same way." }));- }- if extdrive::connected_ms_ago() < 8000 {- pup_core::log(format!("[lane] warm open not answered but the worker polled {} ms ago: busy, not relaunching", extdrive::connected_ms_ago()));- return pup_core::merge(e, json!({ "errorCode": "ext_busy", "_hint": "The extension lane is alive but did not answer in time (another verb is holding it). Retry the open in a few seconds; pup did NOT restart anything." }));- }- // the heartbeat is stale and nothing answered: treat it as cold and launch- pup_core::log("[lane] the extension channel did not answer a warm open and the worker is silent; relaunching the lane".to_string());- extdrive::mark_dead();- let mut a = args.clone(); a["lane"] = json!("ext");- // 2.0.623: RELEASE THE BIRTH LOCK before re-entering. This call re-enters open_window_ext, whose- // first act is to take BIRTH_LOCK, which is not re-entrant: the open deadlocked on itself and every- // later open queued behind it forever (measured on the conference-room box, 2026-09-18: exact- // 60 s timeouts on every open, a lane that read dead, and not one launch line in the log). It bites- // whenever the browser dies between a poll and an open, which is exactly what the foreground- // gate does on purpose and what a user closing the browser does by accident.- drop(birth_guard);- return open_window_ext(ctx, caller, &a, sid, owner, url);- }- }- };- let mut s = Session::new(sid, &profile_arg, owner, Some(&caller.container), 0, None);- s.transport = "ext".into(); s.window_id = chrome_win;- s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none();- let t = transport::ext_tab(&mut s, chrome_tab, &got_url, &got_title); s.tabs.push(t.clone()); s.active_tab = 0;- let mut early_stamped = false;- // 2.0.630: bind by the birth TOKEN first. The set-diff bound a placeholder Chrome makes at launch and pup- // parked that while the real window came up unparked (measured: 4.5 s on top on every cold start). The- // set-diff handle is only a fallback now, and is trusted only when it agrees with the token.- let by_token = if birth_token.is_empty() { None } else { extdrive::hwnd_by_birth_token(ctx, &birth_token, &taken, Duration::from_secs(3)) };- match (by_token, birth_report(watcher, sid)) {- (Some(h), Some((wh, stamped))) => { s.hwnd = Some(h); early_stamped = stamped && wh == h; if wh != h { pup_core::log(format!("[window] \"{sid}\" the set-diff caught window {wh}, the birth token names {h}; bound by token")); } }- (Some(h), None) => { s.hwnd = Some(h); }- (None, Some((wh, stamped))) => { s.hwnd = Some(wh); early_stamped = stamped; pup_core::log(format!("[window] \"{sid}\" birth token {birth_token} not found within 3 s; bound the set-diff handle {wh}")); }- (None, None) => { if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { s.hwnd = Some(h); } }- }- // 2.0.635 backstop: if the watcher missed the invisible moment, stamp NOW, at the bind, before the first- // show below. The button exists already (a minimized window has one), but the stock icon then lasts the- // milliseconds to this call instead of the seconds to the deferred navigate and the full identity pass.- if !early_stamped {- if let (Some(h), Some((app_id, display, icon))) = (s.hwnd, plan.as_ref()) {- let t_s = Instant::now();- if ctx.host.stamp_identity(h, app_id, display, &icon.to_string_lossy(), &identity::relaunch_for(sid)) {- early_stamped = true;- pup_core::log(format!("[window] \"{sid}\" identity stamped at the bind in {}ms (the birth watcher missed the invisible moment)", t_s.elapsed().as_millis()));- }- }- }- // 2.0.604 (John, 2026-09-17: "why do you keep shoving windows in front of me without warning me?- // ... you shoved the window in my face while i was busy typing in another window"). Chrome creates the- // window unfocused but at z1, ON TOP of the user's work, and the park used to run AFTER the deferred- // navigate, the consumer-brand pass, the orphan sweep, the fit and the jump-list commit: measured 4.0 s- // of a pup window sitting over his screen, every open. Park the INSTANT the handle exists, before any of- // that. This is one Win32 call with no activation, so it is safe to hold the birth lock across.- if let Some(h) = s.hwnd {- let t_park = Instant::now();- // 2.0.624: this lane's R1 in two halves. The extension birthed the window MINIMIZED at its final rect- // (the API refuses off-screen, and a visible birth lands on top: measured). This one call orders it to- // the bottom while it is still minimized, restores it without activation, and orders it again, so the- // first frame it is ever drawn in is already behind the user's work. Minimized was a transient measured- // in the hundreds of milliseconds (the handle wait); it can never strand, a taskbar click restores it.- let parked = ctx.host.show_at_bottom_no_activate(h);- pup_core::log(format!("[window] \"{sid}\" birth: off-screen create, on-screen move {} (budget 50 ms), parked at z-bottom {}ms after the handle resolved (ext lane): {parked}",- match birth_move_ms { Some(m) if m >= 0 => format!("{m} ms"), _ => "unmeasured".into() }, t_park.elapsed().as_millis()));- if birth_move_ms.map(|m| m > 50).unwrap_or(false) { pup_core::log(format!("[window] \"{sid}\" DEFECT: the on-screen move took {} ms, over the 50 ms rule (pup-bridge-dev R1)", birth_move_ms.unwrap_or(0))); }- // 2.0.627: the FIRST window of a freshly started browser settles ON TOP after every park pup makes- // at birth (measured on the conference-room box with the fixed bottom call: a warm birth ended at z11,- // behind, the cold one at z0 and stayed). Something in Chrome raises its first window after pup is- // done. The doctrine's answer is the bounded re-assert inside the birth grace, which the CDP lane has- // run since v1.9.201 and this lane opted out of on the claim that Chrome never self-raises an- // unfocused window; true for a warm birth, false for a cold one. Safe by construction: the primitive- // never touches a window that is foreground, so a human who clicked it is left alone.- // 2.0.629: EVERY birth, not only a cold one. Measured: with the cold re-assert in place the cold window was- // pulled back down within seconds, and the WARM windows (B, C) came up on top and stayed, because they got- // no re-assert. "Chrome never self-activates an unfocused-created window on this lane" is false for a- // minimized birth restored by pup; the fit and the identity work that follow the park can raise it too.- schedule_birth_reassert(ctx, sid, if cold { "cold birth" } else { "birth" });- }- drop(birth_guard); // hwnd resolved (or refused): the next thread's birth may start- // the deferred cold navigate, now that no other birth is blocked behind it- // 2.0.630: EVERY birth navigates from the birth page to its real URL here, after the window is bound and- // parked, so the page the user cares about never loads into a window pup does not yet hold.- if url != "about:blank" {- // 2.0.595: the session OWNS its window from here. It used to be inserted only after this navigate, so for- // up to 25 s the lane's only window was an unowned about:blank, and the health tick's orphan sweep closed- // it: Chrome exited with its last window, the worker went silent, and 190 s later the "dead lane" path- // relaunched every session on disk, including six windows John had closed by hand (AdomLapper 21:41,- // 2026-09-15). That was also the "worker stalls right after a cold launch" mystery.- ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- // 2.0.648 (pup-bridge#90, Bridge SDK #59): a slow box can spend the whole 60 s ab budget in the cold launch- // alone; the window EXISTS by now (bound, parked, owned), so answer with loading:true and finish the- // navigate, fit and identity in the background instead of letting ab time the verb out after success.- if t_verb.elapsed() > Duration::from_secs(35) {- if let Some(a) = ctx.arc() {- let (sid2, url2, tab2, caller2) = (sid.to_string(), url.to_string(), chrome_tab, caller.clone());- std::thread::Builder::new().name("open-finish".into()).spawn(move || {- let ctx2: &Ctx = &a;- if let Ok(nav) = extdrive::run(json!({ "action": "navigate", "url": url2, "tabId": tab2, "waitMs": 25000 }), Duration::from_secs(30)) {- ctx2.sessions.with_mut(&sid2, |x| if let Some(t0) = x.tabs.get_mut(0) { if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } } if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } } });- if let Some(ss) = ctx2.sessions.get(&sid2) { ctx2.sessions.save_file(&ss); }- }- extdrive::fit_now();- identity::stamp_opts(&ctx2, &sid2, &caller2, true, true);- pup_core::log(format!("[window] \"{sid2}\" slow open finished in the background ({} s after the verb began)", t_verb.elapsed().as_secs()));- }).ok();- }- return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "tabId": t.tab_id, "hwnd": s.hwnd, "windowId": s.window_id, "transport": "ext", "loading": true,- "progress": { "phase": "navigating", "elapsedSec": t_verb.elapsed().as_secs(), "percent": 70 },- "_hint": "This box is slow: the window exists, is yours and is parked in the background, and the page is still loading. Poll pup_window_info {} (url/title fill in) or pup_readiness (active[]) rather than opening again; a second open with the same window label is refused as owned." }));- }- let nav = match extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)) {- Ok(v) => v,- Err(e) => {- if s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(false) && !extdrive::alive(ctx) {- ctx.sessions.remove(sid); ctx.sessions.delete_file(sid);- pup_core::log(format!("[window] \"{sid}\": the lane browser died during the cold open (its window is gone and the worker is silent); no session"));- return pup_core::merge(e, json!({ "errorCode": "ext_lane_died_during_open", "_hint": "The lane browser exited while this window was being opened. Open again; pup did not keep a dead session." }));- }- Value::Null- }- };- if let Some(t0) = s.tabs.get_mut(0) {- if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } }- if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } }- }- }- if early_stamped { if let Some((a, _, _)) = &plan { s.app_id = Some(a.clone()); } }- ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- // 2.0.653: page scripts + wiki probe on the extension lane's first page (the CDP open always did both)- let mut wiki_extra = json!({});- if let (Ok(lane), Some(t0)) = (transport::lane_for(ctx, &s), s.tabs.get(0).cloned()) { if t0.url != "about:blank" { inject_page_scripts_ext(ctx, &s, &lane, &t0); wiki_extra = wiki::after_navigate_ext(ctx, &s, &lane, &t0, &t0.url, args.get("wikiView").and_then(|v| v.as_str()), false); } }- extdrive::apply_consumer_brands(ctx); // once per lane browser; before the first page the user cares about- extdrive::close_orphan_blank_windows(ctx, chrome_win); // the startup window a relaunch left behind, if any- extdrive::fit_now();- // 2.0.577: ALWAYS a full pass, as the CDP path does after its early stamp. The in-process birth stamp- // sets the AUMID but never commits a jump list, and stamp_opts only commits one inside its full/changed- // block; with full=false and app_id already set, ext windows were born with a tile and NO right-click- // menu (John, 2026-09-14: "a shotlog pup window but no jump list"). Same AUMID again = no button rebuild.- let t_jl = Instant::now();- identity::stamp_opts(ctx, sid, caller, true, true);- if early_stamped { pup_core::log(format!("[window] \"{sid}\" jump list committed on the early-stamped button in {}ms", t_jl.elapsed().as_millis())); }- if let Some(h) = s.hwnd {- // 2.0.584: R1, "background means BEHIND". chrome.windows.create({focused:false}) never steals focus but lands- // the window at z1, right under the user's active window and above everything else (measured: z1 of 17,- // still z1 twenty seconds later). Park it to the bottom once, now. No +2.5 s re-assert on this lane: Chrome- // never self-activates an unfocused-created window (measured), so a re-assert could only undo a human click.- // the early park above already put it behind. Re-assert ONLY if it is still on top and the user has- // not taken it: a blind re-assert here would undo a click the human made in the meantime.- if ctx.host.foreground() != h { let _ = ctx.host.send_to_bottom_no_activate(h); }- activity::on_open(ctx, sid, h, caller); activity::clear_attention_later(ctx, h); // 2.0.637: never ab's clear here (it raises + activates)- }- if let Ok(lane) = transport::lane_for(ctx, &s) { let mut s2 = s.clone(); transport::refresh_all(&lane, &mut s2); ctx.sessions.insert(s2.clone()); ctx.sessions.save_file(&s2); s = s2; }- // 2.0.621: no focus handback anywhere. pup-user-foreground deleted it in August because it foregrounded- // the wrong window; R2: if a window fronts anyway, let the flaw exist and log it, never fight it.- // Get the icon right AT BIRTH, not on a later tick (John, 2026-09-14). The birth wait can bind the- // transient about:blank launch window Chrome replaces; the stamp above then lands on a dead handle.- // Now that the real page is up: if the bound hwnd is not a live window, bind the live lane window- // and stamp it before this verb returns.- if s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(true) {- if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) {- ctx.sessions.with_mut(sid, |x| x.hwnd = Some(h)); s.hwnd = Some(h); ctx.sessions.save_file(&s);- identity::stamp(ctx, sid, caller, true); activity::on_open(ctx, sid, h, caller);- // 2.0.628: the birth park ran on the placeholder, so this window has never been parked. Measured: it- // came up on top and stayed there (the placeholder was rebound 6 s after the park, after every- // re-assert had fired on the dead handle). Park it NOW and re-assert from here.- let parked = ctx.host.send_to_bottom_no_activate(h);- pup_core::log(format!("[ext] birth bound a transient window; rebound \"{sid}\" to the live window {h}, stamped it and parked it at z-bottom: {parked}"));- schedule_birth_reassert(ctx, sid, "late rebind");- }- }- let t = s.active().cloned().unwrap_or(t);- pup_core::merge(ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "hwnd": s.hwnd, "windowId": s.window_id, "transport": "ext", "landed": landed_ext(ctx, &s, &t, &t.url, &t.title),- "_hint": "Extension-driven window (no CDP debug port): a first-class pup window. Drive it with pup_navigate / pup_eval / pup_screenshot / pup_open_tab like any other." })), wiki_extra)-}--/// pup_clear_cookies {domain, names?}: drop a site's cookies in this window's jar, HttpOnly included. The case-/// that made it exist: a bot manager's verdict cookie (Akamai _abck at ~-1~) that keeps a jar denied long after-/// the browser itself looks fine (analog.com: loads on a fresh jar, Access Denied on adom-you, John 2026-09-13).-fn clear_cookies(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let Some(domain) = args["domain"].as_str().filter(|d| !d.trim().is_empty()) else { return err("domain_required", "pup_clear_cookies needs {domain}", Some("e.g. {domain:\"analog.com\"} removes analog.com and *.analog.com cookies; names:[...] narrows to those cookie names.")) };- let names: Vec<String> = args["names"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };- match transport::clear_cookies(&lane, domain, &names) {- Ok(n) => { pup_core::log(format!("[cookies] \"{}\": removed {n} cookie(s) for {domain}{}", s.id, if names.is_empty() { String::new() } else { format!(" ({})", names.join(",")) })); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "domain": domain, "removed": n, "transport": s.transport })) }- Err(e) => e,- }-}--/// pup_clear_site_data {origin | origins:[...]}: cache, cookies, storage and service workers for those origins.-fn clear_site_data(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let mut origins: Vec<String> = args["origins"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();- if let Some(o) = args["origin"].as_str() { origins.push(o.to_string()); }- if origins.is_empty() { return err("origin_required", "pup_clear_site_data needs {origin} or {origins:[...]}", Some("e.g. {origins:[\"https://www.analog.com\",\"https://analog.com\"]}")); }- let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };- match transport::clear_site_data(&lane, &origins) {- Ok(()) => { pup_core::log(format!("[sitedata] \"{}\": cleared {:?}", s.id, origins)); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "origins": origins, "transport": s.transport })) }- Err(e) => e,- }-}--pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- // pup_close {} with no ids closes the caller's window; pup_close {all:true} closes everything of theirs.- let mut targets: Vec<Session> = Vec::new();- if args.get("all").and_then(|b| b.as_bool()) == Some(true) {- // all:true = the caller's windows; everyone:true (the jump list's "Close ALL Adom Pup windows", a- // user action) = every window on this desktop whoever owns it. Node: pup_close {all, everyone}.- // 2.0.491: `everyone:true` closes EVERY pup window on the desktop, including other threads' work.- // That is the jump list's "Close ALL pup windows" — a USER action. The Rust port left it open to any- // AI thread, so one thread could wipe another's windows with no refusal and no trace. Now only a human- // caller gets it; an AI asking for it closes its OWN windows and is told why.- let asked_everyone = args.get("everyone").and_then(|b| b.as_bool()) == Some(true);- let human = caller.thread.is_empty() || USER_CALLERS.contains(&caller.thread.as_str());- let everyone = asked_everyone && human;- if asked_everyone && !human { pup_core::log(format!("[close] thread \"{}\" asked to close EVERYONE's windows; refused — closing only its own (everyone:true is the jump list's user action)", caller.thread)); }- targets = if everyone { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) };- // 2.0.476: cancel any in-flight restore of the windows being closed and remove their files, so the close is- // INSTANT (no 40 s drain that blocks the user and outlives the CLI request) and a relaunch that is mid-flight- // undoes its own window instead of leaving it behind (close-during-restore cycle). A reserved id with no live- // session yet is a window a restore is bringing back right now: cancel and unfile it too.- let mut cancel_ids: Vec<String> = targets.iter().map(|s| s.id.clone()).collect();- if everyone || caller.thread.is_empty() { for f in ctx.sessions.read_files() { if let Some(id) = f["sessionId"].as_str() { if !cancel_ids.iter().any(|c| c == id) { cancel_ids.push(id.to_string()); } } } }- for id in &cancel_ids { crate::recovery::cancel_restore(id); ctx.sessions.delete_file(id); }- }- else { match resolve(ctx, caller, args) { Ok(s) => targets.push(s), Err(e) => return e } }- let mut closed = Vec::new(); let mut chrome_killed = 0; let mut failed: Vec<String> = Vec::new();- for s in targets {- // 2.0.470: the session and its file go FIRST. Closing the targets fires Target.targetDestroyed on the event- // pump, whose handler re-saves the session file; when that save landed after the delete below, the file- // came back (url about:blank, the other tabs listed) and the next bridge start relaunched a ghost window- // from it (arav-rog, 2026-09-11: "drill-b" reborn with 5 tabs after an update).- ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);- let lane_closed = match transport::lane_for(ctx, &s) { Ok(lane) => transport::close_window(&lane, &s), Err(_) => false };- // Verify the OS window is gone; a close that races a bridge respawn (no live CDP handle yet) used to- // drop the session and leave the window behind (John's laptop, 2026-09-06: three leftovers wearing- // old tiles). The window is closed through ab (WM_CLOSE, forced after 3 s) when it survives.- // 2.0.587: an UNBOUND extension session (worker mid-reload, hwnd None) resolves its handle here first;- // before this the OS close was skipped and eight ownerless windows were left on the user's glass.- let hwnd = s.hwnd.or_else(|| if transport::is_ext(&s) { extdrive::lane_hwnd_for(ctx, &s) } else { None });- let mut survived = false;- if let Some(h) = hwnd {- std::thread::sleep(Duration::from_millis(300));- if ctx.host.is_alive(h) {- let _ = ctx.ab.call("desktop_close_window", json!({ "hwnd": h, "forceAfterMs": 3000 }), caller, Duration::from_secs(6));- let t0 = std::time::Instant::now();- while t0.elapsed() < Duration::from_secs(4) && ctx.host.is_alive(h) { std::thread::sleep(Duration::from_millis(250)); }- survived = ctx.host.is_alive(h);- }- } else if !lane_closed { survived = true; } // nothing confirmed the close and there is no handle to force it- if survived {- // 2.0.587: never drop a session whose window is still on glass. Put it back and say so.- let mut back = s.clone(); back.hwnd = hwnd; ctx.sessions.insert(back.clone()); ctx.sessions.save_file(&back);- pup_core::log(format!("[close] \"{}\" did NOT close (lane answered: {lane_closed}, hwnd {:?} still alive); session kept so the window is never ownerless", s.id, hwnd));- failed.push(s.id.clone());- continue;- }- ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); closed.push(s.id.clone()); // again: a late save loses- if let Some(a) = s.app_id.as_deref() { identity::release_identity(ctx, a); } // 2.0.666: no dead AUMID left behind- // last ext window: let the lane browser go, the same rule the CDP browsers follow below- if transport::is_ext(&s) && !ctx.sessions.all().iter().any(|x| transport::is_ext(x) && x.profile == s.profile) {- // 2.0.659: the LAST window of this profile's lane ends that lane's browser (the default lane's rule unchanged)- extdrive::enter_profile(&s.profile);- let n = kill_extdrive(ctx); if n > 0 { chrome_killed += 1; }- if s.profile != "adom-you" { extdrive::drop_lane_for_profile(&s.profile); }- extdrive::leave_profile();- }- // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused)- if ctx.sessions.for_profile(&s.profile).is_empty() {- if s.profile.starts_with("iso-") {- // 2.0.648 (pup-bridge#112): a throwaway profile dies with its last window; the browser is ended- // just below, so remove the directory after it has let go (a few tries, then leave it for prewarm).- let dir = pup_browser::profiles_dir(&*ctx.host).join(&s.profile); let sid = s.id.clone();- let staging = ctx.host.state_dir().join(format!("extlane-{}", s.profile)); let _ = std::fs::remove_dir_all(&staging); // 2.0.660: the lane's staged extension goes with it- std::thread::spawn(move || { for _ in 0..10 { std::thread::sleep(Duration::from_millis(1500)); if std::fs::remove_dir_all(&dir).is_ok() || !dir.exists() { pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} removed", dir.display())); return; } } pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} still held; left on disk", dir.display())); });- }- if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {- let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();- // give Chrome a moment to exit on its own; only then reap a straggler (never a slow one mid-write)- if b.launched.pid > 0 { for _ in 0..12 { if !ctx.host.alive(b.launched.pid) { break; } std::thread::sleep(Duration::from_millis(250)); } if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } chrome_killed += 1; }- }- }- }- if !failed.is_empty() && closed.is_empty() { return pup_core::merge(err("close_failed", format!("{} window(s) did not close: {}", failed.len(), failed.join(", ")), Some("The window is still on screen and its session was kept. Retry pup_close_window; if the extension lane is mid-reload, wait a few seconds first.")), json!({ "failed": failed })); }- ok(json!({ "closed": closed, "failed": failed, "remaining": ctx.sessions.count(), "activeSession": ctx.sessions.active_id(), "chromeProcsKilled": chrome_killed, "chromeProcsRemaining": ctx.browsers.lock().unwrap().len(), "_hint": "Closed. The sessionId is no longer valid; pup_open_window creates a fresh one.", "_next": ["pup_list_windows", "pup_open_window"] }))-}--#[allow(dead_code)]-fn _unused(_: Value) -> Value { blocked_on_user(false, "", "", json!({})) }+ // 2026-10-13: the process query is the host's (Win32_Process on Windows, the process table on macOS); the+ // cfg!(windows) gate that made this empty on a Mac left every stray lane browser running there.+ for (pid, prof) in ctx.host.pup_browser_processes() { m.entry(prof).or_default().push(pid); }+ m+}++/// The consumer-identity JS shim staged into the lane extension (None when consumerBrands is off).+pub fn lane_shim(ctx: &Ctx) -> Option<String> {+ let shim: Option<String> = if ctx.settings.get("consumerBrands", "on") != "off" { consumer_identity_strings(ctx).map(|v| format!(+ concat!(+ "(() => {{ const UA = {ua}; const B = {b}; const F = {f}; const FULL = {full}; const cp = (a) => a.map(x => Object.assign({{}}, x));\n",+ " try {{ Object.defineProperty(Navigator.prototype, 'userAgent', {{ get: () => UA, configurable: true }}); Object.defineProperty(Navigator.prototype, 'appVersion', {{ get: () => UA.replace(/^Mozilla\\//, ''), configurable: true }}); }} catch (e) {{}}\n",+ // navigator.userAgentData is a NEW object on every access (measured), so an instance property is lost;\n",+ " try {{ const d = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); if (d && d.get) {{ const realGet = d.get; Object.defineProperty(Navigator.prototype, 'userAgentData', {{ configurable: true, get() {{ const t = realGet.call(this); if (!t) return t; return new Proxy(t, {{ get(o, k) {{ if (k === 'brands') return cp(B); if (k === 'toJSON') return () => ({{ brands: cp(B), mobile: o.mobile, platform: o.platform }}); if (k === 'getHighEntropyValues') return (h) => o.getHighEntropyValues(h).then(v => Object.assign(v, {{ brands: cp(B), fullVersionList: cp(F), uaFullVersion: FULL || v.uaFullVersion }})); const v = Reflect.get(o, k); return typeof v === 'function' ? v.bind(o) : v; }} }}); }} }}); }} }} catch (e) {{}}\n",+ "}})();"),+ ua = serde_json::to_string(v["ua"].as_str().unwrap_or("")).unwrap(), b = v["brands"].to_string(), f = v["fullVersionList"].to_string(), full = serde_json::to_string(v["fullVersion"].as_str().unwrap_or("")).unwrap())) } else { None };+ shim+}++/// 2.0.628: the doctrine's bounded re-assert inside the birth grace (pup-user-foreground rule 2, the CDP lane's+/// +2.5 s pass since v1.9.201), for the two moments a window on this lane is measured to settle on top after+/// pup parked it: the first window of a freshly started browser, and a window bound late because the birth+/// caught a placeholder. Force is on, so the primitive applies the idle-hands rule: no input since birth means+/// Chrome's own raise and it is bottomed; any input means a person, and it answers user-foreground and stops.+fn schedule_birth_reassert(ctx: &Ctx, sid: &str, why: &'static str) {+ let Some(a) = ctx.arc() else { return };+ let sid2 = sid.to_string();+ std::thread::Builder::new().name("birth-reassert".into()).spawn(move || {+ // 2.0.630: the first passes land within tens of milliseconds. Measured on 2.0.629: a warm birth still+ // showed on top for ONE half-second sample, the gap between pup's restore and the first pass at 300 ms.+ // Then dense through the first second, where the fit and the identity pass run, then sparse; ~6 s in all.+ let waits = [40u64, 60, 100, 150, 250, 400, 600, 1000, 1500, 2500];+ for (i, wait) in waits.iter().enumerate() {+ std::thread::sleep(Duration::from_millis(*wait));+ let r = park::bottom_unless_foreground(&a, &sid2, true);+ if r != "bottomed" || i + 1 == waits.len() { pup_core::log(format!("[park] \"{sid2}\" {why} re-assert {}/{}: {r}", i + 1, waits.len())); }+ if r == "user-foreground" || r == "no-hwnd" { break; }+ }+ }).ok();+}++/// pup-bridge#121: pids of browser processes whose command line names `profile_dir` (Windows: WMI through the+/// host's shell runner). Used only when the lane is NOT alive, so every match is an orphan.+pub fn orphan_browser_pids(ctx: &Ctx, profile_dir: &std::path::Path) -> Vec<u32> {+ // 2.0.708: match the profile FOLDER NAME after pup-profiles, either slash. (The Windows query once doubled every+ // backslash for a PowerShell single-quoted string and never matched: every boot and idle sweep of isolated+ // profiles reported "ended 0 browser process(es)" and left the browser in the system tray, John's laptop+ // 2026-10-11.) The listing is the host's now, one query shared with pup_browser_pids_by_profile.+ let name: String = profile_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect();+ if name.is_empty() { return Vec::new(); }+ let mut v: Vec<u32> = ctx.host.pup_browser_processes().into_iter().filter(|(_, p)| *p == name).map(|(pid, _)| pid).collect();+ v.retain(|p| *p != std::process::id());+ v+}++pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> {+ // 2.0.517: the lane runs on adom-you, the ONE durable jar, because John is right that nothing should+ // ever use anything else: a separate adom-you-ext jar had none of his cookies, logins or history,+ // which is both a divergence and the empty-profile shape bot walls score against (measured: the ext+ // jar carried 0.2 MB of History against adom-you's 6.3 MB). It used to be separate only because+ // Chrome allows ONE process per --user-data-dir and this lane must run with NO debug port, so it+ // cannot coexist with a CDP browser on the same directory. The resolution is that the lane TAKES the+ // profile: any CDP browser holding adom-you is ended first, and CDP re-launches on it are refused+ // while the lane owns it (see ext_lane_owns_profile).+ let profile_owned = extdrive::cur_profile(); // 2.0.659: the lane this thread entered (adom-you unless isolated/profile)+ let profile = profile_owned.as_str();+ let dir = pup_browser::profiles_dir(&*ctx.host).join(profile);+ // 2.0.513: publish the token AFTER kill_extdrive below, never before. 2.0.510 taught kill_extdrive to+ // clear EXTDRIVE_LANE (right, so a closed lane holds no token), but this line ran FIRST and the kill+ // then wiped the token for the launch it was preparing. Every launch since served nothing: the+ // extension polled with the correct lane and pup compared it against "" and refused. The /exthello+ // line added in 2.0.512 is what finally showed it.+ let lane = format!("ext-{}", pup_core::now_ms());+ let shim = lane_shim(ctx);+ let exts = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, profile, true, Some(&lane), shim);+ if exts.is_empty() { return Err(err("ext_no_extension", "the pup extension is not staged, so nothing can drive an extension window", Some("toolbarExtension must be on: the channel IS the extension."))); }+ let cand = match pup_browser::detect_cft(&*ctx.host) {+ Some(c) => c,+ None => return Err(err("ext_needs_cft", "extension-driven mode needs Chrome for Testing (branded Chrome 137+ dropped --load-extension)", Some("pup_readiness shows the Chrome for Testing install; pup_prewarm fetches it."))),+ };+ // 2.0.496: a Chrome still holding this profile makes the NEW process forward to it and EXIT at once, so the+ // launch "succeeds" and nothing is there (ConfRoomROG: the log said up, no window existed, the extension+ // never called home). Same profile-lock rule pup_restart_browser already follows: end the old one and wait+ // for the lock to clear before launching.+ let image = cand.executable_path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| "chrome.exe".into());+ // Hand the profile over from the CDP lane. Without this the portless launch FORWARDS to the running+ // CDP browser and exits at once, which silently gives you a window that still carries a debug port -+ // the exact thing this lane exists to avoid.+ if let Some(b) = ctx.browsers.lock().unwrap().remove(profile) {+ let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));+ b.cdp.close();+ if b.launched.pid > 0 {+ std::thread::sleep(Duration::from_millis(800));+ if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); }+ }+ pup_core::log(format!("[extdrive] ended the CDP browser holding profile \"{profile}\" so the lane can own it"));+ }+ // ctx.browsers is EMPTY right after a pup restart, while a CDP Chrome from before the restart is+ // still running and still holding the directory. pup reconnects to those lazily, so the map is not+ // the source of truth here: the profile's own DevToolsActivePort is. Without this the portless+ // launch just forwards to that browser and exits (the "1 process(es)" line).+ if let Some(port) = pup_browser::existing_port(&dir) {+ if let Ok(cdp) = Cdp::connect_port(port, Duration::from_secs(5)) {+ let _ = cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));+ cdp.close();+ pup_core::log(format!("[extdrive] closed the CDP browser still on :{port} for profile \"{profile}\" (it outlived a pup restart)"));+ let lock = dir.join("SingletonLock");+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(10) { if !lock.exists() { break; } std::thread::sleep(Duration::from_millis(200)); }+ }+ }+ let stale = kill_extdrive(ctx); // 2.0.686: kill_extdrive now waits until the profile is truly free+ if stale > 0 {+ pup_core::log(format!("[extdrive] ended {stale} stale browser process(es) holding profile \"{profile}\" before relaunching"));+ }+ // 2.0.646 (pup-bridge#121): an ORPHANED lane browser (launched by a pup instance that has since restarted,+ // so no pid list knows it) keeps the profile lock; every new launch then prints "Opening in existing+ // browser session." and exits 0, and pup retried blindly into that wall for 60 s per open (AdomLapper,+ // 2026-09-19, one hour). The lane is not alive here, so any browser on this profile is an orphan: end it,+ // then the lock below is removable. Only processes whose command line names THIS profile dir; never the+ // user's own Chrome.+ let orphans = orphan_browser_pids(ctx, &dir);+ if !orphans.is_empty() {+ for p in &orphans { ctx.host.kill(*p); }+ std::thread::sleep(Duration::from_millis(800));+ pup_core::log(format!("[lane] ended {} orphaned browser process(es) holding profile {} (no live lane owned them); launching fresh", orphans.len(), dir.display()));+ }+ for f in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = std::fs::remove_file(dir.join(f)); }+ *extdrive::st().token.lock().unwrap() = lane.clone(); // the kill is done; this token is now the live one+ let before: Vec<u32> = ctx.host.pids_for_image(&image);+ let stock = ctx.settings.get("laneStockFlags", "off") == "on";+ if stock { pup_core::log("[lane] laneStockFlags is ON: launching with only the firewall guards, everything else stock".to_string()); }+ let trials = ctx.settings.get("laneFieldTrials", "on") != "off";+ if stock && !trials { pup_core::log("[lane] laneFieldTrials is OFF: --disable-field-trial-config added to the stock launch".to_string()); }+ // 2.0.605 (John, 2026-09-17: "why do you keep shoving pup windows in front of me? that's rude").+ // A COLD launch is the one case the birth park cannot cover: Chrome paints its OWN startup window the+ // moment the process starts, seconds before pup has a handle to park (measured on his laptop right after+ // he asked me to close everything, which forced exactly this path). So watch for it from the instant of+ // the spawn: poll every 25 ms and bottom any Chrome-for-Testing window on the profile as soon as it+ // exists. send_to_bottom_no_activate never activates and never hides, so the window still renders.+ // 2.0.621: no watcher, no hiding. The lane launches with --no-startup-window, so there is no startup+ // window to catch, and a window held hidden "until its session is ready" is the staging state the+ // visibility invariant forbids (pup-user-foreground rule 7): an interrupted birth would strand it.+ match pup_browser::launch_extdrive_opts(&*ctx.host, &cand, &dir, &exts, url, stock, trials) {+ Ok(pid) => {+ let mut owned = vec![pid];+ for _ in 0..20 {+ std::thread::sleep(Duration::from_millis(250));+ let now = ctx.host.pids_for_image(&image);+ let fresh: Vec<u32> = now.into_iter().filter(|p| !before.contains(p)).collect();+ if !fresh.is_empty() { owned = fresh; break; }+ }+ pup_core::log(format!("[extdrive] {} up on profile \"{profile}\" with NO debug port ({} process(es))", cand.kind, owned.len()));+ for p in &owned { ctx.host.watch_process(*p); } // 2.0.595: the exit code decides restore vs forget+ *extdrive::st().pids.lock().unwrap() = owned;+ save_lane(ctx);+ Ok(pid)+ }+ Err(e) => Err(err("ext_launch_failed", e, None)),+ }+}+++/// The GREASE brand real Chrome sends, per major, as measured on real installs (John's Chrome 152, 2026-09-13).+/// Chrome derives it from the major; unknown majors fall back to the long-standing "Not/A)Brand";v="99".+pub fn grease_for(major: &str) -> (&'static str, &'static str) {+ match major { "152" => ("Not?A_Brand", "24"), _ => ("Not/A)Brand", "99") }+}+/// The identity the lane presents: the INSTALLED Chrome's version, in real Chrome's brand order.+pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {+ set_target_chrome_from_host(&*ctx.host);+ let full = TARGET_CHROME_FULL.lock().unwrap().clone().unwrap_or_default();+ if full.is_empty() { return None; }+ let major = full.split('.').next().unwrap_or("").to_string();+ let (gb, gv) = grease_for(&major);+ let ua = format!("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36");+ let pinned = ctx.settings.get("consumerBrandsHeader", "");+ let chua = if pinned.is_empty() { format!("\"Chromium\";v=\"{major}\", \"{gb}\";v=\"{gv}\", \"Google Chrome\";v=\"{major}\"") } else { pinned };+ let chua_full = format!("\"Chromium\";v=\"{full}\", \"{gb}\";v=\"{gv}.0.0.0\", \"Google Chrome\";v=\"{full}\"");+ Some(json!({ "ua": ua, "chua": chua, "chuaFull": chua_full, "fullVersion": full,+ "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],+ "fullVersionList": [ {"brand":"Chromium","version":full}, {"brand":gb,"version":format!("{gv}.0.0.0")}, {"brand":"Google Chrome","version":full} ] }))+}+pub fn consumer_ua(cdp: &Cdp) -> Option<(String, Value)> {+ if let Some(v) = CONSUMER_UA.lock().unwrap().clone() { return Some(v); }+ let ver = cdp.version().ok()?;+ let mut ua = ver["userAgent"].as_str().unwrap_or("").to_string();+ if ua.is_empty() || !ua.contains("Chrome/") { return None; }+ let cft_full = ua.split("Chrome/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("").to_string();+ // Present the INSTALLED consumer Chrome's version when we know it (John: match nb exactly); else CfT's own.+ let target = TARGET_CHROME_FULL.lock().unwrap().clone().unwrap_or_default();+ let full = if target.is_empty() { cft_full.clone() } else { target };+ let major = full.split('.').next().unwrap_or("").to_string();+ if major.is_empty() { return None; }+ // Modern Chrome uses a REDUCED UA (major only, minor/build/patch zeroed); mirror that in the UA header.+ if !cft_full.is_empty() { ua = ua.replace(&format!("Chrome/{cft_full}"), &format!("Chrome/{major}.0.0.0")); }+ let is_win = ua.contains("Windows");+ let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else { ("macOS", "14.6.0") };+ let (gb, gv) = grease_for(&major); // 2.0.543: real Chrome's order and GREASE (measured on the installed Chrome)+ let meta = json!({+ "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],+ "fullVersionList": [ {"brand":"Chromium","version":full}, {"brand":gb,"version":format!("{gv}.0.0.0")}, {"brand":"Google Chrome","version":full} ],+ "fullVersion": full, "platform": platform, "platformVersion": platform_version,+ "architecture": "x86", "bitness": "64", "model": "", "mobile": false, "wow64": false+ });+ let v = (ua, meta);+ *CONSUMER_UA.lock().unwrap() = Some(v.clone());+ Some(v)+}+pub fn apply_consumer_identity(cdp: &Cdp, sid: &str) {+ if let Some((ua, meta)) = consumer_ua(cdp) {+ let _ = cdp.send(Some(sid), "Network.setUserAgentOverride", json!({ "userAgent": ua, "userAgentMetadata": meta }), Duration::from_secs(5));+ }+ // EME/Widevine capability shim so an EME-probe bot check passes (real DRM playback still needs nb/nbe).+ let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": CONSUMER_EME_JS }), Duration::from_secs(5));+ let _ = cdp.evaluate(sid, CONSUMER_EME_JS, Duration::from_secs(5));+}+/// The verb catalog (name/summary/input/timeoutSeconds/example/hint/related/pitfalls), generated by+/// tools/gen-assets.py from the Node build's pup_describe golden. Node: VERB_META + the V() rows.+const VERB_CATALOG_JSON: &str = include_str!("../assets/verb-catalog.json");+static VERB_CATALOG: std::sync::OnceLock<Value> = std::sync::OnceLock::new();+pub fn catalog() -> &'static Value { VERB_CATALOG.get_or_init(|| serde_json::from_str(VERB_CATALOG_JSON).unwrap_or(json!({ "verbs": [] }))) }+pub fn catalog_meta(verb: &str) -> Option<&'static Value> { catalog()["verbs"].as_array()?.iter().find(|v| v["name"] == json!(verb)) }++/// Ticks a loop stat when dropped (so early `continue`s still count).+pub struct LoopTick(pub &'static str, pub u64, pub std::time::Instant);+impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick(self.0, self.1, self.2.elapsed().as_millis() as u64); } }++static CLEANUP_REPORTS: Mutex<Vec<String>> = Mutex::new(Vec::new());+pub fn push_cleanup_report(msg: String) { CLEANUP_REPORTS.lock().unwrap().push(msg); }+fn drain_cleanup_reports() -> Option<String> { let mut g = CLEANUP_REPORTS.lock().unwrap(); if g.is_empty() { None } else { Some(g.drain(..).collect::<Vec<_>>().join(" | ")) } }++const PUP_ISSUES_REF: &str = "adom/pup-bridge";+const BUG_INVITE_EVERY: u64 = 40;+static BUG_INVITE: Mutex<(bool, u64)> = Mutex::new((false, 0));+/// Node: bugReportInvite(isError).+pub fn bug_report_invite(is_error: bool) -> String {+ let lead = if is_error { "THIS MAY BE A PUP BUG — PLEASE REPORT IT." } else { "pup is actively developed and bug reports are genuinely wanted." };+ format!("{lead} If a pup verb misbehaves (wrong result, a hang, a window that never appears, a hint that turned out to be false, a process left running), file it on pup's own wiki repo so the maintainers see it: `adom-wiki issue list {PUP_ISSUES_REF}` to check for a duplicate first, then `adom-wiki issue create {PUP_ISSUES_REF} --title \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON, and the bridge version from bridge_list>\"`. Include the machine's OS and whether it is Windows or macOS. Reports from real machines are the only way this bridge gets correct — the maintainers cannot reproduce every environment.")+}+const PROBE_VERBS: [&str; 9] = ["pup_readiness", "pup_status", "pup_describe", "pup_prewarm", "pup_list_windows", "pup_list_tabs", "pup_rescan", "pup_configure", "pup_import_scan"];+const RENDER_VERBS: [&str; 4] = ["pup_open_window", "pup_open_tab", "pup_navigate", "pup_reload"];+/// Verbs that do not count as "the agent drove this window" for lastAgentUpdate.+const PASSIVE_VERBS: [&str; 7] = ["pup_window_info", "pup_my_windows", "pup_list_sessions", "pup_errors", "pup_switch_window", "pup_use", "pup_audit_icons"];++/// Node: PERMISSIVE_DEFAULT_PERMISSIONS, applied per permission with Browser.setPermission on the+/// default context (origin omitted = every origin) so no permission prompt ever blocks a debug flow.+pub const DEFAULT_PERMISSIONS: [&str; 32] = ["window-management", "clipboard-read", "clipboard-write", "clipboard-sanitized-write", "notifications", "geolocation", "camera", "microphone", "midi", "background-sync", "background-fetch", "periodic-background-sync", "payment-handler", "persistent-storage", "durable-storage", "storage-access", "top-level-storage-access", "idle-detection", "display-capture", "local-fonts", "nfc", "screen-wake-lock", "system-wake-lock", "accelerometer", "gyroscope", "magnetometer", "ambient-light-sensor", "sensors", "protected-media-identifier", "captured-surface-control", "speaker-selection", "window-placement"];+pub fn grant_default_permissions(cdp: &Cdp) -> usize {+ let mut n = 0;+ for p in DEFAULT_PERMISSIONS { if cdp.send(None, "Browser.setPermission", json!({ "permission": { "name": p }, "setting": "granted" }), Duration::from_secs(3)).is_ok() { n += 1; } }+ n+}++/// Node: probeRender — judge what just landed (404 text, blank, unstyled, stack traces, broken images).+pub fn render_check(cdp: &Cdp, sid: &str, http_status: Option<u64>) -> Value {+ let mut signals: Vec<String> = Vec::new();+ let mut out = json!({ "httpStatus": http_status, "ok": true });+ match cdp.evaluate(sid, RENDER_PROBE_JS, Duration::from_secs(6)) {+ Ok(p) if p.is_object() => {+ out["title"] = p["title"].clone(); out["bodyChars"] = p["bodyChars"].clone(); out["elements"] = p["elements"].clone();+ let head = p["head"].as_str().unwrap_or("").to_string(); let low = head.to_lowercase();+ let (chars, elements, sheets) = (p["bodyChars"].as_u64().unwrap_or(0), p["elements"].as_u64().unwrap_or(0), p["styleSheets"].as_u64().unwrap_or(0));+ if let Some(st) = http_status { if st >= 400 { signals.push(format!("http_{st}")); } }+ if low.contains("page not found") || low.contains("not found") || low.split(|c: char| !c.is_ascii_digit()).any(|w| w == "404") { signals.push("page_says_not_found".into()); }+ if low.contains("cannot get /") || low.contains("cannot post /") { signals.push("dev_server_cannot_get".into()); }+ if low.contains("internal server error") || low.contains("bad gateway") || low.contains("service unavailable") { signals.push("server_error_text".into()); }+ if low.contains("traceback (most recent call last)") || low.contains("unhandled exception") || low.contains("unhandled rejection") || low.contains("referenceerror") || low.contains("typeerror:") || low.contains("syntaxerror:") { signals.push("stack_trace_visible".into()); }+ if chars < 12 && elements < 8 { signals.push("effectively_blank".into()); }+ if chars > 40 && sheets == 0 { signals.push("no_stylesheets_loaded".into()); }+ if let Some(b) = p["brokenImgs"].as_u64() { if b > 0 { signals.push(format!("broken_images_{b}")); } }+ // pup-bridge#109: a chrome-error:// interstitial (net/TLS failure, or a bot-wall RST) must read as+ // BROKEN, not ok. It used to pass because the error page has a title and body text and no other+ // signal fired, so renderCheck told the caller the opposite of the truth.+ if p["errPage"].as_bool() == Some(true) {+ signals.push("chrome_error_page".into());+ let code = p["netError"].as_str().unwrap_or("").trim().to_string();+ if !code.is_empty() { signals.push(format!("net_error:{code}")); out["netError"] = json!(code); }+ out["fallbackHint"] = json!("This is Chrome's error page (a network/TLS failure or a bot-wall reset), NOT the site's content. Many electronics-vendor sites (st.com, mouser.com, digikey.com, arrow.com, analog.com) fingerprint pup's Chrome for Testing and block it, while the user's real Chrome loads them fine. Do not retry in pup: switch lanes. Open the page with nbrowser_open_window, or pull a file with nbrowser_fetch_url (the Adom browser extension drives the user's signed-in Chrome).");+ }+ if !signals.is_empty() { out["excerpt"] = json!(head.chars().take(180).collect::<String>()); }+ }+ Ok(_) => signals.push("probe_timed_out".into()),+ Err(_) => signals.push("probe_failed".into()),+ }+ out["ok"] = json!(signals.is_empty()); out["signals"] = json!(signals);+ out+}++/// Per-tab state the event pump maintains (keyed by CDP session id).+#[derive(Debug, Clone, Default)]+pub struct TabState { pub errors: Vec<Value>, pub dialog: Option<Value>, pub url: Option<String>, pub title: Option<String> }++pub const MAX_SCREENSHOT_DIM: u32 = 1568; // Node: MAX_SCREENSHOT_DIM (Claude's optimal max)+const OFFSCREEN: i32 = -32000; // Node: _OFFX/_OFFY birth position++/// A running browser pup is connected to (one per profile).+pub struct BrowserHandle { pub cdp: Cdp, pub launched: Launched }++pub struct Ctx {+ pub host: Arc<dyn Host>,+ pub ab: Arc<dyn AbClient>,+ pub settings: Arc<Settings>,+ pub installer: Arc<Installer>,+ pub sessions: Arc<Sessions>,+ pub browsers: Mutex<HashMap<String, Arc<BrowserHandle>>>,+ pub tabstate: events::TabStates,+ /// sid -> tab ids currently carrying focus emulation (2.0.459: was a Session field, and a session clone+ /// re-inserted by another path clobbered it, leaving the active tab unemulated after a re-attach).+ pub focus_emul: Mutex<HashMap<String, Vec<String>>>,+ /// CDP session -> a mouse button pressed whose release has not landed yet (2.0.460).+ pub pending_press: Mutex<HashMap<String, (f64, f64, String)>>,+ /// profile -> consecutive Browser.getVersion misses (2.0.461 wedged-browser watchdog).+ pub browser_stall: Mutex<HashMap<String, u32>>,+ /// profile -> when it last had zero pup windows (2.0.463 idle-browser reaper).+ pub browser_idle_since: Mutex<HashMap<String, u64>>,+ pub bridge_dir: PathBuf,+ pub port: u16,+ pub started: Instant,+ pub ad_version: Mutex<Option<String>>,+ pub self_ref: Mutex<std::sync::Weak<Ctx>>,+ pub toasts: Mutex<Vec<Value>>,+ pub vault: pup_vault::Vault,+ pub cards: pup_vault::Cards,+ pub consent: consent::Registry,+ pub tab_recs: record::TabRecs,+ pub screen_rec: record::ScreenRecState,+ pub highfps: highfps::Leases,+ pub last_audit: Mutex<Option<Value>>,+}++/// Callers that ARE the human at the desk (Node: USER_CALLERS): ownership never applies to them.+pub const USER_CALLERS: [&str; 4] = ["user-taskbar-menu", "user-toolbar", "user-pill", "user-dashboard"];+pub const SHOT_KEEP_DAYS: u64 = 14; pub const SHOT_KEEP_BYTES: u64 = 1_000_000_000;+pub const REC_KEEP_DAYS: u64 = 30; pub const REC_KEEP_BYTES: u64 = 3_000_000_000;++impl Ctx {+ pub fn arc(&self) -> Option<Arc<Ctx>> { self.self_ref.lock().unwrap().upgrade() }+ pub fn new_tabstate() -> events::TabStates { Arc::new(Mutex::new(HashMap::new())) }+ pub fn prefer_cft(&self) -> bool { self.settings.get("toolbarExtension", "on") != "off" }+ /// Screenshots: `<ADOM_BRIDGE_DATA_DIR>/screenshots` when ab passes the data dir (pup-bridge#98), else+ /// beside the exe as before. Retention: see `retention_sweep` (janitor).+ pub fn shots_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("screenshots", self.bridge_dir.join("screenshots"))).clone(); let _ = std::fs::create_dir_all(&d); d }+ pub fn recordings_dir(&self) -> PathBuf { static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| pup_core::data_subdir("recordings", self.bridge_dir.join("recordings"))).clone(); let _ = std::fs::create_dir_all(&d); d }+ /// One retention pass over screenshots (14 d / 1 GB) and recordings (30 d / 3 GB); returns the report.+ pub fn retention_sweep(&self) -> Value {+ let (sn, sb) = pup_core::retention_sweep(&self.shots_dir(), SHOT_KEEP_DAYS, SHOT_KEEP_BYTES);+ let (rn, rb) = pup_core::retention_sweep(&self.recordings_dir(), REC_KEEP_DAYS, REC_KEEP_BYTES);+ if sn + rn > 0 { pup_core::log(format!("[retention] screenshots: {sn} removed ({:.1} MB); recordings: {rn} removed ({:.1} MB)", sb as f64 / 1e6, rb as f64 / 1e6)); }+ json!({ "screenshots": { "removed": sn, "freedBytes": sb, "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "removed": rn, "freedBytes": rb, "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES }, "ts": pup_core::now_ms() })+ }+ pub fn data_dirs(&self) -> Value {+ json!({ "dataDir": pup_core::data_dir().map(|p| p.to_string_lossy().replace('\\', "/")), "profiles": pup_browser::profiles_dir(&*self.host).to_string_lossy().replace('\\', "/"), "sessions": pup_browser::sessions_dir(&*self.host).to_string_lossy().replace('\\', "/"), "screenshots": self.shots_dir().to_string_lossy().replace('\\', "/"), "recordings": self.recordings_dir().to_string_lossy().replace('\\', "/") })+ }+}++/// The walls only a human can pass after a login submit (Node: detectHumanChallenge).+/// The login-wall detector (captcha / one-time code), one script for both lanes.+pub const WALL_JS: &str = r#"(() => { const q = (s) => { try { return document.querySelector(s); } catch (e) { return null; } }; const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };+ const otp = q('input[autocomplete="one-time-code"]') || q('input[name*="otp" i]') || q('input[id*="otp" i]') || q('input[name*="verification" i][inputmode="numeric"]') || q('input[name*="mfa" i]') || q('input[name*="totp" i]') || q('input[name*="2fa" i]');+ if (vis(otp)) return { kind: 'two_factor', evidence: 'one-time-code input: ' + (otp.getAttribute('name') || otp.getAttribute('id') || otp.getAttribute('autocomplete')) };+ const cap = Array.from(document.querySelectorAll('iframe')).find(f => /recaptcha|hcaptcha|turnstile|arkose|funcaptcha/i.test(f.src || '')); if (cap && vis(cap)) return { kind: 'captcha', evidence: 'captcha iframe: ' + cap.src.split('/')[2] };+ const t = (document.body && document.body.innerText || '').slice(0, 20000);+ if (/verification code|two[- ]factor|2-step verification|authenticator app|enter the code we sent|security code|one-time (pass)?code/i.test(t) && !q('input[type="password"]')) return { kind: 'two_factor', evidence: 'page text mentions a verification code and no password field is present' };+ if (/i'?m not a robot|prove you are human|verify you are human|security check/i.test(t)) return { kind: 'captcha', evidence: 'page text asks for a human check' };+ return null; })()"#;+pub fn detect_wall(cdp: &Cdp, sid: &str) -> Option<Value> {+ let js = WALL_JS;+ cdp.evaluate(sid, js, Duration::from_secs(4)).ok().filter(|v| v.is_object())+}++/// Route the events the pump cannot handle alone (bindings, screencast frames) to their owners.+pub fn install_event_hook(ctx: Arc<Ctx>) {+ events::set_hook(Box::new(move |method, sid, params| {+ match method {+ "Runtime.bindingCalled" => {+ let name = params["name"].as_str().unwrap_or(""); let payload = params["payload"].as_str().unwrap_or("").to_string();+ match name {+ "__pupCredCapture" => vault::on_capture(&ctx, sid, &payload),+ "__pupFloatyPush" => { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { let caller = Caller { thread: "user-floaty".into(), reason: "the user clicked the corner pencil".into(), ..Default::default() }; let tab = s.tabs.iter().find(|t| t.cdp_session == sid).map(|t| t.tab_id.clone()); pup_core::log(format!("[floaty] \"{}\" pencil clicked — arming capture", s.id)); let _ = annotate::start(&ctx, &caller, &json!({ "window": s.id, "tabId": tab, "tool": "pen", "takeover": true })); } }+ _ => {}+ }+ }+ "Page.screencastFrame" => { if ctx.sessions.all().iter().all(|s| !s.tabs.iter().any(|t| t.cdp_session == sid)) { static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); if N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 3 { pup_core::log(format!("[screencast] frame for a session no window owns: {sid} (known: {:?})", ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.cdp_session.clone())).collect::<Vec<_>>())); } }+ if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { if let Some(b) = browser_for(&ctx, &s.profile) { record::on_frame(&ctx.tab_recs, &b.cdp, sid, params); } } }+ _ => {}+ }+ }));+}++/// The verbs this build serves natively.+pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_list_sessions", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_close_all_windows", "pup_window_info",+ "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_select", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize",+ "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_clear_cookies", "pup_clear_site_data", "pup_quit_idle_browsers", "pup_rescan", "pup_restart_browser", "pup_set_identity", "pup_audit_icons", "pup_dashboard",+ "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_google_accounts", "pup_google_signin", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click",+ "pup_record_start", "pup_record_stop", "pup_record_status", "pup_record_list", "pup_recorder_open", "pup_recorder_close", "pup_screen_record_start", "pup_screen_record_stop", "pup_screen_record_status", "pup_screen_record_list", "pup_highfps_extend",+ "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_cache_prune", "pup_sweep_identities", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"];++pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {+ let t0 = Instant::now();+ extdrive::leave_profile(); // 2.0.659: every request starts on the default lane; lane_for / open enter another+ let mut r = match verb {+ "pup_describe" => describe(ctx),+ "pup_readiness" => readiness(ctx),+ "pup_prewarm" => prewarm(ctx, args),+ "pup_status" => pup_core::merge(status(ctx), json!({ "extensionLane": extdrive::lane_health(ctx) })),+ "pup_list_windows" => list_windows(ctx),+ "pup_my_windows" | "pup_list_sessions" => my_windows(ctx, caller),+ "pup_open_window" => with_bar(ctx, caller, args, "open", || open_window(ctx, caller, args)),+ "pup_navigate" => with_bar(ctx, caller, args, "navigate", || navigate(ctx, caller, args)),+ "pup_eval" | "pup_evaluate" => eval(ctx, caller, args), // frame: handled inside eval+ "pup_screenshot" => { match resolve(ctx, caller, args) { Ok(s) => { let sid = s.id.clone(); activity::with_progress(ctx, &sid, "screenshot", || screenshot(ctx, caller, args)) } Err(_) => screenshot(ctx, caller, args) } },+ "pup_close_window" | "pup_close" => close_window(ctx, caller, args),+ // adom/pup-bridge#108 class: the verb existed in the contract, erred not_in_rust_yet and named no+ // supported equivalent. close_window already implements {all:true} = the caller's own windows.+ "pup_close_all_windows" => { let mut a = args.clone(); if let Some(m) = a.as_object_mut() { m.insert("all".into(), json!(true)); } close_window(ctx, caller, &a) }+ "pup_window_info" => window_info(ctx, caller, args),+ "pup_open_tab" => with_bar(ctx, caller, args, "open-tab", || tabs::open_tab(ctx, caller, args)),+ "pup_list_tabs" => tabs::list_tabs(ctx, caller, args),+ "pup_switch_tab" => tabs::switch_tab(ctx, caller, args),+ "pup_close_tab" => tabs::close_tab(ctx, caller, args),+ "pup_switch_window" => tabs::switch_window(ctx, caller, args),+ "pup_click" => input::click(ctx, caller, args),+ "pup_hover" => input::hover(ctx, caller, args),+ "pup_type" => input::type_text(ctx, caller, args),+ "pup_press_key" => input::press_key(ctx, caller, args),+ "pup_scroll" => input::scroll(ctx, caller, args),+ "pup_fill" => input::fill(ctx, caller, args),+ "pup_select" => input::select(ctx, caller, args),+ "pup_wait" => input::wait(ctx, caller, args),+ "pup_reload" => with_bar(ctx, caller, args, "navigate", || input::reload(ctx, caller, args)),+ "pup_back" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, true)),+ "pup_forward" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, false)),+ "pup_set_viewport" => input::set_viewport(ctx, caller, args),+ "pup_errors" => input::errors(ctx, caller, args),+ "pup_dialog" => input::dialog(ctx, caller, args),+ "pup_input_dispatch" => input::input_dispatch(ctx, caller, args),+ "pup_render_html" => input::render_html(ctx, caller, args),+ "pup_fetch_url" => input::fetch_url(ctx, caller, args),+ "pup_maximize" => input::maximize(ctx, caller, args),+ "pup_raise_os_window" | "pup_focus_window" => window::raise(ctx, caller, args),+ "pup_lower_os_window" => window::lower(ctx, caller, args),+ "pup_alert_window" => window::alert(ctx, caller, args),+ "pup_progress" => window::progress(ctx, caller, args),+ "pup_configure" => settings::configure(ctx, args),+ "pup_clear_cookies" => clear_cookies(ctx, caller, args),+ "pup_clear_site_data" => clear_site_data(ctx, caller, args),+ "pup_use" => settings::use_browser(ctx, args),+ "pup_quit_idle_browsers" => settings::quit_idle_browsers(ctx),+ "pup_rescan" => recovery::rescan(ctx, args),+ "pup_restart_browser" => extras::restart_browser(ctx, caller, args),+ "pup_set_identity" => identity::set_identity(ctx, caller, args),+ "pup_dashboard" => {+ let human = caller.thread == "user-taskbar-menu" || caller.thread == "user-dashboard";+ let url = format!("http://127.0.0.1:{}/dashboard", ctx.port);+ // The dashboard is a local page; it needs nothing from CDP. It opens like every other pup window: the+ // extension lane on adom-you (John, 2026-09-14: "why does pup_dashboard even need cdp?"). The Node-era+ // pup-dashboard profile is retired.+ let mut a = json!({ "window": "pup-dashboard", "url": url, "takeover": true });+ if human { a["foreground"] = json!(true); a["foregroundReason"] = json!("you clicked \"Open pup dashboard\" in the taskbar menu"); }+ let existed = ctx.sessions.exists("pup-dashboard");+ let r = if existed { navigate(ctx, caller, &a) } else { open_window(ctx, caller, &a) };+ if human { let _ = window::raise(ctx, caller, &json!({ "window": "pup-dashboard", "takeover": true, "foregroundReason": "you clicked \"Open pup dashboard\" in the taskbar menu" })); }+ pup_core::merge(r, json!({ "sessionId": "pup-dashboard", "dashboardUrl": format!("http://127.0.0.1:{}/dashboard", ctx.port), "reused": existed, "foregrounded": human, "_hint": if human { "The pup dashboard was raised because a human clicked the taskbar task." } else { "The pup dashboard is open in its own pup window (session pup-dashboard), BACKGROUNDED like any pup window; raise it with pup_raise_os_window {foregroundReason} if the user asked to see it." } }))+ }+ "pup_audit_icons" => identity::audit(ctx),+ "pup_credentials" | "credential_list" => vault::credentials(ctx),+ "credential_set" => vault::credential_set(ctx, args),+ "credential_delete" => vault::credential_delete(ctx, args),+ "pup_login" => vault::login(ctx, caller, args),+ "pup_credential_reveal" => vault::reveal(ctx, caller, args),+ "pup_card_set" => vault::card_set(ctx, args),+ "pup_cards" => vault::cards(ctx),+ "pup_card_delete" => vault::card_delete(ctx, args),+ "pup_fill_card" => vault::fill_card(ctx, caller, args),+ "pup_wiki_set_view" => wiki::set_view(ctx, caller, args),+ "pup_annotate" => annotate::start(ctx, caller, args),+ "pup_floaty_click" => annotate::floaty_click(ctx, caller, args),+ "pup_record_start" => record::start(ctx, caller, args),+ "pup_record_stop" => record::stop(ctx, caller, args),+ "pup_record_status" => record::status(ctx, caller, args),+ "pup_record_list" => record::list(ctx),+ "pup_recorder_open" => record::recorder_open(ctx, args),+ "pup_recorder_close" => record::recorder_close(ctx),+ "pup_screen_record_start" => record::screen_start(ctx, args),+ "pup_screen_record_stop" => record::screen_stop(ctx),+ "pup_screen_record_status" => record::screen_status(ctx),+ "pup_screen_record_list" => record::list(ctx),+ "pup_highfps_extend" => highfps::extend(ctx, caller, args),+ "pup_screenshot_element" => extras::screenshot_element(ctx, caller, args),+ "pup_screenshot_full_res" => extras::screenshot_full_res(ctx, caller, args),+ "pup_download" => extras::download(ctx, caller, args),+ "pup_launch" => extras::launch(ctx, caller, args),+ "pup_deps" => extras::deps(ctx),+ "pup_install_toolbar" => extras::install_toolbar(ctx),+ "pup_reap_profiles" => extras::reap_profiles(ctx, args),+ "pup_cache_prune" => extras::cache_prune(ctx, args.get("dryRun").and_then(|v| v.as_bool()).unwrap_or(false)),+ "pup_sweep_identities" => { let r = identity::sweep_dead_identities(ctx, "verb"); ok(pup_core::merge(r, json!({ "_hint": "Unregistered every pup taskbar identity (its registry key and any Start Menu anchor) that no live window holds, and removed icon generations nothing references. pup runs this a minute after every boot and releases each window's identity on close (2.0.666)." }))) },+ "pup_uninstall" => extras::uninstall(ctx, args),+ "pup_move_to_workspace" => extras::move_to_workspace(ctx, caller, args),+ "pup_assisted_click" => assist::assisted_click(ctx, caller, args),+ "pup_import_scan" => import::import_scan(ctx),+ "pup_google_accounts" => google::google_accounts(ctx, caller, args),+ "pup_google_signin" => google::google_signin(ctx, caller, args),+ "pup_import_browser_logins" => import::import_browser_logins(ctx, caller, args),+ "pup_vault_sync" => vault::vault_sync(ctx, caller, args),+ "pup_vault_export" => vault::vault_export(ctx, caller),+ "pup_vault_import" => vault::vault_import(ctx, caller, args),+ v if v.starts_with("desktop_record") || v.starts_with("desktop_recorder") => err("wrong_bridge", format!("{v} is ab-core's native desktop recorder (ab 2.1.36+), not a pup verb; this ab routed it to pup by a stale prefix claim (adom-bridge#150)"), Some("Update Adom Bridge so desktop_record_* routes to ab-core: its native recorder (GPU-accelerated, GPU frame offload when available, system audio, MP4) is the one to use for desktop recording. Until then pup's fallback works, a Google Meet style screen share that runs almost anywhere: pup_recorder_open {reason} then pup_screen_record_start {confirmDesktopNotTabRecording:true, reason}.")),+ v if v.starts_with("pup_") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),+ v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),+ };+ // 2.0.672 (pup-bridge#124): ab's screenshot delivery contract is fullPath (+ safePath, the <=1568 px copy). When a+ // reply carries those, ab's CLI pulls the image to the caller and adds localPath/localSafePath, so a container AI can+ // Read it without a second call. pup answered with filePath only, so nothing was pulled. Every image reply now+ // carries both keys (filePath stays for older callers). The image pup writes is already the shrunk copy when needed.+ image_delivery(&mut r);+ // lastAgentUpdate {tabId, verb, note, ts}: every driving verb that names a window (Node: touch sites)+ if !PROBE_VERBS.contains(&verb) && !PASSIVE_VERBS.contains(&verb) {+ if let Some(sid) = r.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) {+ let tab = r.get("tabId").and_then(|v| v.as_str()).map(|s| s.to_string());+ ctx.sessions.with_mut(&sid, |x| { x.last_verb = Some(verb.to_string()); if tab.is_some() { x.last_tab = tab; } x.last_agent_update_ms = pup_core::now_ms(); });+ }+ }+ if let Some(o) = r.as_object_mut() {+ // 2.0.672: many handlers put their real fields only in the `output` JSON string. ab used to lift those onto+ // the reply; ab 2.1.14x stopped (measured 2026-09-22: "ok" gone from every error reply, and pup_record_stop's+ // durationMs became the 615 ms CALL time instead of the 8 s recording, failing the smoke). pup lifts them itself+ // now, never overwriting a top-level key, and BEFORE the call time is stamped.+ if let Some(inner) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).and_then(|s| serde_json::from_str::<Value>(s).ok()) {+ if let Some(io) = inner.as_object() { for (k, v) in io { if !o.contains_key(k) { o.insert(k.clone(), v.clone()); } } }+ }+ // and every reply says ok true/false itself (the contract fixtures expect it; ab used to add it)+ if !o.contains_key("ok") {+ let failed = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);+ o.insert("ok".into(), json!(!failed));+ }+ o.entry("durationMs").or_insert(json!(t0.elapsed().as_millis() as u64)); o.insert("shell".into(), json!("rust"));+ // Node sendJSON: the catalog's canonical hint/related/next/pitfalls when the handler set none+ if let Some(m) = catalog_meta(verb) {+ if !o.contains_key("_hint") { if let Some(h) = m["hint"].as_str() { o.insert("_hint".into(), json!(h)); } }+ if !o.contains_key("related") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("related".into(), m["related"].clone()); } }+ if !o.contains_key("_next") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("_next".into(), m["related"].clone()); } }+ if !o.contains_key("pitfalls") { if m["pitfalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("pitfalls".into(), m["pitfalls"].clone()); } }+ }+ if !PROBE_VERBS.contains(&verb) && !o.contains_key("_cleanupReport") { if let Some(rep) = drain_cleanup_reports() { o.insert("_cleanupReport".into(), json!(rep)); } }+ // Node maybeAttachBugInvite: every failure carries the invite; substantive successes carry it once per process, then every 40th+ if ctx.settings.get("reportIssues", "on") != "off" && !o.contains_key("_reportIssues") {+ let is_error = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);+ if is_error { o.insert("_reportIssues".into(), json!(bug_report_invite(true))); }+ else if !PROBE_VERBS.contains(&verb) {+ let mut g = BUG_INVITE.lock().unwrap(); g.1 += 1;+ if !g.0 || g.1 % BUG_INVITE_EVERY == 0 { g.0 = true; o.insert("_reportIssues".into(), json!(bug_report_invite(false))); }+ }+ }+ // Session-scoped notices (Node: attachSessionNotices): an emulated viewport warning on+ // measurement verbs, and a one-shot disruption notice. Resolve the session from output or the envelope.+ {+ let sid = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string())+ .or_else(|| o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|v| v["sessionId"].as_str().map(|s| s.to_string())));+ if let Some(sid) = sid { if let Some(sess) = ctx.sessions.get(&sid) {+ const VP_SENSITIVE: [&str; 8] = ["pup_screenshot", "pup_screenshot_full_res", "pup_eval", "pup_list_windows", "pup_my_windows", "pup_status", "pup_navigate", "pup_reload"];+ if VP_SENSITIVE.contains(&verb) && !o.contains_key("_viewportOverride") {+ if let Some(ev) = &sess.emulated { let w = ev["width"].as_u64().unwrap_or(0); let h = ev["height"].as_u64().unwrap_or(0);+ if w > 0 { let outer = sess.last_outer_width;+ let shape = match outer { Some(ow) if w > ow + 4 => format!(" The emulation is WIDER than the OS window ({w} vs {ow}), so the user is seeing this page CROPPED: about {}px is off the right edge.", w - ow), Some(ow) if w + 4 < ow => format!(" The emulation is NARROWER than the OS window ({w} vs {ow}), so the user sees dead space beside the page."), _ => String::new() };+ o.insert("_viewportOverride".into(), json!(format!("This window has an EMULATED VIEWPORT of {w}x{h}{}, so it is NOT tracking the real OS window.{shape} Anything you measure in the page (innerWidth, a bounding rect) describes the emulation, not what the user sees. Clear it with pup_set_viewport {{\"reset\":true}} when done.", ev["deviceScaleFactor"].as_f64().filter(|d| *d != 1.0).map(|d| format!(" @{d}x")).unwrap_or_default())));+ }+ }+ }+ if !o.contains_key("_sessionNotice") { if let Some(n) = &sess.disruption_notice { o.insert("_sessionNotice".into(), json!(n)); ctx.sessions.with_mut(&sid, |x| x.disruption_notice = None); } }+ } }+ }+ // render verbs: the verdict rides the response (Node: RENDER_VERBS block in sendJSON)+ if RENDER_VERBS.contains(&verb) && o.get("ok") != Some(&json!(false)) && o.get("success") != Some(&json!(false)) {+ if let Some(rc) = o.get("renderCheck").cloned() { if rc["ok"] == json!(false) { if let Some(sig) = rc["signals"].as_array() { if !sig.is_empty() {+ let st = rc["httpStatus"].as_u64().map(|s| format!(" (HTTP {s})")).unwrap_or_default();+ let lead = format!("â›” THE PAGE IS STILL BROKEN: {}{st}{}. Do NOT report success. See _verifyRender. ", sig.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "), rc["excerpt"].as_str().map(|e| format!(" — it says \"{}\"", e.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(120).collect::<String>())).unwrap_or_default());+ let cur = o.get("_hint").and_then(|h| h.as_str()).unwrap_or("").to_string();+ let fb = rc["fallbackHint"].as_str().map(|h| format!("{h} ")).unwrap_or_default();+ if !cur.contains("STILL BROKEN") && !cur.contains("IS BROKEN") { o.insert("_hint".into(), json!(format!("{lead}{fb}{cur}"))); }+ } } } }+ if !o.contains_key("_verifyRender") { o.insert("_verifyRender".into(), json!(VERIFY_RENDER)); }+ if !o.contains_key("_urlLint") {+ let u = o.get("url").and_then(|v| v.as_str()).or_else(|| args.get("url").and_then(|v| v.as_str())).unwrap_or("");+ if let Some(l) = lint::url_lint(u, &caller.container) { o.insert("_urlLint".into(), l["message"].clone()); o.insert("urlLintCode".into(), l["code"].clone()); }+ }+ }+ if let Some(sid) = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) { if let Some(h) = ctx.sessions.with_mut(&sid, |x| x.pending_cred_hint.take()).flatten() { o.insert("_credentialCaptured".into(), json!(h)); } }+ // THE ab ENVELOPE (adom-bridge cli/src/relay.rs browser_command, measured at the 2.0.402 candidate+ // install): ab computes success = bridge.success (absent = false) and output = bridge.output (absent+ // = ""); the CLI then RETURNS THE PARSED `output` ALONE when success is true, and collapses a+ // non-JSON output to {success, output}. So every success carries success:true and an `output`+ // that is the JSON of the WHOLE payload, exactly as the Node build's sendJSON sites did.+ let is_err = o.get("success") == Some(&json!(false)) || o.get("ok") == Some(&json!(false)) || o.contains_key("errorCode");+ if !is_err {+ o.insert("success".into(), json!(true));+ if !o.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) {+ let mut inner = o.clone(); inner.remove("output");+ o.insert("output".into(), json!(Value::Object(inner).to_string()));+ }+ } else { o.entry("success").or_insert(json!(false)); o.entry("output").or_insert(json!("")); }+ // Node v1.9.98: ab/the CLI unwrap `output` and drop the envelope on success, so the advisory+ // fields have to live INSIDE output too (never overwriting what the handler put there).+ if let Some(inner_s) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).map(|s| s.to_string()) {+ if let Ok(mut inner) = serde_json::from_str::<Value>(&inner_s) { if let Some(i) = inner.as_object_mut() {+ let sid = i.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()).or_else(|| o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()));+ if let Some(sid) = sid { if !i.contains_key("window") { i.insert("window".into(), json!(sid)); if !i.contains_key("aiThread") { if let Some(owner) = ctx.sessions.get(&sid).and_then(|x| x.owner) { i.insert("aiThread".into(), json!(owner)); } } } }+ for k in ["_reportIssues", "_cleanupReport", "_autoLogin", "_credentialCaptured", "_hint", "_next", "related", "pitfalls", "renderCheck", "_verifyRender"] { if let Some(v) = o.get(k) { if !i.contains_key(k) { i.insert(k.into(), v.clone()); } } }+ o.insert("output".into(), json!(inner.to_string()));+ } }+ }+ }+ r+}++// ── resolution: window | sessionId | the caller's own window ───────────────────────────++fn window_arg(args: &Value) -> Option<String> {+ for k in ["window", "sessionId", "session", "ai-thread", "aiThread"] { if let Some(s) = args.get(k).and_then(|v| v.as_str()) { if !s.trim().is_empty() { return Some(if k == "ai-thread" || k == "aiThread" { thread_default_session_id(s) } else { s.trim().to_string() }); } } }+ None+}++/// The pup window and tab showing `url`: exact URL first, then equal ignoring the query, then the+/// longest prefix match ignoring the query (the popup's own rule). None when no pup tab shows it.+pub fn session_for_tab_url(ctx: &Ctx, url: &str) -> Option<(Session, Tab)> {+ let base = |u: &str| u.split('?').next().unwrap_or("").trim_end_matches('/').to_string();+ let want = base(url);+ // 2.0.471: a window whose browser is connected wins over one whose browser is gone, and among those the most+ // recently driven wins (a leftover session at the same page used to take the toolbar pen: annotate_inject_failed).+ // 2.0.472: the FOREGROUND window wins outright: a toolbar/jump-list click comes from the window the user is+ // looking at, and its payload carries only the tab URL (winvm ralph: the pen landed on a recovered background+ // window's tab at the same page and failed to inject there).+ let fg = ctx.host.foreground();+ let mut all = ctx.sessions.all();+ all.sort_by_key(|s| (!(fg != 0 && s.hwnd == Some(fg)), !transport::lane_for(ctx, s).is_ok(), std::cmp::Reverse(s.last_agent_update_ms)));+ for s in &all { if let Some(t) = s.tabs.iter().find(|t| t.url == url) { return Some((s.clone(), t.clone())); } }+ for s in &all { if let Some(t) = s.tabs.iter().find(|t| !want.is_empty() && base(&t.url) == want) { return Some((s.clone(), t.clone())); } }+ let mut best: Option<(usize, Session, Tab)> = None;+ for s in &all { for t in &s.tabs { let b = base(&t.url); if b.len() > 12 && want.starts_with(&b) && best.as_ref().map(|x| b.len() > x.0).unwrap_or(true) { best = Some((b.len(), s.clone(), t.clone())); } } }+ best.map(|(_, s, t)| (s, t))+}++/// 2.0.699: the taskbar progress bar on the window a page-loading verb works in. Only pup_screenshot was ever+/// wrapped here; the extension-lane wrapper sat on a route these verbs no longer take, so open, navigate, new+/// tab, reload, back and forward painted nothing (John, 2026-10-05). A window with no handle yet (being born)+/// gets no bar; start_icon_apply covers its first seconds.+fn with_bar(ctx: &Ctx, caller: &Caller, args: &Value, op: &str, work: impl FnOnce() -> Value) -> Value {+ let r = match resolve(ctx, caller, args) { Ok(s) => activity::with_progress(ctx, &s.id, op, work), Err(_) => work() };+ wall_check(ctx, caller, args, r)+}++/// 2.0.712: say when a page load landed on a bot wall, and warn before a thread trips one. A Cloudflare challenge+/// page used to come back as an ordinary successful load, so a thread kept loading DigiKey pages 7 s apart and dug+/// the wall deeper (John's ECAS window, 2026-10-11). Signatures come from the electronics-sites skillpack.+const BOT_WALL_JS: &str = r#"(()=>{const t=(document.title||'').slice(0,200);const b=((document.body&&document.body.innerText)||'').slice(0,3000);const short=b.length<1500;+if(/just a moment|performing security verification|checking your browser/i.test(t)||(short&&/performing security verification|verify you are human/i.test(b))||document.querySelector('iframe[src*="challenges.cloudflare.com"]'))return 'Cloudflare';+if(/access to this page has been denied/i.test(t+' '+b)||/press (&|and) hold/i.test(b)||document.querySelector('#px-captcha,[id^="px-captcha"]'))return 'PerimeterX';+if(/^access denied/i.test(t)&&short)return 'Akamai';+if(document.querySelector('iframe[src*="captcha-delivery.com"]'))return 'DataDome';+if(short&&/verify you are a human|are you a robot|unusual traffic/i.test(b))return 'bot check';+return ''})()"#;+const PACED_HOSTS: [&str; 8] = ["digikey.", "mouser.", "arrow.com", "ti.com", "analog.com", "st.com", "octopart.com", "newark.com"];+static LAST_LOAD: std::sync::Mutex<Option<HashMap<String, u64>>> = std::sync::Mutex::new(None);++fn wall_check(ctx: &Ctx, caller: &Caller, args: &Value, mut r: Value) -> Value {+ if r["ok"] == json!(false) || r["success"] == json!(false) { return r; }+ let Ok(s) = resolve(ctx, caller, args) else { return r };+ let Some(t) = s.active().cloned() else { return r };+ let host = t.url.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").to_lowercase();+ // pacing: a second load on a walled vendor within 15 s, by any thread on this desktop+ if let Some(key) = PACED_HOSTS.iter().find(|h| host.contains(**h)) {+ let now = pup_core::now_ms();+ let mut g = LAST_LOAD.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);+ if let Some(prev) = m.insert(key.to_string(), now) {+ let gap = now.saturating_sub(prev);+ if gap < 15_000 { r["_paceWarning"] = json!(format!("{host} was loaded {} s ago. This site walls automated browsers that load pages in quick succession; wait at least 15 s between pages here, and get stock, price and datasheets through adom-parts-search instead of loading product pages.", gap / 1000)); }+ }+ }+ let Ok(lane) = transport::lane_for(ctx, &s) else { return r };+ let vendor = transport::evaluate(&lane, &t, BOT_WALL_JS).ok().and_then(|v| v.as_str().map(|x| x.to_string()).or_else(|| v["value"].as_str().map(|x| x.to_string()))).unwrap_or_default();+ if vendor.is_empty() { return r; }+ pup_core::log(format!("[wall] \"{}\": {host} answered with a {vendor} bot check ({})", s.id, t.url.chars().take(100).collect::<String>()));+ r["botWall"] = json!({ "vendor": vendor, "host": host, "url": t.url });+ r["_hint"] = json!(format!("BOT WALL: {host} answered with a {vendor} bot check instead of the page. Do NOT reload or load more pages from {host} now; that makes it worse. For parts data (stock, price, datasheet) use adom-parts-search. If you need this page itself: ask the user for ONE click on the check in this window (pup_alert_window or pup_raise_os_window with a reason), or open it in the user's own browser with nb. Never try to solve the check yourself."));+ r+}++pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {+ let explicit = window_arg(args);+ let id = match explicit.clone() {+ Some(id) => id,+ None => {+ if caller.thread.is_empty() {+ return Err(err("caller_identity_required", "no window given and no --ai-thread to key one on", Some("Pass --ai-thread <your thread name> (the CLI flag) so pup can resolve your own window, or name one with {window:\"...\"}.")));+ }+ // 2.0.453 (John, woke from sleep, toolbar pen did nothing): the Adom toolbar identifies its window by+ // the TAB URL (`_tabUrl`, Node's contract); the Rust port never resolved it, so a pen click landed+ // here with thread "user-toolbar" and no window. Any tab in any pup window matches, exact first.+ // 2.0.702: the browser's own tab id first. A page that changes its address in place (Slack, Gmail, YouTube)+ // no longer matched pup's recorded URL, so the toolbar pen resolved no window and did nothing (John's Slack+ // window, 2026-10-07). The id never changes.+ if let Some(tid) = args.get("_tabId").and_then(|v| v.as_i64()).map(|v| v.to_string()) {+ if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.target_id == tid)) { return Ok(s); }+ }+ if let Some((s, _)) = args.get("_tabUrl").and_then(|u| u.as_str()).and_then(|u| session_for_tab_url(ctx, u)) { return Ok(s); }+ // last resort for a human click: the pup window in front is the one they clicked in+ if args.get("_tabUrl").is_some() || args.get("_tabId").is_some() {+ let fg = ctx.host.foreground();+ if fg != 0 { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.hwnd == Some(fg)) { return Ok(s); } }+ }+ let d = thread_default_session_id(&caller.thread);+ if ctx.sessions.exists(&d) { d } else {+ let mine = ctx.sessions.owned_by(&caller.thread);+ if mine.len() == 1 { mine[0].id.clone() } else { return Err(err("no_window", format!("thread \"{}\" has no pup window", caller.thread), Some("pup_open_window {url} first; later verbs with no ids resolve to that window. pup_my_windows {} lists what you own."))); }+ }+ }+ };+ match ctx.sessions.get(&id) {+ Some(s) => {+ // Ownership guard (Node: ownershipRefusal): another thread's window needs takeover:true.+ // A HUMAN caller (jump-list task, toolbar button, dashboard click) is exempt, as in Node's+ // USER_CALLERS: the user owns every window. Missing in the Rust port until 2.0.424, so every+ // window-targeting jump-list task answered not_your_window (John, 2026-09-06).+ if let Some(owner) = s.owner.as_deref() { if !caller.thread.is_empty() && !USER_CALLERS.contains(&caller.thread.as_str()) && owner != caller.thread {+ if args.get("takeover").and_then(|t| t.as_bool()) != Some(true) {+ return Err(pup_core::merge(err("not_your_window", format!("window \"{}\" belongs to thread \"{}\", not you (\"{}\")", id, owner, caller.thread),+ Some("Open your OWN window instead: pup_open_window with NO window id gives your thread its own. Pass takeover:true ONLY if the USER explicitly told you to repurpose this window — every takeover is logged with both thread names.")), json!({ "owner": owner, "window": id })));+ }+ // 2.0.491: LOG every sanctioned takeover, both thread names (Node did this; the Rust port+ // dropped it, so a thread could drive another thread's window all day leaving no trace).+ pup_core::log(format!("[takeover] \"{}\": thread \"{}\" acted on \"{}\"'s window (takeover:true — sanctioned only when the user asked). Reason: {}",+ id, caller.thread, owner, if caller.reason.is_empty() { "(none given)" } else { &caller.reason }));+ } }+ Ok(s)+ }+ None => Err(pup_core::merge(err("session_not_found", format!("no pup window named \"{id}\""), Some("pup_list_windows {} shows what is open; pup_open_window creates one.")), json!({ "window": id }))),+ }+}++pub fn touch(ctx: &Ctx, sid: &str, note: Option<&str>) { ctx.sessions.with_mut(sid, |x| { x.last_agent_update_ms = pup_core::now_ms(); if let Some(n) = note { x.last_note = Some(n.to_string()); } }); }++/// Close top-level about:blank page targets nobody tracks: the initial window an older pup build+/// launched Chrome with (it never got a session, an identity or a close), which shows up as a stock+/// Chrome for Testing button on the taskbar. Runs once per browser connect, BEFORE the handle is+/// published, so no verb can be creating a target in the gap. Popups (openerId) and every target a+/// session owns are never touched.+fn close_orphan_blank_targets(ctx: &Ctx, cdp: &Cdp, profile: &str) {+ // Orphans: (1) the about:blank launch window an older build left behind, and (2) whole WINDOWS no+ // session owns any more, left by a close that raced a bridge respawn (John's laptop, 2026-09-06:+ // three leftover windows wearing old tiles). A window is orphaned only when NONE of its tabs belongs+ // to a session, so a user's extra tab in a live pup window is never touched; popups never are.+ let Ok(targets) = cdp.targets() else { return };+ let owned: std::collections::HashSet<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();+ let pages: Vec<&Value> = targets.iter().filter(|t| t["type"] == json!("page") && !t.get("openerId").and_then(|o| o.as_str()).map(|o| !o.is_empty()).unwrap_or(false)).collect();+ let mut win_of: HashMap<String, i64> = HashMap::new();+ for t in &pages { if let Some(tid) = t["targetId"].as_str() { if let Ok((wid, _)) = cdp.window_for_target(tid) { win_of.insert(tid.to_string(), wid); } } }+ let owned_windows: std::collections::HashSet<i64> = win_of.iter().filter(|(tid, _)| owned.contains(*tid)).map(|(_, w)| *w).collect();+ let mut closed = 0;+ for t in &pages {+ let tid = t["targetId"].as_str().unwrap_or(""); if tid.is_empty() || owned.contains(tid) { continue; }+ let blank = t["url"].as_str().map(|u| u == "about:blank" || u.is_empty()).unwrap_or(false);+ let orphan_window = win_of.get(tid).map(|w| !owned_windows.contains(w)).unwrap_or(false);+ if blank || orphan_window { if cdp.close_target(tid).is_ok() { closed += 1; pup_core::log(format!("[launch] closed orphan {} in profile \"{profile}\": {}", if blank { "about:blank window" } else { "window no session owns" }, t["url"].as_str().unwrap_or("").chars().take(60).collect::<String>())); } }+ }+ if closed > 0 { pup_core::log(format!("[launch] {closed} orphan window(s) closed in profile \"{profile}\"")); }+}++/// Does the extension lane currently hold this profile directory? True only while a lane browser is+/// actually alive, so a dead lane never blocks a CDP open.+pub fn ext_lane_owns_profile(ctx: &Ctx, profile: &str) -> bool {+ profile == "adom-you" && !extdrive::st().token.lock().unwrap().is_empty() && extdrive_browser_alive(ctx)+}++pub fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }++/// Get or launch the browser for a profile: reuse a live DevTools port, else spawn-verify each+/// candidate in order (Node: getOrLaunchBrowser).+fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -> Result<Arc<BrowserHandle>, Value> {+ set_target_chrome_from_host(&*ctx.host); // learn the installed consumer Chrome version once, before any attach+ Cdp::quiet_runtime(ctx.settings.get("cdpQuiet", "off") == "on"); // pup-bridge#109: skip the Runtime.enable leak+ pup_browser::set_stock_flags(ctx.settings.get("stockFlags", "off") == "on"); // pup-bridge#109: near-stock launch flags+ if let Some(b) = browser_for(ctx, profile) { return Ok(b); }+ if ext_lane_owns_profile(ctx, profile) {+ return Err(err("profile_owned_by_ext_lane",+ format!("the extension lane is running on profile \"{profile}\", and Chrome allows only one process per profile directory"),+ Some("This lane deliberately runs with NO debug port, which is what lets vendor sites load. Drive it with pup_open_window / pup_navigate / pup_eval, or close it with pup_close_window first if you specifically need CDP.")));+ }+ let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);+ if let Some(port) = pup_browser::existing_port(&profile_dir) {+ if let Ok(cdp) = Cdp::connect_port(port, Duration::from_secs(5)) {+ let kind = cdp.version().ok().and_then(|v| v.get("product").and_then(|p| p.as_str()).map(|s| s.to_string())).unwrap_or_default();+ let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| kind.contains("Chrome") && (c.kind == "chrome-for-testing" || c.kind == "chrome") || kind.contains("Edg") && c.kind == "edge").unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: PathBuf::new(), build_id: None });+ pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));+ let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;+ let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });+ if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }+ close_orphan_blank_targets(ctx, &h.cdp, profile);+ events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());+ ctx.host.watch_process(h.launched.pid);+ ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());+ return Ok(h);+ }+ }+ let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+ if cands.is_empty() {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ if rd.get("lowDisk").and_then(|b| b.as_bool()) == Some(true) {+ return Err(pup_core::merge(err("chrome_install_no_disk", format!("No Chrome/Edge on this machine and only {} MB free — not enough to install Chrome for Testing (~800 MB needed).", rd.get("diskFreeMb").cloned().unwrap_or(Value::Null)), Some("Free up disk space, or install Chrome or Edge, then retry.")), json!({ "readiness": rd })));+ }+ ctx.installer.ensure_background(ctx.host.clone());+ return Err(pup_core::merge(err("chrome_for_testing_installing", "This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).", Some("LAST-RESORT PROVISIONING (not an error): poll pup_readiness {} until ready:true (it shows percent + ETA), then call pup_open_window again.")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness", "readiness": rd })));+ }+ pup_core::log(format!("[launch] {} browser candidate(s): {}", cands.len(), cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>().join(" -> ")));+ let mut errors = Vec::new();+ for c in &cands {+ let t0 = Instant::now();+ let exts = pup_browser::stage_extensions(&*ctx.host, ctx.port, profile, ctx.prefer_cft());+ match pup_browser::launch(&*ctx.host, c, &profile_dir, high_fps, insecure_dev, &exts) {+ Ok(l) => match Cdp::connect_port(l.cdp_port, Duration::from_secs(10)) {+ Ok(cdp) => {+ pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));+ pup_browser::write_default(&*ctx.host, c, false);+ let h = Arc::new(BrowserHandle { cdp, launched: l });+ // pup-bridge#109: real Chrome pre-grants NOTHING (every permission answers "prompt"); pup used+ // to grant 32 up front, and a bot manager reads Notification.permission=="granted" on a first+ // visit as automation. prePermissions:on restores the blanket grant for a debug flow.+ if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }+ close_orphan_blank_targets(ctx, &h.cdp, profile);+ events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());+ ctx.host.watch_process(h.launched.pid);+ ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());+ return Ok(h);+ }+ Err(e) => { errors.push(format!("{}: connect {e}", c.kind)); let _ = ctx.host.kill(l.pid); }+ },+ Err(e) => errors.push(format!("{}: {e}", c.kind)),+ }+ pup_core::log(format!("[launch] {} failed, falling through: {}", c.kind, errors.last().cloned().unwrap_or_default()));+ }+ Err(err("browser_launch_failed", format!("every browser candidate failed to launch: {}", errors.join(" | ")), Some("pup_readiness {} shows the candidates and the last install error; pup_use can pin a different browser.")))+}++/// Which tabs carry focus emulation (Emulation.setFocusEmulationEnabled). It is what lets pup drive a window+/// parked BEHIND the user's windows: the page keeps reporting focus, so CDP input lands and focus events fire.+/// Until 2.0.458 every attached tab carried it, and a tab that reports focus also reports VISIBLE, so Chrome+/// never throttled its timers and Memory Saver never discarded it. Measured on John's laptop, 2026-09-10: a+/// 21-tab pup window ran EVERY background tab's timers at full rate (60 setTimeout(16ms) fires per second where+/// a background tab should get about 1), held 2.2 GB that Chrome could not reclaim, and burned 5.2 % CPU against+/// 0.2 % for his other Chrome, while the machine sat at 260 MB free of 32 GB and scrolling stuttered.+/// Now: the window's ACTIVE tab (pup must be able to drive it), plus at most the one tab a verb is driving.+pub fn focus_emulate(ctx: &Ctx, sid: &str, extra: Option<&str>) {+ let Some(s) = ctx.sessions.get(sid) else { return };+ let Some(b) = browser_for(ctx, &s.profile) else { return };+ let mut want: Vec<String> = s.active().map(|t| t.tab_id.clone()).into_iter().collect();+ if let Some(x) = extra { if !want.iter().any(|w| w == x) { want.push(x.to_string()); } }+ let have = ctx.focus_emul.lock().unwrap().get(sid).cloned().unwrap_or_default();+ if want == have { return; }+ for t in &s.tabs {+ let on = want.iter().any(|w| *w == t.tab_id);+ if on != have.iter().any(|w| *w == t.tab_id) { b.cdp.set_focus_emulation(&t.cdp_session, on); }+ }+ ctx.focus_emul.lock().unwrap().insert(sid.to_string(), want);+}++/// Is this tab currently focus-emulated? (Reporting only; never perturbs the state it reports.)+pub fn focus_emulated(ctx: &Ctx, sid: &str, tab_id: &str) -> bool {+ ctx.focus_emul.lock().unwrap().get(sid).map(|v| v.iter().any(|w| w == tab_id)).unwrap_or(false)+}++/// Re-apply the focus-emulation policy to every session (after boot recovery or a reconnect).+pub fn focus_emulate_all(ctx: &Ctx) { for s in ctx.sessions.all() { focus_emulate(ctx, &s.id, None); } }++/// What this window costs and what the machine has left, reported to the CALLING AI on every open and every+/// survey. John, 2026-09-11, after a thread opened twenty wiki component pages at roughly 200 MB apiece and+/// put his laptop into thrashing: "if the ai knew how much ram it was using, that might have helped the ai+/// decide. and i suppose you should be automatically telling it what % of ram was even left on the box."+/// browserMb is the whole Chrome process tree for this profile (renderers and the GPU process included),+/// which is the number that actually moves when a thread opens a page; avgMbPerTab is that divided by the+/// tabs it is carrying, so a caller can price its next tab before opening it.+pub fn memory_report(ctx: &Ctx, profile: Option<&str>) -> Value {+ let (total, avail) = match ctx.host.memory_status() { Some(x) => x, None => return Value::Null };+ let free_pct = if total > 0 { (avail as f64 / total as f64 * 100.0).round() as u64 } else { 0 };+ let mut v = json!({ "boxTotalMb": total, "boxFreeMb": avail, "boxFreePct": free_pct });+ if let Some(p) = profile {+ if let Some(b) = browser_for(ctx, p) {+ if let Some(mb) = ctx.host.process_tree_rss_mb(b.launched.pid) {+ let tabs: usize = ctx.sessions.for_profile(p).iter().map(|s| s.tabs.len()).sum();+ v["browserMb"] = json!(mb);+ v["tabsOnThisBrowser"] = json!(tabs);+ if tabs > 0 { v["avgMbPerTab"] = json!(mb / tabs as u64); }+ }+ }+ }+ if v.get("tabsOnThisBrowser").and_then(|x| x.as_u64()) == Some(0) {+ let mins = ctx.settings.get("idleBrowserQuitMin", "10");+ v["idleBrowser"] = json!(true);+ v["idleNote"] = json!(if mins == "0" { "This browser has no pup windows and idleBrowserQuitMin is 0, so pup will keep it running.".to_string() }+ else { format!("This browser has no pup windows: pup closes it after {mins} min idle and gives the RAM back. The next open relaunches it, still signed in.") });+ }+ let per = v.get("avgMbPerTab").and_then(|x| x.as_u64());+ v["_hint"] = json!(match (free_pct, per) {+ (f, Some(p)) if f < 10 => format!("Only {f}% of this machine's RAM is free and each pup tab here is costing about {p} MB. Do not open more pages until you close some: at this level Windows is paging to disk and the user's whole desktop stutters. pup_close_tab / pup_close_window on what you are done with."),+ (f, None) if f < 10 => format!("Only {f}% of this machine's RAM is free. Do not open more pages until you close some: at this level Windows is paging to disk and the user's whole desktop stutters."),+ (f, Some(p)) if f < 25 => format!("{f}% of this machine's RAM is free and each pup tab here is costing about {p} MB. Budget accordingly and close tabs you are done with; this is the user's own laptop."),+ (f, None) if f < 25 => format!("{f}% of this machine's RAM is free. Budget accordingly and close pages you are done with; this is the user's own laptop."),+ (f, Some(p)) => format!("{f}% of this machine's RAM is free; each pup tab here is costing about {p} MB. Close tabs you are done with so it stays that way."),+ (f, None) => format!("{f}% of this machine's RAM is free."),+ });+ v+}++pub fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {+ // 2.0.455: idempotent. A target already in this session is returned as is (the rescan adopted one tab twice+ // because its "tracked" list was built once up front).+ if let Some(t) = s.tabs.iter().find(|t| t.target_id == target_id) { return Ok(t.tab_id.clone()); }+ let sid = cdp.attach(target_id).map_err(|e| err("cdp_attach_failed", e.to_string(), None))?;+ apply_consumer_identity(cdp, &sid); // pup-bridge#109: consumer Google Chrome identity + EME shim, before any navigation+ // 2.0.455: the page-domain setup (Page.enable, the two bindings, the login-capture script) runs on a helper+ // thread. A tab Chrome DISCARDED (Memory Saver after a sleep) has no renderer, so each of those calls sat+ // out its full timeout and one adoption took 30 s (measured on AdomLapper, 2026-09-10). The tab is usable+ // for resolution and identity at once; the setup lands when the renderer answers.+ let (cdp2, sid2) = (cdp.clone(), sid.clone());+ std::thread::Builder::new().name("tab-setup".into()).spawn(move || {+ let _ = cdp2.page_enable(&sid2);+ for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = cdp2.send(Some(&sid2), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }+ let capture = vault::capture_login_script();+ let _ = cdp2.send(Some(&sid2), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));+ let _ = cdp2.evaluate(&sid2, &capture, Duration::from_secs(5));+ }).ok();+ let tab_id = s.next_tab_id();+ s.tabs.push(Tab { tab_id: tab_id.clone(), target_id: target_id.into(), cdp_session: sid, url: "about:blank".into(), title: String::new(), error_count: 0, fav: None });+ s.active_tab = s.tabs.len() - 1;+ Ok(tab_id)+}++/// Per-page scripts that need settings (floaty pencil + hotkey, owner chip). Idempotent.+pub fn inject_page_scripts(ctx: &Ctx, s: &Session, cdp: &Cdp, sid: &str) {+ let floaty = annotate::floaty_script(ctx);+ let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": floaty }), Duration::from_secs(5));+ let _ = cdp.evaluate(sid, &floaty, Duration::from_secs(5));+ if ctx.settings.get("ownerChip", "off") == "on" { // 2.0.713: OFF by default (John, 2026-10-12: it sits on the page's own controls, and he never asked for it)+ let chip = OWNER_CHIP_JS.replace("__THREAD__", &serde_json::to_string(&s.owner.clone().unwrap_or_default()).unwrap()).replace("__PROFILE__", &serde_json::to_string(&s.profile).unwrap()).replace("__AVATAR__", &serde_json::to_string(&ctx.settings.get("identityAvatarB64", "")).unwrap()).replace("__IDENT__", &serde_json::to_string(&ctx.settings.get("identityName", "")).unwrap());+ let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": chip }), Duration::from_secs(5));+ let _ = cdp.evaluate(sid, &chip, Duration::from_secs(5));+ }+}++/// 2.0.653: the extension-lane twin of inject_page_scripts (per page load; the smoke found no owner chip there).+pub fn inject_page_scripts_ext(ctx: &Ctx, s: &Session, lane: &transport::Lane, tab: &Tab) {+ if tab.url.is_empty() || tab.url.starts_with("about:") || tab.url.starts_with("chrome") { return; } // nothing to decorate, evals forbidden there+ if !transport::wait_ready(lane, tab, &tab.url, Duration::from_secs(10)) { pup_core::log(format!("[page] \"{}\" page scripts skipped: the document was not ready within 10 s", s.id)); return; }+ let floaty = annotate::floaty_script(ctx);+ let _ = transport::evaluate(lane, tab, &floaty);+ if ctx.settings.get("ownerChip", "off") == "on" { // 2.0.713: OFF by default (John, 2026-10-12: it sits on the page's own controls, and he never asked for it)+ let chip = OWNER_CHIP_JS.replace("__THREAD__", &serde_json::to_string(&s.owner.clone().unwrap_or_default()).unwrap()).replace("__PROFILE__", &serde_json::to_string(&s.profile).unwrap()).replace("__AVATAR__", &serde_json::to_string(&ctx.settings.get("identityAvatarB64", "")).unwrap()).replace("__IDENT__", &serde_json::to_string(&ctx.settings.get("identityName", "")).unwrap());+ let _ = transport::evaluate(lane, tab, &chip);+ }+}++pub fn refresh_tab(cdp: &Cdp, t: &mut Tab) {+ if let Ok(info) = cdp.target_info(&t.cdp_session) {+ if let Some(u) = info.get("url").and_then(|u| u.as_str()) { t.url = u.into(); }+ if let Some(ti) = info.get("title").and_then(|u| u.as_str()) { t.title = ti.into(); }+ }+}++// ── the verbs ──────────────────────────────────────────────────────────────────────────++fn image_delivery(r: &mut Value) {+ fn fix(v: &mut Value) {+ let Some(fp) = v.get("filePath").and_then(|x| x.as_str()).map(|x| x.to_string()) else { return };+ let low = fp.to_lowercase();+ if ![".png", ".jpg", ".jpeg", ".webp"].iter().any(|e| low.ends_with(e)) || v.get("fullPath").is_some() { return; }+ v["fullPath"] = json!(fp);+ let fits = |k: &str| v.get(k).and_then(|x| x.as_u64()).map(|n| n <= 1568).unwrap_or(false);+ if fits("width") && fits("height") { v["safePath"] = json!(fp); }+ }+ fix(r);+ // the same keys inside the JSON string many verbs also carry in `output`+ if let Some(o) = r.get("output").and_then(|x| x.as_str()).map(|x| x.to_string()) {+ if let Ok(mut ov) = serde_json::from_str::<Value>(&o) { if ov.is_object() { let before = ov.clone(); fix(&mut ov); if ov != before { r["output"] = json!(ov.to_string()); } } }+ }+}++fn describe(ctx: &Ctx) -> Value {+ let c = catalog();+ let body = json!({+ "success": true, "bridge": "puppeteer", "version": pup_core::version(), "shell": "rust",+ "progressContract": c["progressContract"], "verbs": c["verbs"], "verbCount": c["verbs"].as_array().map(|a| a.len()).unwrap_or(0), "notes": c["notes"], "statusVerb": "pup_readiness",+ "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()),+ "_hint": "This list — the bridge's verbs + schemas + hints, for AD's Verbs tab. Read it once to learn the surface. This build is pup as one Rust exe (spawn.kind exe): no Node, no npm, no postinstall; same contract as the Node build.",+ "_next": ["pup_status"], "related": ["pup_status"],+ });+ let mut r = body.clone(); r["output"] = json!(body.to_string()); r["error"] = json!(""); r["status"] = json!("ok"); r["ok"] = json!(true);+ r+}++fn active_rows(ctx: &Ctx) -> Vec<Value> {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ let mut rows = Vec::new();+ if rd.get("installing").and_then(|b| b.as_bool()) == Some(true) {+ if rd.get("installPhase").and_then(|p| p.as_str()) == Some("unpacking") {+ let total = rd.get("bytesTotal").and_then(|b| b.as_u64()).unwrap_or(0) as f64 * 2.3;+ let pct = if total > 0.0 { Some(((rd.get("unpackBytes").and_then(|b| b.as_u64()).unwrap_or(0) as f64 / total * 100.0).round() as u64).min(99)) } else { None };+ rows.push(json!({ "id": "cft-unpack", "verb": "pup_prewarm", "phase": "unpack", "stepLabel": format!("Unpacking Chrome for Testing{}", rd.get("unpackMethod").and_then(|m| m.as_str()).map(|m| format!(" ({m})")).unwrap_or_default()), "percent": pct, "bytes": rd.get("unpackBytes"), "elapsedSec": rd.get("unpackElapsedSec"), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "estimated", "startKind": "cold" }));+ } else {+ rows.push(json!({ "id": "cft-download", "verb": "pup_prewarm", "phase": "download", "stepLabel": "Downloading Chrome for Testing", "percent": rd.get("installProgressPct"), "elapsedSec": rd.get("downloadElapsedSec"), "estimatedSec": rd.get("downloadElapsedSec").and_then(|e| e.as_u64()).zip(rd.get("downloadEtaSec").and_then(|e| e.as_u64())).map(|(a, b)| a + b), "etaSec": rd.get("downloadEtaSec"), "confidence": if rd.get("downloadEtaSec").is_some_and(|v| !v.is_null()) { "measured" } else { "estimated" }, "startKind": "cold" }));+ }+ }+ rows.extend(ctx.consent.rows());+ rows.extend(extras::active_download_rows()); // pup-bridge#109: in-flight downloads are pollable here+ rows+}++fn readiness(ctx: &Ctx) -> Value {+ let mut rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ if let Some(o) = rd.as_object_mut() { if let Value::Object(cs) = pup_browser::chrome_stable::snapshot() { for (k, v) in cs { o.insert(k, v); } } }+ let ready = rd.get("ready").and_then(|b| b.as_bool()).unwrap_or(false);+ let installing = rd.get("installing").and_then(|b| b.as_bool()).unwrap_or(false);+ // 2.0.646 (pup-bridge#121): a lane browser nobody tracks (a pup restart lost its pid list) holds the profile+ // lock and makes every launch exit with "Opening in existing browser session". Say so here, with the pids.+ if !extdrive::alive(ctx) {+ let dir = pup_browser::profiles_dir(&*ctx.host).join("adom-you");+ let orphans = orphan_browser_pids(ctx, &dir);+ if !orphans.is_empty() {+ if let Some(o) = rd.as_object_mut() { o.insert("orphanedBrowser".into(), json!({ "pids": orphans, "profile": dir.to_string_lossy(), "_hint": "A pup browser on this profile is running but no live lane owns it (left over from a pup restart). It holds the profile lock, so a launch would exit at once. The next pup_open_window ends it and launches fresh; nothing to do by hand." })); }+ }+ }+ let hint = if ready { "A drivable browser exists: pup_open_window {url} will open in the background. chromeForTestingInstalled tells you whether the toolbar build is cached (pup fetches it in the background on every box)." }+ else if installing { "Chrome for Testing is downloading (active[] carries percent, elapsed and ETA). Poll this verb; do not start a second download. A system Chrome/Edge, if present, is drivable already." }+ else if rd.get("installPhase").and_then(|p| p.as_str()) == Some("failed") { "The Chrome for Testing install FAILED (lastError). Retry pup_prewarm once the cause (network/proxy, disk) is fixed; a system Chrome/Edge is drivable meanwhile." }+ else { "No browser yet: pup_prewarm {} starts the Chrome for Testing download; pup_open_window starts it too and answers chrome_for_testing_installing until it lands." };+ let mut r = pup_core::merge(ok(rd.clone()), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"], "error": "",+ // the Node build's real-Chrome installer phases (pup_use {browser:"chrome", install:true}); the Rust build has no stable-Chrome installer yet, so the phase is always idle+ "chromeStablePhase": rd.get("chromeStablePhase").cloned().unwrap_or(json!("idle")), "chromeStableAwaitingApproval": rd.get("chromeStableAwaitingApproval").cloned().unwrap_or(json!(false)), "chromeStableError": rd.get("chromeStableError").cloned().unwrap_or(Value::Null), "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null }));+ let mut inner = rd; if let Some(i) = inner.as_object_mut() { i.insert("active".into(), r["active"].clone()); i.insert("_hint".into(), json!(hint)); for k in ["chromeStablePhase", "chromeStableAwaitingApproval", "chromeStableError", "chromeStableNotifyChannel", "chromeStableNotifyPeers"] { i.insert(k.into(), r[k].clone()); } }+ r["output"] = json!(inner.to_string());+ r+}++fn prewarm(ctx: &Ctx, args: &Value) -> Value {+ let wait = args.get("wait").and_then(|b| b.as_bool()).unwrap_or(false);+ if wait { let done = ctx.installer.ensure_blocking(ctx.host.clone(), Duration::from_secs(600)); let rd = readiness(ctx); return pup_core::merge(rd, json!({ "waited": true, "installed": done })); }+ ctx.installer.ensure_background(ctx.host.clone());+ let rd = readiness(ctx);+ pup_core::merge(rd, json!({ "started": true, "_hint": "Chrome for Testing provisioning is running in the background (or already cached). Poll pup_readiness for percent + ETA; pass wait:true to block until it lands." }))+}++pub fn session_row(ctx: &Ctx, s: &Session) -> Value {+ let active_tab = s.active();+ json!({ "sessionId": s.id, "window": s.id, "transport": s.transport, "lane": if s.transport == "ext" { "extension (no debug port)" } else { "cdp" }, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes(), "emulatedViewport": s.emulated, "lastAgentUpdate": s.last_agent_update(), "lastForeground": s.last_foreground, "errorCount": s.tabs.iter().map(|t| ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0)).sum::<usize>(),+ "activeTabId": active_tab.map(|t| t.tab_id.clone()), "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null },+ "overlay": s.overlay_desc.clone().unwrap_or_else(|| "none (not yet applied)".into()), "overlayLed": s.overlay_led, "overlayWhy": s.overlay_why, "flash": "none", "flashCount": s.flash_count, "progressBarCount": s.progress_count, "progressBarMs": s.progress_ms,+ "background": if s.foreground { "still-foreground" } else if s.hwnd.is_some() { "backgrounded" } else { "no-window" } })+}++fn status(ctx: &Ctx) -> Value {+ let list: Vec<Value> = ctx.sessions.all().iter().map(|s| session_row(ctx, s)).collect();+ ok(json!({+ "version": pup_core::version(), "shell": "rust", "uptimeSec": ctx.started.elapsed().as_secs(), "dataDirs": ctx.data_dirs(), "retention": { "screenshots": { "keepDays": SHOT_KEEP_DAYS, "keepBytes": SHOT_KEEP_BYTES }, "recordings": { "keepDays": REC_KEEP_DAYS, "keepBytes": REC_KEEP_BYTES } },+ "sessionCount": list.len(), "activeSession": ctx.sessions.active_id(), "sessions": list,+ "settings": { "taskbarMode": if ctx.settings.get("aumidIcons", "on") != "on" { "plain" } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles" } else { "aumid" }, "aumidIcons": ctx.settings.get("aumidIcons", "on"), "threadTiles": ctx.settings.get("threadTiles", "on"), "overlayBadges": ctx.settings.get("overlayBadges", "on"), "overlaySize": ctx.settings.get("overlaySize", "75"), "jumpLists": ctx.settings.get("jumpLists", "on") },+ "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": pup_core::loops::report(), "pupshotUrl": Value::Null, "iconAudit": ctx.last_audit.lock().unwrap().clone(),+ "adVersion": ctx.ad_version.lock().unwrap().clone(),+ "memory": memory_report(ctx, Some("adom-you")),+ }))+}++fn list_windows(ctx: &Ctx) -> Value {+ let mut all = ctx.sessions.all();+ for s in all.iter_mut() { if let Some(b) = browser_for(ctx, &s.profile) { for t in s.tabs.iter_mut() { refresh_tab(&b.cdp, t); } ctx.sessions.with_mut(&s.id, |x| x.tabs = s.tabs.clone()); } }+ let rows: Vec<Value> = all.iter().map(|s| session_row(ctx, s)).collect();+ ok(json!({ "count": rows.len(), "sessions": rows, "activeSession": ctx.sessions.active_id(), "memory": memory_report(ctx, Some("adom-you")) }))+}++fn my_windows(ctx: &Ctx, caller: &Caller) -> Value {+ if caller.thread.is_empty() { return err("caller_identity_required", "pup_my_windows needs --ai-thread", Some("Pass --ai-thread <your thread name>; windows are keyed on it.")); }+ let mine: Vec<Value> = ctx.sessions.owned_by(&caller.thread).iter().map(|s| session_row(ctx, s)).collect();+ let default_id = thread_default_session_id(&caller.thread);+ let body = json!({ "ok": true, "aiThread": caller.thread, "ai-thread": caller.thread, "count": mine.len(), "windows": mine, "defaultWindow": default_id, "_hint": if mine.is_empty() { "You own no pup window yet: pup_open_window {url} creates yours (no ids needed), later verbs resolve to it." } else { "Verbs with no ids resolve to your window; name another with {window:\"<sessionId>\"}." }, "_next": ["pup_open_window", "pup_screenshot", "pup_navigate"] });+ let mut r = ok(body.clone()); r["success"] = json!(true); r["output"] = json!(body.to_string());+ r+}++/// The park geometry in CSS px (what Browser.setWindowBounds takes): Node v1.8.66 rule, "size is+/// measured, not guessed": screen.availLeft/Top/Width/Height from the page itself, with retries, then a+/// 10 px inset on every side. The Win32 work area is PHYSICAL px; feeding it to CDP on a 150% display+/// produced a window 1.5x the screen, hanging off the bottom-right (John's AdomLapper, 2026-09-06).+fn birth_rect_css(cdp: &Cdp, cdp_session: &str, ctx: &Ctx) -> (i32, i32, i32, i32) {+ for _ in 0..4 {+ if let Ok(v) = cdp.evaluate(cdp_session, "({x:screen.availLeft||0,y:screen.availTop||0,w:screen.availWidth,h:screen.availHeight})", Duration::from_secs(3)) {+ let (x, y, w, h) = (v["x"].as_f64().unwrap_or(0.0) as i32, v["y"].as_f64().unwrap_or(0.0) as i32, v["w"].as_f64().unwrap_or(0.0) as i32, v["h"].as_f64().unwrap_or(0.0) as i32);+ if w > 400 && h > 300 { return (x + 10, y + 10, w - 20, h - 20); }+ }+ std::thread::sleep(Duration::from_millis(300));+ }+ match ctx.host.work_area() { Some((x, y, w, h)) => { let sc = ctx.host.dpi_scale(0).max(1.0); ((x as f64 / sc) as i32 + 10, (y as f64 / sc) as i32 + 10, ((w as f64 / sc) as i32 - 20).max(400), ((h as f64 / sc) as i32 - 20).max(300)) }, None => (10, 10, 1280, 800) }+}++fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let t0 = Instant::now();+ let url = args.get("url").and_then(|u| u.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "about:blank".into());+ // 2.0.648 (pup-bridge#112): isolated:true MINTS a throwaway profile. It used to bind adom-you and only pick the+ // anonymous tile, so a "throwaway" login persisted into the shared jar and an untrusted page ran on the user's+ // real cookies. The directory is removed when the window's browser is gone (close_window).+ let profile = match args.get("profile").and_then(|p| p.as_str()) {+ Some(p) => p.to_string(),+ None if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) => format!("iso-{:x}{:x}", pup_core::now_ms() & 0xffffff, std::process::id() & 0xffff),+ None => "adom-you".to_string(),+ };+ if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) && args.get("isolationReason").and_then(|r| r.as_str()).map(|r| r.len() >= 10) != Some(true) {+ return err("isolation_reason_required", "isolated:true needs an isolationReason (min 10 chars): why must this window NOT be adom-you?", Some("Surf as adom-you by default (its logins accumulate). Isolation is for logged-out tests, acting as a different user, or untrusted pages: say which."));+ }+ if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) && args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.len() >= 8) != Some(true) {+ return err("foreground_reason_required", "foreground:true needs a foregroundReason shown to the user on screen", Some("pup windows are born in the BACKGROUND. Only a demo / 'show me' earns the foreground, and the reason is captioned for the user."));+ }+ let sid = window_arg(args).unwrap_or_else(|| if caller.thread.is_empty() { format!("pup-{}", pup_core::now_ms() % 100000) } else { thread_default_session_id(&caller.thread) });+ // 2.0.476: a DELIBERATE open of this id (anything but the auto-recovery relaunch) clears a pending close-cancel,+ // so reopening a window shortly after closing it during a restore is honored rather than swallowed.+ if !caller.reason.starts_with("auto-recovery") { crate::recovery::clear_cancel(&sid); }+ let high_fps = args.get("highFps").and_then(|b| b.as_bool()).unwrap_or(false);+ let insecure = args.get("webSecurity").and_then(|b| b.as_bool()) == Some(false);+ // 2.0.525: the lane is chosen HERE, before any browser is launched. defaultLane is "ext": a plain+ // pup_open_window is an extension-driven window on adom-you (no debug port, John's default browsing+ // lane). lane:"cdp" is the explicit opt-in for what CDP alone can do (iframes, trusted input, annotate).+ // 2.0.563 (John): the extension lane IS pup. It is the lane that loads every vendor site, so it is the+ // default and the CDP lane opens ONLY on an explicit lane:"cdp". Nothing falls back to CDP silently any+ // more: a request that needs a CDP-only feature without asking for the lane is refused with the fix.+ let explicit_lane = args.get("lane").and_then(|l| l.as_str()).map(|x| x.to_string());+ let lane = explicit_lane.clone().unwrap_or_else(|| ctx.settings.get("defaultLane", "ext"));+ let isolated = args.get("isolated").and_then(|b| b.as_bool()) == Some(true);+ // 2.0.652 (John, 2026-09-19: "i want it almost NEVER used cuz then you may use it as a crutch instead of fixing+ // ext lane to solve all use cases"). lane:"cdp" is refused unless the user turned the CDP lane on for this box.+ if args.get("lane").and_then(|l| l.as_str()) == Some("cdp") && ctx.settings.get("cdpLane", "off") != "on" {+ pup_core::log(format!("[lane] refused: thread \"{}\" asked for lane:\"cdp\" and cdpLane is off (cdp_lane_disabled)", caller.thread));+ return err("cdp_lane_disabled", "the CDP lane is off on this box; every pup window runs on the extension lane", Some("Use the extension lane (omit lane). Trusted input, HTTP auth, fetch, screenshots and recording all work there. If a feature genuinely needs CDP, file it on adom/pup-bridge as an extension-lane gap; the user can enable the lane for one box with pup_configure {cdpLane:\"on\"}."));+ }+ if lane != "cdp" {+ // 2.0.659 (pup-bridge#112/#113): profile and isolated run on the EXTENSION lane now (a lane per profile)+ let needs_cdp = if high_fps { Some("highFps:true (anti-throttle flags are a CDP-lane feature)".to_string()) }+ else if insecure { Some("webSecurity:false (a CDP-lane feature)".to_string()) }+ else { None };+ if let Some(why) = needs_cdp {+ pup_core::log(format!("[lane] refused: thread \"{}\" passed a CDP-only option ({}) without lane:\"cdp\" (cdp_lane_required)", caller.thread, ["profile","isolated","highFps","webSecurity"].iter().filter(|k| args.get(**k).is_some()).cloned().collect::<Vec<_>>().join(",")));+ return err("cdp_lane_required", format!("This open needs the CDP lane because of {why}, and the CDP lane opens only when asked for."),+ Some("The extension lane is pup's default: it is the lane every vendor site loads on, with no --remote-debugging-port for edges to detect. Pass lane:\"cdp\" ONLY if you specifically need what CDP alone can do (iframes, trusted input events, element/full-res screenshots, a separate profile, highFps, webSecurity:false); read the pup-lanes skill first. Note that lane:\"cdp\" on adom-you takes the profile away from the extension lane and closes its windows."));+ }+ let owner = args.get("owner").and_then(|o| o.as_str()).map(|o| o.to_string()).or_else(|| if caller.thread.is_empty() { None } else { Some(caller.thread.clone()) });+ let mut a2 = args.clone(); a2["_mintedProfile"] = json!(profile);+ return open_window_ext(ctx, caller, &a2, &sid, owner.as_deref(), &url);+ }+ // lane:cdp on adom-you while the extension lane holds it: hand the profile back. The two lanes cannot+ // share one --user-data-dir (Chrome runs one process per profile), so this is symmetric with the ext+ // birth taking adom-you from a CDP browser. It closes the ext windows, which is why lane:cdp is an+ // explicit opt-in and the default stays ext.+ if profile == "adom-you" && ext_lane_owns_profile(ctx, &profile) {+ let ext_ids: Vec<String> = ctx.sessions.all().into_iter().filter(|x| transport::is_ext(x)).map(|x| x.id).collect();+ if !ext_ids.is_empty() {+ pup_core::log(format!("[lane] lane:cdp requested on adom-you; handing the profile back from the extension lane (closing {} ext window(s))", ext_ids.len()));+ for id in &ext_ids { ctx.sessions.remove(id); ctx.sessions.delete_file(id); }+ }+ let _ = crate::extdrive::close(ctx, caller, &json!({ "all": true }));+ kill_extdrive(ctx);+ let dir = pup_browser::profiles_dir(&*ctx.host).join(&profile);+ let lock = dir.join("SingletonLock");+ let t0 = Instant::now(); while t0.elapsed() < Duration::from_secs(8) { if !lock.exists() { break; } std::thread::sleep(Duration::from_millis(200)); }+ }+ let b = match get_or_launch(ctx, &profile, high_fps, insecure) { Ok(b) => b, Err(e) => return e };++ // An existing window of that name: later opens become TABS in it (the Node contract).+ if let Some(existing) = ctx.sessions.get(&sid) {+ if existing.profile == profile {+ let tid = match b.cdp.create_target(&url, false, None, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };+ let mut s = existing.clone();+ let tab_id = match attach_tab(&mut s, &b.cdp, &tid) { Ok(t) => t, Err(e) => return e };+ if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }+ if let Some(t) = s.tabs.last_mut() { let _ = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(15)); refresh_tab(&b.cdp, t); }+ let title = s.active().map(|t| t.title.clone()).unwrap_or_default();+ let tab_count = s.tabs.len();+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ ctx.sessions.with_mut(&sid, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == tab_id) { x.active_tab = i; } }); // Chrome shows the new tab in front; the badge follows it+ focus_emulate(ctx, &sid, None);+ if let Some(a) = ctx.arc() { identity::schedule(a.clone(), sid.clone(), caller.clone(), false); identity::overlay_retries(a, sid.clone(), caller.clone()); }+ return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": tab_count, "reused": true, "active": true,+ "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "browser": b.launched.candidate.to_json(), "profile": profile, "owner": s.owner, "ownerSource": "session",+ "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": "warm" },+ "memory": memory_report(ctx, Some(&profile)),+ "_hint": "This window already existed for your thread, so the URL opened as a NEW TAB in it (pup_list_tabs / pup_switch_tab drive tabs). Screenshot to verify the render.", "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval"] }));+ }+ }++ // Node: an explicit `owner` declares the owning thread (ownerSource "declared"); else the caller's thread.+ // The Rust port ignored `owner` until 2.0.438, so a window recreated on another thread's behalf wore the+ // recreating thread's tile (John's AdomLapper proof run, 2026-09-06).+ let declared_owner = args.get("owner").and_then(|o| o.as_str()).map(|o| o.trim().to_string()).filter(|o| !o.is_empty());+ let owner: Option<&str> = declared_owner.as_deref().or(if caller.thread.is_empty() { None } else { Some(caller.thread.as_str()) });+ // R4 / pup-window-targeting rule 2: the set-diff is only exact while exactly ONE pup birth is in flight.+ let birth_guard = BIRTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ let taken = taken_hwnds(ctx, &sid);+ let before: std::collections::HashSet<i64> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();+ let prev_fg = ctx.host.foreground(); // Node v1.9.253: measure whether creation steals focus, and hand it straight back+ // 2.0.446 (John: "i would expect the icon to come up the first time with the ai-thread name and then it NEVER+ // has to swap icons again", and "i thought you said you could make it such that the cft icon never actually+ // showed first?"): the shell creates a window's button at its first show and bakes the icon then, so the+ // identity has to be ON the window before Chrome shows it. Chrome shows the window inside Target.createTarget,+ // so a watcher thread starts BEFORE the call, polls every top-level frame (visible or not) every millisecond,+ // and stamps the newborn natively (property store + window icon, no ab round-trip) the instant it exists.+ // The tile is rendered and the AUMID registered ahead of time, so the button's picture resolves on creation.+ let plan_name = owner.map(|o| o.to_string()).unwrap_or_else(|| sid.clone());+ let (plan, _early_ok, watcher) = birth_watch(ctx, caller, &sid, &plan_name, &url, &before, &taken, false);+ // 2.0.450 (John: "i would like a setting in the pup settings dialog to turn this flash feature on or off"):+ // winAttentionFlash off (default) = focus:false, Chrome never asks for the foreground, no attention state;+ // on = Chrome's default ask, and Windows lights the button when it refuses (the classic new-window flash).+ let attention = ctx.settings.get("winAttentionFlash", "off") == "on";+ let target = match b.cdp.create_target("about:blank", true, Some((OFFSCREEN, OFFSCREEN, 1280, 800)), if attention { None } else { Some(false) }) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", format!("Target.createTarget: {e}"), Some("The browser is up but refused a new window; pup_readiness shows its state, pup_close {} restarts it.")) };+ let mut s = Session::new(&sid, &profile, owner, Some(&caller.container), b.launched.cdp_port, if b.launched.pid > 0 { Some(b.launched.pid) } else { None });+ s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none();+ let tab_id = match attach_tab(&mut s, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };+ let (bl, bt, bw, bh) = s.tabs.last().map(|t| birth_rect_css(&b.cdp, &t.cdp_session, ctx)).unwrap_or((10, 10, 1280, 800)); // CSS px, measured from the page+ // park: on-screen rect via CDP (moves without z-order change), then bottom + tag via the host+ let mut hwnd: Option<i64> = None;+ // Node v2.0.276: born off-screen so Chrome never activates it, moved on-screen within the 50 ms budget by+ // one local CDP call that changes no z-order. Leaving it off-screen longer (2.0.429 to 2.0.433 candidates)+ // made Chrome fire its activation on the first real paint, which Windows denies and tints. Move NOW.+ if let Ok((wid, _)) = b.cdp.window_for_target(&target) { s.window_id = Some(wid); let _ = b.cdp.set_window_bounds(wid, json!({ "left": bl, "top": bt, "width": bw, "height": bh })); }+ let mut early_stamped = false;+ if let Some((h, stamped)) = birth_report(watcher, &sid) { hwnd = Some(h); early_stamped = stamped; }+ if hwnd.is_none() { for _ in 0..200 {+ let now = ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1"));+ if let Some(w) = now.iter().find(|w| !before.contains(&w.hwnd) && !taken.contains(&w.hwnd)) { hwnd = Some(w.hwnd); break; }+ std::thread::sleep(Duration::from_millis(15));+ } }+ drop(birth_guard); // hwnd resolved: the next birth may start+ let mut parked = false;+ if let Some(h) = hwnd {+ // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name+ // and then it NEVER has to swap icons again." Windows bakes a button's icon when the button is+ // created, and creates it when the window is shown. So: HIDE the newborn at once (its button+ // goes away), render the thread tile + register the AUMID + stamp + jump list on the hidden+ // window, then show it once, no-activate, at z-bottom. One button, born with the tile.+ // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name+ // and then it NEVER has to swap icons again." Windows bakes a button's icon when it creates the+ // button. So: take the newborn's button away through the shell's own ITaskbarList::DeleteTab+ // (the window stays visible, so Chrome queues no activation and no attention tint can follow),+ // render the thread tile + register the AUMID + stamp + jump list, then AddTab: the shell creates+ // the button ONCE, already wearing the tile. Hiding the window instead (2.0.429 to 2.0.432+ // candidates) made Chrome fire its first-show activation on the re-show, which Windows denies+ // and tints; measured, so never hide a newborn.+ // 2.0.446: an early-stamped newborn keeps its button (it was created wearing the tile); only the late path untabs.+ if !early_stamped { let _ = ctx.host.taskbar_tab(h, false); }+ // Node v1.9.253: when the user's foreground window belongs to pup's own Chrome (they are using a+ // pup window), Windows lets Chrome activate the newborn, so creation steals their focus. Measured+ // 2026-09-06 (foreground 1901802 -> the newborn). Hand it straight back before anything else.+ if prev_fg != 0 && prev_fg != h && ctx.host.foreground() == h {+ let _ = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": prev_fg }), caller, Duration::from_secs(4));+ pup_core::log(format!("[window] \"{sid}\" creation took the foreground from {prev_fg}; handed it back ({})", if ctx.host.foreground() == prev_fg { "ok" } else { "still ours" }));+ }+ s.hwnd = Some(h);+ ctx.host.tag(h, &s.tag_string());+ ctx.sessions.insert(s.clone());+ // Park FIRST (Node: the unconditional launch park), so the newborn never sits on top while we work.+ parked = ctx.host.send_to_bottom_no_activate(h);+ let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));+ activity::mark_icon_applied(ctx, &sid);+ activity::on_open(ctx, &sid, h, caller);+ }+ if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }+ let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false };+ let (auto_login_note, wiki_extra) = match s.tabs.last() { Some(t) => (vault::autofill_on_load(ctx, &s, &t.cdp_session, &url), wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &url, args["wikiView"].as_str(), args["isolated"].as_bool().unwrap_or(false))), None => (None, json!({})) };+ ctx.sessions.insert(s.clone());+ let _ = park::bottom_unless_foreground(ctx, &sid, true); // Node reassertBottomAfterNav: the first cross-origin paint can raise it; inside the birth grace it is bottomed+ if let Some(h) = hwnd {+ // Measured 2026-09-06: Windows puts the attention flag on the BUTTON that exists when Chrome's+ // first real paint (this navigation) tries to activate the window and is denied. No button existed+ // (DeleteTab at birth), so nothing was flagged. Now stamp the thread identity and let the shell+ // create the one button this window will ever have, wearing the tile.+ // Node v1.9.342: place AGAIN once the window has settled. Chrome cascades a new window from the+ // last one's position after our first move (measured on AdomLapper: requested 10,10 CSS, landed+ // 120,120 CSS, overhanging a 150% screen), and re-reads the screen the window actually sits on.+ if let (Some(t), Ok((wid, _))) = (s.tabs.last(), b.cdp.window_for_target(&target)) {+ let (l2, t2, w2, h2) = birth_rect_css(&b.cdp, &t.cdp_session, ctx);+ let _ = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" }));+ let _ = b.cdp.set_window_bounds(wid, json!({ "left": l2, "top": t2, "width": w2, "height": h2 }));+ if let Ok(v) = b.cdp.send(None, "Browser.getWindowBounds", json!({ "windowId": wid }), Duration::from_secs(3)) { pup_core::log(format!("[window] \"{sid}\" placed at {},{} {}x{} CSS (asked {l2},{t2} {w2}x{h2})", v["bounds"]["left"], v["bounds"]["top"], v["bounds"]["width"], v["bounds"]["height"])); }+ }+ let t_id = Instant::now();+ if early_stamped { if let Some((a, _, _)) = &plan { ctx.sessions.with_mut(&sid, |x| x.app_id = Some(a.clone())); } }+ identity::stamp_opts(ctx, &sid, caller, true, false); // same AUMID again (no button rebuild, measured) + the jump list+ if early_stamped {+ ctx.host.send_to_bottom_no_activate(h);+ pup_core::log(format!("[window] \"{sid}\" jump list committed on the early-stamped button in {}ms (no DeleteTab/AddTab)", t_id.elapsed().as_millis()));+ } else {+ let _ = ctx.host.taskbar_tab(h, false);+ let retabbed = ctx.host.taskbar_tab(h, true);+ ctx.host.send_to_bottom_no_activate(h);+ pup_core::log(format!("[window] \"{sid}\" identity stamped after the first paint, one button created in {}ms (AddTab={retabbed})", t_id.elapsed().as_millis()));+ }+ }+ let title = s.active().map(|t| t.title.clone()).unwrap_or_default();+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ focus_emulate(ctx, &sid, None); // 2.0.458: the newborn's active tab, and only it+ if let Some(a) = ctx.arc() { if hwnd.is_none() { identity::schedule(a.clone(), sid.clone(), caller.clone(), true); } identity::overlay_retries(a, sid.clone(), caller.clone()); } // identity was stamped while hidden; the favicon lands late, repaint at 1.5 s and 4.5 s+ if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) {+ // Node: a foreground open is a background birth followed by a GRANTED raise (caption + toast).+ let _ = window::raise(ctx, caller, &json!({ "window": sid, "foregroundReason": args["foregroundReason"] }));+ } else { ctx.sessions.with_mut(&sid, |x| x.fg_watch_until_ms = pup_core::now_ms() + 20_000); } // Node: the no-fault fg watch — caption if the window fronts itself in the next 20s+ pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));+ let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+ let strict = args["strictPermissions"].as_bool() == Some(true);+ let render_check_val = match s.tabs.last() { Some(t) if loaded => render_check(&b.cdp, &t.cdp_session, None), _ => Value::Null };+ let mut resp = ok(json!({+ "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": 1, "reused": false, "active": true, "loaded": loaded,+ "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "hwnd": hwnd, "parked": parked,+ "browser": b.launched.candidate.to_json(), "availableBrowsers": cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>(),+ "defaultBrowser": pup_browser::read_default(&*ctx.host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": false })),+ "profile": profile, "owner": s.owner, "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null }, "ageMinutes": 0, "highFps": high_fps, "emulatedViewport": Value::Null, "errorCount": 0,+ "defaultPermissionsApplied": !strict, "defaultPermissionsCount": if strict { 0 } else { DEFAULT_PERMISSIONS.len() }, "defaultPermissionsNote": if strict { "strictPermissions: nothing pre-granted" } else { "full browser permissions pre-granted (clipboard, camera, geolocation, window-management, ...) — pup_describe has the exact list" },+ "downloadsEnabled": !strict, "downloadPath": ctx.host.home().join("Downloads").to_string_lossy().replace('\\', "/"), "flash": "none", "flashCount": 0, "lastAgentUpdate": Value::Null, "lastForeground": Value::Null, "overlay": "none (not yet applied)", "progressBarCount": 0, "progressBarMs": 0, "restoredTabsClosed": 0,+ "renderCheck": render_check_val,+ "size": { "width": bw, "height": bh }, "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": if b.launched.reused { "warm" } else { "cold" } },+ "memory": memory_report(ctx, Some(&profile)),+ "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval", "pup_close_window"],+ "_hint": "Opened in the BACKGROUND (behind the user's windows, did NOT steal focus). Fully drivable while hidden: pup_screenshot / pup_eval / pup_navigate resolve to this window with no ids. Only bring it to the user's screen if they explicitly want to watch.",+ }));+ if b.launched.candidate.kind != "chrome-for-testing" { resp["_toolbarNotice"] = json!(format!("This window launched on {}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked.", b.launched.candidate.kind)); }+ if let Some(n) = auto_login_note { resp["_autoLogin"] = json!(n); }+ resp = pup_core::merge(resp, wiki_extra);+ if args["highFps"].as_bool() == Some(true) { highfps::grant(ctx, &sid, args["highFpsMinutes"].as_u64().unwrap_or(highfps::DEFAULT_MIN)); resp["highFps"] = highfps::info(ctx, &sid); }+ if hwnd.is_none() { resp["_windowNotice"] = json!("pup could not resolve the OS window handle for this window (no Chrome_WidgetWin_1 appeared), so it was not parked or tagged; it may be on top. This is the Phase 1 Rust bridge; report it with the box name."); }+ resp+}++pub const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not assume it rendered. ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. Take a screenshot and READ the image: pup_screenshot {} returns a real PNG path. Look for a blank page, an unstyled flash of plain HTML, a framework error overlay, missing images, a dev-server 'Cannot GET /' page, or the wrong build. If it is wrong: fix the source, reload, screenshot AGAIN.";++fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let url = match args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()) { Some(u) => u.to_string(), None => return err("url_required", "pup_navigate needs {url}", None) };+ if transport::is_ext(&s) { return navigate_ext(ctx, caller, &s, &url); }+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 brings automatic recovery.")) };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ match b.cdp.navigate(&tab.cdp_session, &url, Duration::from_secs(25)) {+ Ok((_, loaded)) => {+ let mut t = tab.clone(); refresh_tab(&b.cdp, &mut t);+ ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { *tt = t.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });+ let _ = park::bottom_unless_foreground(ctx, &s.id, true); // never yank a window the user is using (Node v2.0.26)+ if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }+ if let Some(a) = ctx.arc() { identity::schedule(a.clone(), s.id.clone(), caller.clone(), false); identity::overlay_retries(a, s.id.clone(), caller.clone()); }+ let note = vault::autofill_on_load(ctx, &s, &t.cdp_session, &t.url);+ let wiki_extra = wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &t.url, args["wikiView"].as_str(), false);+ let mut r = ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "renderCheck": if loaded { render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }));+ if let Some(n) = note { r["_autoLogin"] = json!(n); }+ pup_core::merge(r, wiki_extra)+ }+ Err(e) => err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL).")),+ }+}++/// The extension-lane arm of pup_navigate: same session bookkeeping, driven through the transport.++/// 2.0.642: where the page actually LANDED, classified, so an AI never asks the user to sign in to a site the+/// shared profile is already signed into. John, 2026-09-19: another thread opened claude.ai, which went straight+/// to his chats (the adom-you profile had the session), and it toasted "Sign into claude.ai in the pup window+/// once" anyway. The open/navigate response now says whether the final page is a sign-in page.+pub fn landed(url: &str, title: &str) -> Value { landed_with(url, title, None) }++/// 2.0.643: the DOM probe behind `landed`. (login_offered, login_form): a visible "Log in"/"Sign in" link or+/// button means the profile is NOT signed in here even when the URL looks like an app page (chatgpt.com/codex+/// is a marketing page with a Log in link when signed out, and the URL rule alone called it signed in); a+/// password field, or an email field next to a Continue/Next button, means the page IS a sign-in form.+pub fn landed_ext(ctx: &Ctx, s: &Session, tab: &pup_session::Tab, url: &str, title: &str) -> Value {+ if let Ok(lane) = transport::lane_for(ctx, s) { let _ = transport::wait_ready(&lane, tab, url, Duration::from_secs(8)); }+ const PROBE: &str = "(()=>{try{const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0};const txt=e=>((e.innerText||e.value||e.getAttribute('aria-label')||'')+'').trim().toLowerCase().replace(/\\s+/g,' ');const els=[...document.querySelectorAll('a,button,input[type=submit],[role=button]')].filter(vis);const offered=els.some(e=>/^(log ?in|sign ?in|log ?in with .*|sign ?in with .*)$/.test(txt(e)));const pw=!!document.querySelector('input[type=password]');const em=!!document.querySelector('input[type=email],input[name*=email i],input[autocomplete=username],input[autocomplete=email]');const cont=els.some(e=>/^(continue|next|log ?in|sign ?in|continue with .*)$/.test(txt(e)));return JSON.stringify({offered,form:pw||(em&&cont)})}catch(e){return JSON.stringify({err:String(e)})}})()";+ let probe = transport::lane_for(ctx, s).ok().and_then(|lane| transport::evaluate(&lane, tab, PROBE).ok()).and_then(|v| {+ let txt = v.as_str().map(|x| x.to_string()).or_else(|| v.get("result").and_then(|r| r.as_str()).map(|x| x.to_string()))?;+ let j: Value = serde_json::from_str(&txt).ok()?;+ Some((j["offered"].as_bool().unwrap_or(false), j["form"].as_bool().unwrap_or(false)))+ });+ landed_with(url, title, probe)+}++pub fn landed_with(url: &str, title: &str, probe: Option<(bool, bool)>) -> Value {+ let u = url.to_ascii_lowercase(); let t = title.to_ascii_lowercase();+ let host = u.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").to_string();+ let path = u.split("//").nth(1).unwrap_or("").splitn(2, '/').nth(1).map(|p| format!("/{p}")).unwrap_or_else(|| "/".into());+ let known_signin = (host.ends_with("claude.ai") && (path.starts_with("/login") || path.starts_with("/magic-link") || path.starts_with("/oauth")))+ || ((host.ends_with("chatgpt.com") || host.ends_with("openai.com")) && (path.contains("/auth/") || path.starts_with("/log-in") || path.starts_with("/login") || host.starts_with("auth.")))+ || host == "accounts.google.com" || host.ends_with("login.microsoftonline.com") || host.ends_with("appleid.apple.com");+ let generic = ["/login", "/signin", "/sign-in", "/sign_in", "/auth", "/sso", "/oauth", "/authorize", "/session/new", "/account/login", "/users/sign_in"].iter().any(|p| path.starts_with(p) || path.contains(&format!("{p}?")) || path.contains(&format!("{p}/")))+ || ["sign in", "log in", "login", "signin"].iter().any(|k| t.starts_with(k) || t.contains(&format!(" {k}")) && t.len() < 40);+ let (offered, form) = probe.unwrap_or((false, false));+ let login_page = known_signin || generic || form;+ let signed_in_known = !login_page && !offered && (+ (host.ends_with("claude.ai") && (path.starts_with("/new") || path.starts_with("/chat") || path.starts_with("/project") || path.starts_with("/settings") || path == "/"))+ || (host.ends_with("chatgpt.com") && (path.starts_with("/c/") || path.starts_with("/g/") || (path.starts_with("/codex") && probe.is_some()))));+ let hint = if !login_page && offered {+ format!("The page landed on {url} and shows a Log in / Sign in link: the shared adom-you profile is NOT signed in here. Follow login-recipes/<domain>.md on the pup-bridge wiki page (Google sign-in via pup_google_signin, or the vault via pup_login) before involving the user.")+ } else if login_page {+ format!("The page landed on a SIGN-IN page ({url}). Before asking the user for anything: pup_credentials for a saved login (pup_login fills it), pup_google_accounts + pup_google_signin if the site offers Google sign-in, and login-recipes/<domain>.md on the pup-bridge wiki page. Toast the user ONLY if pup cannot finish the sign-in itself.")+ } else if signed_in_known {+ format!("The page landed on {url}: the shared adom-you profile is ALREADY signed in here. Do not ask the user to sign in and do not toast about signing in.")+ } else {+ format!("The page landed on {url}. It is not a sign-in page. Do not ask the user to sign in unless the page itself asks.")+ };+ json!({ "url": url, "title": title, "loginPage": login_page, "loginOffered": offered, "signedIn": if signed_in_known { json!(true) } else if login_page || offered { json!(false) } else { Value::Null }, "probed": probe.is_some(), "_hint": hint })+}+fn navigate_ext(ctx: &Ctx, caller: &Caller, s: &Session, url: &str) -> Value {+ let lane = match transport::lane_for(ctx, s) { Ok(l) => l, Err(e) => return e };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ let (u, title, loaded) = match transport::navigate(&lane, &tab, url) { Ok(x) => x, Err(e) => return e };+ ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == tab.tab_id) { tt.url = u.clone(); tt.title = title.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });+ if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }+ // 2.0.621: NO attention clear here. ab implements desktop_clear_window_attention by RAISING the window+ // (adom-bridge#199, measured z16 -> z1 on every call), and it ran after EVERY navigate, which is the+ // "navigate puts the window on top" that flashed pup at John through every Google sign-in. Nothing needs+ // clearing after a navigate; the birth tint is cleared once, at birth.+ identity::overlay(ctx, &s.id, caller);+ // 2.0.607 (John, 2026-09-17: "why did you just shove a pup window to the foreground? that was rude").+ // An AI navigating a window the user never asked to see must leave it where it was: BEHIND. Measured on+ // his laptop, a background window came forward on the navigate, 21 s before the toast that explained it.+ // Narrow on purpose: never when the window is foreground, and never when he has touched it recently+ // (park::user_is_using), so this can never pull away a window HE raised from a toast.+ // 2.0.629: force, so a navigation inside the birth grace that Chrome ACTIVATED is still bottomed under the+ // idle-hands rule; outside the grace, or with any input since birth, the primitive stands down as before.+ let _ = park::bottom_unless_foreground(ctx, &s.id, true);+ // 2.0.653: what the CDP path always did after a navigate, now on this lane too: page scripts (owner chip,+ // floaty pencil) and the wiki view probe with the desktop-token auto-sign.+ let tab_now = ctx.sessions.get(&s.id).and_then(|x| x.tab_by_id(&tab.tab_id).cloned()).unwrap_or_else(|| tab.clone());+ inject_page_scripts_ext(ctx, s, &lane, &tab_now);+ let wiki_extra = wiki::after_navigate_ext(ctx, s, &lane, &tab_now, &u, None, false);+ // 2.0.644 (pup-bridge#119): a cancelled HTTP auth challenge is reported as a typed error, not a blank page.+ if let Some((host, answered)) = transport::ext_id(&tab).as_i64().and_then(vault::unanswered_challenge) {+ if !answered { return pup_core::merge(vault::no_credential_error(&host, false), json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "transport": "ext" })); }+ }+ pup_core::merge(ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "loaded": loaded, "transport": "ext", "landed": landed_ext(ctx, s, &tab, &u, &title) })), wiki_extra)+}++fn eval(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let expr = ["expr", "expression", "script", "js", "code"].iter().find_map(|k| args.get(*k).and_then(|v| v.as_str())).map(|s| s.to_string());+ let Some(expr) = expr else { return err("eval_expr_missing", "Missing required parameter \"expr\" (aliases: expression, script, js, code)", Some("pup_eval {expr:\"document.title\"}")) };+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if transport::is_ext(&s) {+ if args.get("frame").and_then(|f| f.as_str()).filter(|f| !f.is_empty()).is_some() { return err("frame_unsupported_ext", "frame targeting needs the CDP lane", Some("Open the window with lane:\"cdp\" for iframe evaluation.")); }+ let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ return match transport::evaluate(&lane, &tab, &expr) { Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })), Err(e) => e };+ }+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+ let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ // frame: run inside an IFRAME by name, URL substring, or numeric index (login forms usually live in one). Node: the args.frame block.+ let eval_result = if let Some(fq) = args.get("frame").and_then(|f| f.as_str()).filter(|f| !f.is_empty()) {+ let frames = b.cdp.frames(&tab.cdp_session).unwrap_or_default();+ let pick = fq.parse::<usize>().ok().and_then(|i| frames.get(i).cloned())+ .or_else(|| frames.iter().find(|f| f["name"].as_str() == Some(fq)).cloned())+ .or_else(|| frames.iter().find(|f| f["url"].as_str().map(|u| u.contains(fq)).unwrap_or(false)).cloned());+ match pick {+ None => {+ let avail: Vec<String> = frames.iter().enumerate().map(|(i, f)| format!("[{i}] name={} url={}", f["name"].as_str().filter(|n| !n.is_empty()).unwrap_or("(none)"), f["url"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect();+ return pup_core::merge(err("frame_not_found", format!("No frame matched \"{fq}\" in window \"{}\".", s.id), Some("frame matches by frame NAME, then by URL substring, then by numeric index. An iframe's id attribute is NOT its frame name.")), json!({ "frames": avail }));+ }+ Some(f) => {+ let fid = f["id"].as_str().unwrap_or("");+ match b.cdp.frame_context(&tab.cdp_session, fid) { Ok(ctxid) => b.cdp.evaluate_in_context(&tab.cdp_session, ctxid, &expr, Duration::from_secs(20)), Err(e) => return err("frame_context_failed", e.to_string(), None) }+ }+ }+ } else { b.cdp.evaluate(&tab.cdp_session, &expr, Duration::from_secs(20)) };+ match eval_result {+ Ok(v) if v.get("__error").is_some() => err("eval_failed", format!("pup_eval could not run your code: {}", v["__error"].as_str().unwrap_or("evaluation threw")), Some("Expression and statement forms both run (devtools-console semantics). If this names a detached frame or closed tab, re-list windows.")),+ Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })),+ Err(e) => err("eval_failed", e.to_string(), None),+ }+}++fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if transport::is_ext(&s) {+ let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ let png = match transport::screenshot_png(&lane, &tab, false) { Ok(p) => p, Err(e) => return e };+ let max_w = args.get("maxWidth").and_then(|m| m.as_u64()).map(|m| m as u32).unwrap_or(MAX_SCREENSHOT_DIM).min(MAX_SCREENSHOT_DIM);+ let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };+ let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));+ if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }+ return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "transport": "ext", "_hint": "captureVisibleTab: the VISIBLE area of the active tab. READ the file before describing it." }));+ }+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ let full = args.get("fullPage").and_then(|b| b.as_bool()).unwrap_or(false);+ let png = match b.cdp.screenshot_png(&tab.cdp_session, full, Duration::from_secs(20)) {+ Ok(p) => p,+ Err(e) => {+ // OS fallback (Node 2.0.38x): ab captures the window even when the renderer is stuck.+ if let Some(h) = s.hwnd {+ // 2.0.649 (pup-bridge#106): the OS capture is by hwnd, the only path that can hand back another window's+ // document. Check the OS title against the tab before capturing; a mismatch is a typed error, not an image.+ let os_title = ctx.host.frame_windows_all(None).into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();+ let want = tab.title.trim();+ if !want.is_empty() && !os_title.contains(want) { return pup_core::merge(err("capture_identity_mismatch", format!("window {h} is titled {os_title:?} but this tab is {want:?}: not capturing another window's document"), Some("pup_window_info shows the session's hwnd and tabs; pup_list_windows shows every window. Re-open the page or pass tabId explicitly.")), json!({ "sessionId": s.id, "hwnd": h, "osTitle": os_title, "tab": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id } })); }+ if let Ok(r) = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": h }), caller, Duration::from_secs(15)) { return pup_core::merge(ok(r), json!({ "capturedVia": "os", "captured": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": h, "via": "os-window", "osTitle": os_title }, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "_hint": "CDP timed out; this is an OS window capture (window chrome included)." })); } }+ return err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState to see whether the renderer is alive."));+ }+ };+ let max_w = args.get("maxWidth").and_then(|m| m.as_u64()).map(|m| m as u32).unwrap_or(MAX_SCREENSHOT_DIM).min(MAX_SCREENSHOT_DIM);+ let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };+ let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));+ if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }+ ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "fullPage": full, "capturedVia": "cdp", "_hint": "PNG of the page. READ the image before asserting anything rendered.", "_next": ["pup_eval", "pup_navigate"] }))+}++fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let fg = ctx.host.foreground();+ let tabs: Vec<Value> = s.tabs.iter().enumerate().map(|(i, t)| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": i == s.active_tab })).collect();+ ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "owner": s.owner, "ownerContainer": s.owner_container, "profile": s.profile, "ageMinutes": s.age_minutes(), "tabs": tabs,+ "lastAgentUpdate": s.last_agent_update(), "hwnd": s.hwnd, "foreground": s.hwnd.is_some() && s.hwnd == Some(fg), "lastTakeoverBy": Value::Null, "cardShown": false, "captionShown": false,+ "_hint": "Info returned as JSON only — nothing was drawn on the user's screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar Info action gets the on-screen card; pass showCaption:true to draw the native card yourself." }))+}++/// Birth of an extension-lane window as a FIRST-CLASS SESSION: same Session struct, same insert/save,+/// same identity stamp, overlay, progress and attention-clear the CDP birth runs. Only the driving differs.+type BirthRx = std::sync::mpsc::Receiver<(i64, bool, bool, u128, u128, Option<u128>)>;++/// ONE birth watcher for BOTH lanes (2.0.567). The CDP lane has had this since 2.0.446: register the AUMID+/// ahead of time, then a thread started BEFORE the window exists polls every top-level frame every+/// millisecond and stamps the newborn natively while it is still invisible, so the shell bakes pup's icon+/// into the button at its first show. The extension lane skipped it and every ext window was born on the+/// stock Chrome icon until a later stamp (John, 2026-09-14: "you sure don't seem to be branding that windows+/// taskbar icon very early in the process").+fn birth_watch(ctx: &Ctx, caller: &Caller, sid: &str, plan_name: &str, url: &str, before: &std::collections::HashSet<i64>, taken: &std::collections::HashSet<i64>, ext: bool) -> (Option<(String, String, std::path::PathBuf)>, bool, Option<BirthRx>) {+ let plan = identity::plan(ctx, sid, plan_name, url);+ let early_ok = plan.as_ref().map(|(a, d, i)| identity::preregister(ctx, caller, a, d, i)).unwrap_or(false); // used by the watcher thread below+ let lane_pids_now: Vec<u32> = if ext { crate::extdrive_pids() } else { Vec::new() }; // 2.0.659: THIS lane's pids, read on the verb thread+ let watcher = ctx.arc().map(|a| {+ let (tx, rx) = std::sync::mpsc::channel::<(i64, bool, bool, u128, u128, Option<u128>)>();+ let before2 = before.clone(); let taken2 = taken.clone(); let plan2 = plan.clone(); let sid2 = sid.to_string(); let t_w = Instant::now(); let lane_pids = lane_pids_now.clone();+ std::thread::Builder::new().name("birth-watch".into()).spawn(move || {+ let mut found: Option<pup_host::HostWindow> = None;+ let mut ambiguous = 0usize;+ while t_w.elapsed() < Duration::from_secs(4) {+ // Class Chrome_WidgetWin_1 is ALSO Edge's: John's own Edge window is one, so an unfiltered+ // diff can stamp pup's tile onto his browser. Filter to the lane's own Chrome pids.+ let pids = if ext { if lane_pids.is_empty() { crate::extdrive_pids() } else { lane_pids.clone() } } else { Vec::new() };+ let cands: Vec<pup_host::HostWindow> = a.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter()+ .filter(|w| !before2.contains(&w.hwnd) && !taken2.contains(&w.hwnd))+ .filter(|w| pids.is_empty() || pids.contains(&w.pid))+ .collect();+ // R4 / pup-window-targeting: "If >1 window appears under the lock it refuses to guess and+ // logs loudly - a wrong hwnd paints another window's badge and parks the wrong window, so+ // late beats wrong." Keep polling; the extra startup frame usually closes and leaves one.+ if cands.len() > 1 { ambiguous = cands.len(); std::thread::sleep(Duration::from_millis(1)); continue; }+ if let Some(w) = cands.into_iter().next() { found = Some(w); break; }+ std::thread::sleep(Duration::from_millis(1));+ }+ let Some(w) = found else {+ if ambiguous > 1 { pup_core::log(format!("[birth] \"{sid2}\" REFUSED to guess its window: {ambiguous} new Chrome frames were up under the birth lock and none resolved unambiguously - leaving it BARE rather than stamping another thread's window (bare > wrong)")); }+ return;+ };+ let t_found = t_w.elapsed().as_millis();+ let mut stamped = false; let mut t_stamp = 0u128;+ if let (true, false, Some((app_id, display, icon))) = (early_ok, w.visible, plan2.as_ref()) {+ stamped = a.host.stamp_identity(w.hwnd, app_id, display, &icon.to_string_lossy(), &identity::relaunch_for(&sid2));+ t_stamp = t_w.elapsed().as_millis();+ }+ // 2.0.639: PARK WHILE INVISIBLE. The z-watch (2.0.638) showed Chrome's first show putting every newborn+ // on top for 140-250 ms until the park that waited for the token bind. The watcher holds the handle+ // ~150 ms before that first show; bottom it now, so the first frame Chrome draws is already behind.+ let mut pre_parked = false;+ if !w.visible { pre_parked = a.host.send_to_bottom_no_activate(w.hwnd); }+ let mut t_vis = if w.visible { Some(t_found) } else { None };+ if pre_parked {+ // 2.0.640: native spin, bottomed in the same instant it shows, held 300 ms (see park_on_first_show)+ match a.host.park_on_first_show(w.hwnd, 6000) {+ Some(ms) => { t_vis = Some(t_w.elapsed().as_millis()); pup_core::log(format!("[window] \"{sid2}\" pre-parked while invisible, shown {ms}ms later and bottomed in that instant, held for 300 ms")); }+ None => { pup_core::log(format!("[window] \"{sid2}\" pre-parked while invisible, never shown within 6 s")); }+ }+ }+ while t_vis.is_none() && t_w.elapsed() < Duration::from_secs(6) { if a.host.frame_windows_all(Some("Chrome_WidgetWin_1")).iter().any(|x| x.hwnd == w.hwnd && x.visible) { t_vis = Some(t_w.elapsed().as_millis()); } else { std::thread::sleep(Duration::from_millis(1)); } }+ let _ = tx.send((w.hwnd, w.visible, stamped, t_found, t_stamp, t_vis));+ }).ok();+ rx+ });+ (plan, early_ok, watcher)+}++/// Wait for the watcher and log the birth timeline. Some((hwnd, stamped_before_first_show)).+fn birth_report(watcher: Option<BirthRx>, sid: &str) -> Option<(i64, bool)> {+ let rx = watcher?;+ let Ok((h, was_visible, stamped, t_found, t_stamp, t_vis)) = rx.recv_timeout(Duration::from_secs(6)) else { return None };+ pup_core::log(format!("[window] \"{sid}\" newborn hwnd={h} caught {} at +{t_found}ms{}; shown at {}", if was_visible { "ALREADY VISIBLE (early stamp missed)" } else { "invisible" },+ if stamped { format!(", identity stamped natively at +{t_stamp}ms (before the first show)") } else if !was_visible { " (not stamped: no plan or registration)".to_string() } else { String::new() },+ t_vis.map(|t| format!("+{t}ms")).unwrap_or_else(|| "never (within 6 s)".into())));+ Some((h, stamped))+}++/// 2.0.638 instrumentation (2.0.636 watched only AFTER the park and saw nothing once ab's clear was gone, while+/// the gate still caught the NEWBORN on top ~1 s after the open began, sometimes on its birth page). This watches+/// from BEFORE the extension creates the window: whenever the top visible window is a lane Chrome window that did+/// not exist before this birth, log when, its title, whether it is the foreground, and the last thing pup logged.+fn spawn_zwatch(ctx: &Ctx, sid: &str, before: std::collections::HashSet<i64>) {+ let Some(a) = ctx.arc() else { return };+ let sid2 = sid.to_string(); let t0 = Instant::now();+ std::thread::Builder::new().name("zwatch".into()).spawn(move || {+ let mut on_top: Option<(i64, u128)> = None;+ while t0.elapsed() < Duration::from_millis(12000) {+ let pids = crate::extdrive_pids();+ let top = a.host.frame_windows_all(None).into_iter()+ .find(|w| w.visible && !w.title.is_empty() && w.rect.0 > -30000 && w.rect.2 > w.rect.0 + 50+ && !matches!(w.class.as_str(), "Windows.UI.Core.CoreWindow" | "Xaml_WindowedPopupClass" | "Shell_TrayWnd" | "Shell_SecondaryTrayWnd" | "Progman" | "WorkerW" | "TextInputHost" | "ApplicationFrameWindow" | "Windows.UI.Composition.DesktopWindowContentBridge"));+ let now = t0.elapsed().as_millis();+ let ours = top.as_ref().filter(|w| w.class == "Chrome_WidgetWin_1" && pids.contains(&w.pid) && !before.contains(&w.hwnd));+ match (ours, on_top) {+ (Some(w), None) => { on_top = Some((w.hwnd, now)); pup_core::log(format!("[zwatch] \"{sid2}\" lane window {} ON TOP at +{now}ms title=\"{}\" foreground={} last: {}", w.hwnd, w.title.chars().take(40).collect::<String>(), a.host.foreground() == w.hwnd, pup_core::last_log())); }+ (None, Some((h, t))) => { on_top = None; pup_core::log(format!("[zwatch] \"{sid2}\" lane window {h} left the top at +{now}ms after {}ms, last: {}", now - t, pup_core::last_log())); }+ _ => {}+ }+ std::thread::sleep(Duration::from_millis(15));+ }+ }).ok();+}+pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owner: Option<&str>, url: &str) -> Value {+ let t_verb = Instant::now();+ // 2.0.659 (pup-bridge#113): the lane is per profile. isolated:true minted an iso-<id> profile (passed as+ // _mintedProfile); a named profile is honoured too. Everything below talks to THAT profile's browser and queue.+ let profile_arg = args.get("_mintedProfile").and_then(|p| p.as_str()).or_else(|| args.get("profile").and_then(|p| p.as_str())).unwrap_or("adom-you").to_string();+ extdrive::enter_profile(&profile_arg); // 2.0.648 (pup-bridge#90): the early-return budget starts here+ if let Some(s) = ctx.sessions.get(sid) {+ if transport::is_ext(&s) {+ if url != "about:blank" { return tabs::open_tab(ctx, caller, &json!({ "window": sid, "url": url })); }+ return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "reused": true, "transport": "ext" }));+ }+ }+ // R4 / pup-window-targeting rule 2. The ext lane is ONE Chrome holding MANY threads' windows, so an+ // unlocked set-diff is exactly how all-engineering, NSF and pup-bridge all ended up on hwnd 590484.+ #[allow(unused_assignments)]+ let mut birth_move_ms: Option<i64> = None; // 2.0.621: R1's on-screen move, measured by the extension; set by whichever open arm runs+ let mut birth_token = String::new(); // 2.0.630: the birth page's title token, the deterministic handle+ let birth_guard = BIRTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ let cold = !extdrive::alive(ctx);+ if cold {+ // 2.0.545: launch on about:blank, apply the consumer identity (wire rule + JS shim), and only then go+ // to the requested URL, so the FIRST real request already carries the installed Chrome's identity.+ // Launching straight on the URL loaded that page before the rule existed (measured: the launch page+ // echoed CfT 148 while the next navigation echoed 152).+ // 2.0.586: a cold open BLOCKS until the lane is up, with one relaunch if the first launch never calls+ // home (a Chrome still shutting down after the last close makes the new one forward to it and exit).+ // Answering `starting:true` with no session made the caller's very next verb fail with+ // session_not_found (parity's pup_open_tab, and other threads' "hard time opening pup", 2026-09-15).+ // 2.0.613 (John, 2026-09-17, shouting, and right): "IF YOU CAN'T FUCKING CREATE A PUP WINDOW IN THE+ // BACKGROUND THEN FUCKING WARN ME AS THE USER YOU'RE ABOUT TO SHOVE A WINDOW IN FRONT OF MY FACE".+ // A warm open is invisible now (created minimized, shown already at the bottom). A COLD one starts the+ // browser, and Chrome draws its own startup window before pup owns anything, so it can still flash.+ // That case gets a warning BEFORE it happens, with a beat for him to see it, never an apology after.+ // 2.0.617: no prompt. The cold start draws nothing now (--no-startup-window + --keep-alive-for-test),+ // so there is nothing to ask about: "being asked even that once is too much" (John, 2026-09-18).+ for attempt in 1..=2u32 {+ // 2.0.632: no url. "about:blank" here overrode --no-startup-window and made an unowned about:blank+ // window that sat on top of the user's work for 18 s on every cold start (measured, 2.0.631).+ if let Err(e) = launch_extdrive(ctx, "") { if attempt == 2 { return e; } else { continue; } }+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(25) { if extdrive::connected_ms_ago() < 4000 { break; } std::thread::sleep(Duration::from_millis(300)); }+ if extdrive::alive(ctx) { break; }+ if attempt == 1 {+ pup_core::log("[lane] the launched browser never called home within 25 s; ending it and launching once more".to_string());+ kill_extdrive(ctx); std::thread::sleep(Duration::from_millis(1500));+ }+ }+ if !extdrive::alive(ctx) { return err("ext_lane_start_failed", "the extension-driven browser did not start (two launches, no worker poll within 25 s each)", Some("pup_readiness shows the browser state; a Chrome for Testing still holding the profile is the usual cause. pup_close {} then open again.")); }+ }+ // 2.0.635: arm the birth watcher HERE, after a cold launch, not before it. The watcher lives 4 s; armed+ // before the launch it expired while Chrome was still starting, so a cold birth never got the early native+ // stamp and wore the stock Chrome-for-Testing icon until the deferred navigate finished (measured on+ // John's laptop, 2026-09-19: 12 s of the wrong icon; warm births were stamped before their first show).+ let taken = taken_hwnds(ctx, sid);+ let before: std::collections::HashSet<i64> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();+ // the same birth watcher the CDP lane uses: identity on the window BEFORE Chrome shows it+ let plan_name = owner.map(|o| o.to_string()).unwrap_or_else(|| sid.to_string());+ let (plan, _early_ok, watcher) = birth_watch(ctx, caller, sid, &plan_name, url, &before, &taken, true);+ spawn_zwatch(ctx, sid, before.clone());+ // the tab and Chrome window for this session: the launch URL's tab on a cold start, a NEW window otherwise+ let (chrome_tab, chrome_win, got_url, got_title) = if cold {+ extdrive::apply_consumer_brands(ctx);+ // 2.0.617: the lane launches with --no-startup-window, so there is no tab to adopt. Create the first+ // window the same way as every later one: through the extension, minimized, shown at the bottom.+ match extdrive::run(json!({ "action": "open", "url": "about:blank" }), Duration::from_secs(25)) {+ Ok(r) => {+ birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string();+ let t = r.clone();+ let id = t["tabId"].as_i64().unwrap_or(0);+ // The real page is loaded AFTER the birth lock is released (below): a 25 s navigate held+ // under the lock would serialize every other thread's window birth behind this one.+ (id, t["windowId"].as_i64(), url.to_string(), String::new())+ }+ Err(e) => return e,+ }+ } else {+ match extdrive::run(json!({ "action": "open", "url": url }), Duration::from_secs(20)) {+ Ok(r) => { birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string(); (r["tabId"].as_i64().unwrap_or(0), r["windowId"].as_i64(), url.to_string(), String::new()) }+ Err(e) => {+ // 2.0.577: a lane whose worker polled within the last few seconds is BUSY, not dead: relaunching it+ // here killed every thread's windows because one open waited behind a long capture (NSF's open,+ // 18:28 on 2026-09-14). Fail THIS open and say so; only a silent worker gets the relaunch.+ // 2.0.622: only a NON-ANSWER is "busy". The worker answering with an error (measured: Chrome+ // refusing a window's bounds) was being relabelled ext_busy with a "retry in a few seconds"+ // hint, which is a lie that sends a caller into a retry loop against a deterministic failure.+ let answered = e["errorCode"].as_str().map(|c| c != "ext_no_answer").unwrap_or(false) && e["error"].as_str().map(|m| !m.contains("did not answer")).unwrap_or(true);+ if answered {+ pup_core::log(format!("[lane] warm open refused by the browser: {}", e["error"].as_str().unwrap_or("?")));+ return pup_core::merge(e, json!({ "errorCode": "ext_open_failed", "_hint": "The browser answered and refused to create the window; the error is Chrome's own. This is not a busy lane and a retry will fail the same way." }));+ }+ if extdrive::connected_ms_ago() < 8000 {+ pup_core::log(format!("[lane] warm open not answered but the worker polled {} ms ago: busy, not relaunching", extdrive::connected_ms_ago()));+ return pup_core::merge(e, json!({ "errorCode": "ext_busy", "_hint": "The extension lane is alive but did not answer in time (another verb is holding it). Retry the open in a few seconds; pup did NOT restart anything." }));+ }+ // the heartbeat is stale and nothing answered: treat it as cold and launch+ pup_core::log("[lane] the extension channel did not answer a warm open and the worker is silent; relaunching the lane".to_string());+ extdrive::mark_dead();+ let mut a = args.clone(); a["lane"] = json!("ext");+ // 2.0.623: RELEASE THE BIRTH LOCK before re-entering. This call re-enters open_window_ext, whose+ // first act is to take BIRTH_LOCK, which is not re-entrant: the open deadlocked on itself and every+ // later open queued behind it forever (measured on the conference-room box, 2026-09-18: exact+ // 60 s timeouts on every open, a lane that read dead, and not one launch line in the log). It bites+ // whenever the browser dies between a poll and an open, which is exactly what the foreground+ // gate does on purpose and what a user closing the browser does by accident.+ drop(birth_guard);+ return open_window_ext(ctx, caller, &a, sid, owner, url);+ }+ }+ };+ let mut s = Session::new(sid, &profile_arg, owner, Some(&caller.container), 0, None);+ s.transport = "ext".into(); s.window_id = chrome_win;+ s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none();+ let t = transport::ext_tab(&mut s, chrome_tab, &got_url, &got_title); s.tabs.push(t.clone()); s.active_tab = 0;+ let mut early_stamped = false;+ // 2.0.630: bind by the birth TOKEN first. The set-diff bound a placeholder Chrome makes at launch and pup+ // parked that while the real window came up unparked (measured: 4.5 s on top on every cold start). The+ // set-diff handle is only a fallback now, and is trusted only when it agrees with the token.+ let by_token = if birth_token.is_empty() { None } else { extdrive::hwnd_by_birth_token(ctx, &birth_token, &taken, Duration::from_secs(3)) };+ match (by_token, birth_report(watcher, sid)) {+ (Some(h), Some((wh, stamped))) => { s.hwnd = Some(h); early_stamped = stamped && wh == h; if wh != h { pup_core::log(format!("[window] \"{sid}\" the set-diff caught window {wh}, the birth token names {h}; bound by token")); } }+ (Some(h), None) => { s.hwnd = Some(h); }+ (None, Some((wh, stamped))) => { s.hwnd = Some(wh); early_stamped = stamped; pup_core::log(format!("[window] \"{sid}\" birth token {birth_token} not found within 3 s; bound the set-diff handle {wh}")); }+ (None, None) => { if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { s.hwnd = Some(h); } }+ }+ // 2.0.635 backstop: if the watcher missed the invisible moment, stamp NOW, at the bind, before the first+ // show below. The button exists already (a minimized window has one), but the stock icon then lasts the+ // milliseconds to this call instead of the seconds to the deferred navigate and the full identity pass.+ if !early_stamped {+ if let (Some(h), Some((app_id, display, icon))) = (s.hwnd, plan.as_ref()) {+ let t_s = Instant::now();+ if ctx.host.stamp_identity(h, app_id, display, &icon.to_string_lossy(), &identity::relaunch_for(sid)) {+ early_stamped = true;+ pup_core::log(format!("[window] \"{sid}\" identity stamped at the bind in {}ms (the birth watcher missed the invisible moment)", t_s.elapsed().as_millis()));+ }+ }+ }+ // 2.0.604 (John, 2026-09-17: "why do you keep shoving windows in front of me without warning me?+ // ... you shoved the window in my face while i was busy typing in another window"). Chrome creates the+ // window unfocused but at z1, ON TOP of the user's work, and the park used to run AFTER the deferred+ // navigate, the consumer-brand pass, the orphan sweep, the fit and the jump-list commit: measured 4.0 s+ // of a pup window sitting over his screen, every open. Park the INSTANT the handle exists, before any of+ // that. This is one Win32 call with no activation, so it is safe to hold the birth lock across.+ if let Some(h) = s.hwnd {+ let t_park = Instant::now();+ // 2.0.624: this lane's R1 in two halves. The extension birthed the window MINIMIZED at its final rect+ // (the API refuses off-screen, and a visible birth lands on top: measured). This one call orders it to+ // the bottom while it is still minimized, restores it without activation, and orders it again, so the+ // first frame it is ever drawn in is already behind the user's work. Minimized was a transient measured+ // in the hundreds of milliseconds (the handle wait); it can never strand, a taskbar click restores it.+ let parked = ctx.host.show_at_bottom_no_activate(h);+ pup_core::log(format!("[window] \"{sid}\" birth: off-screen create, on-screen move {} (budget 50 ms), parked at z-bottom {}ms after the handle resolved (ext lane): {parked}",+ match birth_move_ms { Some(m) if m >= 0 => format!("{m} ms"), _ => "unmeasured".into() }, t_park.elapsed().as_millis()));+ if birth_move_ms.map(|m| m > 50).unwrap_or(false) { pup_core::log(format!("[window] \"{sid}\" DEFECT: the on-screen move took {} ms, over the 50 ms rule (pup-bridge-dev R1)", birth_move_ms.unwrap_or(0))); }+ // 2.0.627: the FIRST window of a freshly started browser settles ON TOP after every park pup makes+ // at birth (measured on the conference-room box with the fixed bottom call: a warm birth ended at z11,+ // behind, the cold one at z0 and stayed). Something in Chrome raises its first window after pup is+ // done. The doctrine's answer is the bounded re-assert inside the birth grace, which the CDP lane has+ // run since v1.9.201 and this lane opted out of on the claim that Chrome never self-raises an+ // unfocused window; true for a warm birth, false for a cold one. Safe by construction: the primitive+ // never touches a window that is foreground, so a human who clicked it is left alone.+ // 2.0.629: EVERY birth, not only a cold one. Measured: with the cold re-assert in place the cold window was+ // pulled back down within seconds, and the WARM windows (B, C) came up on top and stayed, because they got+ // no re-assert. "Chrome never self-activates an unfocused-created window on this lane" is false for a+ // minimized birth restored by pup; the fit and the identity work that follow the park can raise it too.+ schedule_birth_reassert(ctx, sid, if cold { "cold birth" } else { "birth" });+ }+ drop(birth_guard); // hwnd resolved (or refused): the next thread's birth may start+ // the deferred cold navigate, now that no other birth is blocked behind it+ // 2.0.630: EVERY birth navigates from the birth page to its real URL here, after the window is bound and+ // parked, so the page the user cares about never loads into a window pup does not yet hold.+ if url != "about:blank" {+ // 2.0.595: the session OWNS its window from here. It used to be inserted only after this navigate, so for+ // up to 25 s the lane's only window was an unowned about:blank, and the health tick's orphan sweep closed+ // it: Chrome exited with its last window, the worker went silent, and 190 s later the "dead lane" path+ // relaunched every session on disk, including six windows John had closed by hand (AdomLapper 21:41,+ // 2026-09-15). That was also the "worker stalls right after a cold launch" mystery.+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ // 2.0.648 (pup-bridge#90, Bridge SDK #59): a slow box can spend the whole 60 s ab budget in the cold launch+ // alone; the window EXISTS by now (bound, parked, owned), so answer with loading:true and finish the+ // navigate, fit and identity in the background instead of letting ab time the verb out after success.+ if t_verb.elapsed() > Duration::from_secs(35) {+ if let Some(a) = ctx.arc() {+ let (sid2, url2, tab2, caller2) = (sid.to_string(), url.to_string(), chrome_tab, caller.clone());+ std::thread::Builder::new().name("open-finish".into()).spawn(move || {+ let ctx2: &Ctx = &a;+ if let Ok(nav) = extdrive::run(json!({ "action": "navigate", "url": url2, "tabId": tab2, "waitMs": 25000 }), Duration::from_secs(30)) {+ ctx2.sessions.with_mut(&sid2, |x| if let Some(t0) = x.tabs.get_mut(0) { if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } } if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } } });+ if let Some(ss) = ctx2.sessions.get(&sid2) { ctx2.sessions.save_file(&ss); }+ }+ extdrive::fit_now();+ identity::stamp_opts(&ctx2, &sid2, &caller2, true, true);+ pup_core::log(format!("[window] \"{sid2}\" slow open finished in the background ({} s after the verb began)", t_verb.elapsed().as_secs()));+ }).ok();+ }+ return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "tabId": t.tab_id, "hwnd": s.hwnd, "windowId": s.window_id, "transport": "ext", "loading": true,+ "progress": { "phase": "navigating", "elapsedSec": t_verb.elapsed().as_secs(), "percent": 70 },+ "_hint": "This box is slow: the window exists, is yours and is parked in the background, and the page is still loading. Poll pup_window_info {} (url/title fill in) or pup_readiness (active[]) rather than opening again; a second open with the same window label is refused as owned." }));+ }+ let nav = match extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)) {+ Ok(v) => v,+ Err(e) => {+ if s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(false) && !extdrive::alive(ctx) {+ ctx.sessions.remove(sid); ctx.sessions.delete_file(sid);+ pup_core::log(format!("[window] \"{sid}\": the lane browser died during the cold open (its window is gone and the worker is silent); no session"));+ return pup_core::merge(e, json!({ "errorCode": "ext_lane_died_during_open", "_hint": "The lane browser exited while this window was being opened. Open again; pup did not keep a dead session." }));+ }+ Value::Null+ }+ };+ if let Some(t0) = s.tabs.get_mut(0) {+ if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } }+ if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } }+ }+ }+ if early_stamped { if let Some((a, _, _)) = &plan { s.app_id = Some(a.clone()); } }+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ // 2.0.653: page scripts + wiki probe on the extension lane's first page (the CDP open always did both)+ let mut wiki_extra = json!({});+ if let (Ok(lane), Some(t0)) = (transport::lane_for(ctx, &s), s.tabs.get(0).cloned()) { if t0.url != "about:blank" { inject_page_scripts_ext(ctx, &s, &lane, &t0); wiki_extra = wiki::after_navigate_ext(ctx, &s, &lane, &t0, &t0.url, args.get("wikiView").and_then(|v| v.as_str()), false); } }+ extdrive::apply_consumer_brands(ctx); // once per lane browser; before the first page the user cares about+ extdrive::close_orphan_blank_windows(ctx, chrome_win); // the startup window a relaunch left behind, if any+ extdrive::fit_now();+ // 2.0.577: ALWAYS a full pass, as the CDP path does after its early stamp. The in-process birth stamp+ // sets the AUMID but never commits a jump list, and stamp_opts only commits one inside its full/changed+ // block; with full=false and app_id already set, ext windows were born with a tile and NO right-click+ // menu (John, 2026-09-14: "a shotlog pup window but no jump list"). Same AUMID again = no button rebuild.+ let t_jl = Instant::now();+ identity::stamp_opts(ctx, sid, caller, true, true);+ if early_stamped { pup_core::log(format!("[window] \"{sid}\" jump list committed on the early-stamped button in {}ms", t_jl.elapsed().as_millis())); }+ if let Some(h) = s.hwnd {+ // 2.0.584: R1, "background means BEHIND". chrome.windows.create({focused:false}) never steals focus but lands+ // the window at z1, right under the user's active window and above everything else (measured: z1 of 17,+ // still z1 twenty seconds later). Park it to the bottom once, now. No +2.5 s re-assert on this lane: Chrome+ // never self-activates an unfocused-created window (measured), so a re-assert could only undo a human click.+ // the early park above already put it behind. Re-assert ONLY if it is still on top and the user has+ // not taken it: a blind re-assert here would undo a click the human made in the meantime.+ if ctx.host.foreground() != h { let _ = ctx.host.send_to_bottom_no_activate(h); }+ activity::on_open(ctx, sid, h, caller); activity::clear_attention_later(ctx, h); // 2.0.637: never ab's clear here (it raises + activates)+ }+ if let Ok(lane) = transport::lane_for(ctx, &s) { let mut s2 = s.clone(); transport::refresh_all(&lane, &mut s2); ctx.sessions.insert(s2.clone()); ctx.sessions.save_file(&s2); s = s2; }+ // 2.0.621: no focus handback anywhere. pup-user-foreground deleted it in August because it foregrounded+ // the wrong window; R2: if a window fronts anyway, let the flaw exist and log it, never fight it.+ // Get the icon right AT BIRTH, not on a later tick (John, 2026-09-14). The birth wait can bind the+ // transient about:blank launch window Chrome replaces; the stamp above then lands on a dead handle.+ // Now that the real page is up: if the bound hwnd is not a live window, bind the live lane window+ // and stamp it before this verb returns.+ if s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(true) {+ if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) {+ ctx.sessions.with_mut(sid, |x| x.hwnd = Some(h)); s.hwnd = Some(h); ctx.sessions.save_file(&s);+ identity::stamp(ctx, sid, caller, true); activity::on_open(ctx, sid, h, caller);+ // 2.0.628: the birth park ran on the placeholder, so this window has never been parked. Measured: it+ // came up on top and stayed there (the placeholder was rebound 6 s after the park, after every+ // re-assert had fired on the dead handle). Park it NOW and re-assert from here.+ let parked = ctx.host.send_to_bottom_no_activate(h);+ pup_core::log(format!("[ext] birth bound a transient window; rebound \"{sid}\" to the live window {h}, stamped it and parked it at z-bottom: {parked}"));+ schedule_birth_reassert(ctx, sid, "late rebind");+ }+ }+ let t = s.active().cloned().unwrap_or(t);+ pup_core::merge(ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "hwnd": s.hwnd, "windowId": s.window_id, "transport": "ext", "landed": landed_ext(ctx, &s, &t, &t.url, &t.title),+ "_hint": "Extension-driven window (no CDP debug port): a first-class pup window. Drive it with pup_navigate / pup_eval / pup_screenshot / pup_open_tab like any other." })), wiki_extra)+}++/// pup_clear_cookies {domain, names?}: drop a site's cookies in this window's jar, HttpOnly included. The case+/// that made it exist: a bot manager's verdict cookie (Akamai _abck at ~-1~) that keeps a jar denied long after+/// the browser itself looks fine (analog.com: loads on a fresh jar, Access Denied on adom-you, John 2026-09-13).+fn clear_cookies(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let Some(domain) = args["domain"].as_str().filter(|d| !d.trim().is_empty()) else { return err("domain_required", "pup_clear_cookies needs {domain}", Some("e.g. {domain:\"analog.com\"} removes analog.com and *.analog.com cookies; names:[...] narrows to those cookie names.")) };+ let names: Vec<String> = args["names"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ match transport::clear_cookies(&lane, domain, &names) {+ Ok(n) => { pup_core::log(format!("[cookies] \"{}\": removed {n} cookie(s) for {domain}{}", s.id, if names.is_empty() { String::new() } else { format!(" ({})", names.join(",")) })); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "domain": domain, "removed": n, "transport": s.transport })) }+ Err(e) => e,+ }+}++/// pup_clear_site_data {origin | origins:[...]}: cache, cookies, storage and service workers for those origins.+fn clear_site_data(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let mut origins: Vec<String> = args["origins"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();+ if let Some(o) = args["origin"].as_str() { origins.push(o.to_string()); }+ if origins.is_empty() { return err("origin_required", "pup_clear_site_data needs {origin} or {origins:[...]}", Some("e.g. {origins:[\"https://www.analog.com\",\"https://analog.com\"]}")); }+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let lane = match transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ match transport::clear_site_data(&lane, &origins) {+ Ok(()) => { pup_core::log(format!("[sitedata] \"{}\": cleared {:?}", s.id, origins)); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "origins": origins, "transport": s.transport })) }+ Err(e) => e,+ }+}++pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ // pup_close {} with no ids closes the caller's window; pup_close {all:true} closes everything of theirs.+ let mut targets: Vec<Session> = Vec::new();+ if args.get("all").and_then(|b| b.as_bool()) == Some(true) {+ // all:true = the caller's windows; everyone:true (the jump list's "Close ALL Adom Pup windows", a+ // user action) = every window on this desktop whoever owns it. Node: pup_close {all, everyone}.+ // 2.0.491: `everyone:true` closes EVERY pup window on the desktop, including other threads' work.+ // That is the jump list's "Close ALL pup windows" — a USER action. The Rust port left it open to any+ // AI thread, so one thread could wipe another's windows with no refusal and no trace. Now only a human+ // caller gets it; an AI asking for it closes its OWN windows and is told why.+ let asked_everyone = args.get("everyone").and_then(|b| b.as_bool()) == Some(true);+ let human = caller.thread.is_empty() || USER_CALLERS.contains(&caller.thread.as_str());+ let everyone = asked_everyone && human;+ if asked_everyone && !human { pup_core::log(format!("[close] thread \"{}\" asked to close EVERYONE's windows; refused — closing only its own (everyone:true is the jump list's user action)", caller.thread)); }+ targets = if everyone { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) };+ // 2.0.476: cancel any in-flight restore of the windows being closed and remove their files, so the close is+ // INSTANT (no 40 s drain that blocks the user and outlives the CLI request) and a relaunch that is mid-flight+ // undoes its own window instead of leaving it behind (close-during-restore cycle). A reserved id with no live+ // session yet is a window a restore is bringing back right now: cancel and unfile it too.+ let mut cancel_ids: Vec<String> = targets.iter().map(|s| s.id.clone()).collect();+ if everyone || caller.thread.is_empty() { for f in ctx.sessions.read_files() { if let Some(id) = f["sessionId"].as_str() { if !cancel_ids.iter().any(|c| c == id) { cancel_ids.push(id.to_string()); } } } }+ for id in &cancel_ids { crate::recovery::cancel_restore(id); ctx.sessions.delete_file(id); }+ }+ else { match resolve(ctx, caller, args) { Ok(s) => targets.push(s), Err(e) => return e } }+ let mut closed = Vec::new(); let mut chrome_killed = 0; let mut failed: Vec<String> = Vec::new();+ for s in targets {+ // 2.0.470: the session and its file go FIRST. Closing the targets fires Target.targetDestroyed on the event+ // pump, whose handler re-saves the session file; when that save landed after the delete below, the file+ // came back (url about:blank, the other tabs listed) and the next bridge start relaunched a ghost window+ // from it (arav-rog, 2026-09-11: "drill-b" reborn with 5 tabs after an update).+ ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);+ let lane_closed = match transport::lane_for(ctx, &s) { Ok(lane) => transport::close_window(&lane, &s), Err(_) => false };+ // Verify the OS window is gone; a close that races a bridge respawn (no live CDP handle yet) used to+ // drop the session and leave the window behind (John's laptop, 2026-09-06: three leftovers wearing+ // old tiles). The window is closed through ab (WM_CLOSE, forced after 3 s) when it survives.+ // 2.0.587: an UNBOUND extension session (worker mid-reload, hwnd None) resolves its handle here first;+ // before this the OS close was skipped and eight ownerless windows were left on the user's glass.+ let hwnd = s.hwnd.or_else(|| if transport::is_ext(&s) { extdrive::lane_hwnd_for(ctx, &s) } else { None });+ let mut survived = false;+ if let Some(h) = hwnd {+ std::thread::sleep(Duration::from_millis(300));+ // macOS: the native handle outlives the close (Chrome destroys the NSWindow on a later UI turn), so a+ // lane that confirmed the close AND no longer lists the window is the verification there.+ let lane_gone = lane_closed && ctx.host.closed_window_may_linger()+ && !transport::lane_for(ctx, &s).map(|lane| transport::lane_has_window(&lane, &s)).unwrap_or(false);+ if lane_gone { pup_core::log(format!("[close] \"{}\" closed by the lane; native handle {h} may linger until the browser's next UI turn", s.id)); }+ else if ctx.host.is_alive(h) {+ let _ = ctx.ab.call("desktop_close_window", json!({ "hwnd": h, "forceAfterMs": 3000 }), caller, Duration::from_secs(6));+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(4) && ctx.host.is_alive(h) { std::thread::sleep(Duration::from_millis(250)); }+ survived = ctx.host.is_alive(h);+ }+ } else if !lane_closed { survived = true; } // nothing confirmed the close and there is no handle to force it+ if survived {+ // 2.0.587: never drop a session whose window is still on glass. Put it back and say so.+ let mut back = s.clone(); back.hwnd = hwnd; ctx.sessions.insert(back.clone()); ctx.sessions.save_file(&back);+ pup_core::log(format!("[close] \"{}\" did NOT close (lane answered: {lane_closed}, hwnd {:?} still alive); session kept so the window is never ownerless", s.id, hwnd));+ failed.push(s.id.clone());+ continue;+ }+ ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); closed.push(s.id.clone()); // again: a late save loses+ if let Some(a) = s.app_id.as_deref() { identity::release_identity(ctx, a); } // 2.0.666: no dead AUMID left behind+ // last ext window: let the lane browser go, the same rule the CDP browsers follow below+ if transport::is_ext(&s) && !ctx.sessions.all().iter().any(|x| transport::is_ext(x) && x.profile == s.profile) {+ // 2.0.659: the LAST window of this profile's lane ends that lane's browser (the default lane's rule unchanged)+ extdrive::enter_profile(&s.profile);+ let n = kill_extdrive(ctx); if n > 0 { chrome_killed += 1; }+ if s.profile != "adom-you" { extdrive::drop_lane_for_profile(&s.profile); }+ extdrive::leave_profile();+ }+ // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused)+ if ctx.sessions.for_profile(&s.profile).is_empty() {+ if s.profile.starts_with("iso-") {+ // 2.0.648 (pup-bridge#112): a throwaway profile dies with its last window; the browser is ended+ // just below, so remove the directory after it has let go (a few tries, then leave it for prewarm).+ let dir = pup_browser::profiles_dir(&*ctx.host).join(&s.profile); let sid = s.id.clone();+ let staging = ctx.host.state_dir().join(format!("extlane-{}", s.profile)); let _ = std::fs::remove_dir_all(&staging); // 2.0.660: the lane's staged extension goes with it+ std::thread::spawn(move || { for _ in 0..10 { std::thread::sleep(Duration::from_millis(1500)); if std::fs::remove_dir_all(&dir).is_ok() || !dir.exists() { pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} removed", dir.display())); return; } } pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} still held; left on disk", dir.display())); });+ }+ if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {+ let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();+ // give Chrome a moment to exit on its own; only then reap a straggler (never a slow one mid-write)+ if b.launched.pid > 0 { for _ in 0..12 { if !ctx.host.alive(b.launched.pid) { break; } std::thread::sleep(Duration::from_millis(250)); } if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } chrome_killed += 1; }+ }+ }+ }+ if !failed.is_empty() && closed.is_empty() { return pup_core::merge(err("close_failed", format!("{} window(s) did not close: {}", failed.len(), failed.join(", ")), Some("The window is still on screen and its session was kept. Retry pup_close_window; if the extension lane is mid-reload, wait a few seconds first.")), json!({ "failed": failed })); }+ ok(json!({ "closed": closed, "failed": failed, "remaining": ctx.sessions.count(), "activeSession": ctx.sessions.active_id(), "chromeProcsKilled": chrome_killed, "chromeProcsRemaining": ctx.browsers.lock().unwrap().len(), "_hint": "Closed. The sessionId is no longer valid; pup_open_window creates a fresh one.", "_next": ["pup_list_windows", "pup_open_window"] }))+}++#[allow(dead_code)]+fn _unused(_: Value) -> Value { blocked_on_user(false, "", "", json!({})) }+@@ -1,351 +1,352 @@⋯ 297 unchanged lines ⋯ "_saveChunk" => { if let Some((_, _, f, _, n)) = r.current.as_mut() { if let Ok(b) = pup_cdp::base64_decode(payload.trim_matches('"')) { let _ = f.write_all(&b); *n += 1; } } } "_recordingStopped" => { if let Some((id, path, f, t0, _)) = r.current.take() { drop(f); let ms = t0.elapsed().as_millis() as u64; let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); let _ = std::fs::write(path.with_extension("json"), json!({ "recordingId": id, "reason": r.reason, "durationMs": ms, "sizeKB": size / 1024, "filePath": path.to_string_lossy() }).to_string()); r.clips.push(json!({ "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "durationMs": ms, "sizeKB": size / 1024 })); pup_core::log(format!("[recorder] {id} stopped: {} bytes, {ms}ms", size)); } } "_recorderClosed" => { pup_core::log("[recorder] HUD closed by the user"); *st = None; break; }- "_openRecordingsFolder" => { let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn(); }+ "_openRecordingsFolder" => { let opener = if cfg!(windows) { "explorer.exe" } else if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&dir).spawn(); } _ => {} } }⋯ 44 unchanged lines ⋯ let summary = json!({ "clipCount": r.clips.len(), "totalSizeKB": r.clips.iter().map(|c| c["sizeKB"].as_u64().unwrap_or(0)).sum::<u64>(), "reason": r.reason, "clips": r.clips }); ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "sessionSummary": summary }).to_string() })) }+@@ -1,304 +1,317 @@⋯ 206 unchanged lines ⋯ } } +/// Does the lane still hold this session's window? Ext: any tab whose windowId is the session's. CDP: any of+/// the session's targets still attached. The lane's word, for hosts whose native handle outlives a close.+pub fn lane_has_window(lane: &Lane, s: &Session) -> bool {+ match lane {+ Lane::Cdp(b) => { let live = b.cdp.targets().unwrap_or_default(); s.tabs.iter().any(|t| live.iter().any(|v| v["targetId"].as_str() == Some(t.target_id.as_str()))) }+ Lane::Ext => match s.window_id {+ Some(w) => match run(json!({ "action": "tabs" }), 10) { Ok(r) => r["tabs"].as_array().map(|ts| ts.iter().any(|t| t["windowId"].as_i64() == Some(w))).unwrap_or(false), Err(_) => true },+ None => false,+ },+ }+}+ /// Download a URL through the browser. Ext: chrome.downloads in the control extension. CDP callers keep the /// existing Browser.setDownloadBehavior path in extras.rs. pub fn download(lane: &Lane, url: &str, save_as: Option<&str>, wait_ms: u64) -> Result<Value, Value> {⋯ 89 unchanged lines ⋯ } } }+@@ -1,127 +1,131 @@⋯ 45 unchanged lines ⋯ let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} needs this window on your screen"), "body": reason, "focus": { "hwnd": h }, "level": "warning" }), caller, AB_T); std::thread::sleep(Duration::from_millis(1200)); }- let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);- let applied = r.is_ok();- if !applied { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }+ // macOS raises natively (ab cannot target another app's window by handle there); Windows goes through ab.+ let applied = if ctx.host.raise_window(h) { true } else {+ let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);+ if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }+ r.is_ok()+ }; ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); }); touch(ctx, &s.id, Some(reason)); if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); } ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "toasted": !human, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] }))⋯ 8 unchanged lines ⋯ let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500); let _ = ctx.ab.call("desktop_caption", json!({ "text": caption, "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T); }- let applied = if ctx.host.foreground() == h { true } else {+ let applied = if ctx.host.foreground() == h { true } else if ctx.host.raise_window(h) { true } else { let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T); if r.is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); } r.is_ok()⋯ 55 unchanged lines ⋯ touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str())); ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "mode": mode, "painted": applied, "note": args.get("note").and_then(|n| n.as_str()) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." })) }+@@ -1,633 +1,640 @@⋯ 215 unchanged lines ⋯ return 'data:image/png;base64,' + r.data; } async function pupCaptureTab(t) {- try { return { dataUrl: await chrome.tabs.captureVisibleTab(t.windowId, { format: 'png' }), via: 'visible' }; }+ // mac port (2026-10-13): on macOS a window of a HIDDEN app (pup's park state there) never paints, and captureVisibleTab then+ // neither resolves nor rejects (measured 2026-10-13: 20 s to the command deadline, every time). Bound it like the+ // occlusion case and take the debugger frame, which does not need the window on glass.+ let timer = null;+ const bounded = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('captureVisibleTab did not settle within 3000 ms (window not painting)')), 3000); });+ try { return { dataUrl: await Promise.race([chrome.tabs.captureVisibleTab(t.windowId, { format: 'png' }), bounded]), via: 'visible' }; } catch (e) { const msg = String((e && e.message) || e);- if (!/readback|capture/i.test(msg)) throw e;+ if (!/readback|capture|settle/i.test(msg)) throw e; return { dataUrl: await pupCaptureViaDebugger(t.id), via: 'debugger (window occluded: ' + msg + ')' }; }+ finally { clearTimeout(timer); } } async function pupExtPollOnce() {⋯ 403 unchanged lines ⋯ chrome.alarms.create('pup-extdrive', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'pup-extdrive') pupExtPoll(); }); pupExtPoll();+@@ -0,0 +1,17 @@+{+ "manifest_version": 1,+ "name": "puppeteer",+ "version": "2.0.719",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.719/adom-bridge-puppeteer-macos-v2.0.719.zip",+ "sha256": "bcac1c76c806320c0309beea65824de3a68aeb132ad300b9ae2c06cb0396624a",+ "size": 8394844,+ "platform": "macos",+ "shell": "rust",+ "verbPrefixes": [+ "pup_",+ "browser_"+ ],+ "healthEndpoint": "/status",+ "statusVerb": "pup_readiness",+ "released_at": "2026-10-14T16:54:34Z"+}
Comments
Log in to comment.