app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
rust/: the tree 2.0.594 is built from, pushed whole (pup-bridge#117); ship.sh pushes it on every release from now on
44 files changed
+4383−357
rust/Cargo.lock+382−15rust/Cargo.toml+1−1rust/bridge/src/main.rs+25−1rust/browser/src/ext_assets.rs+22rust/browser/src/lib.rs+281−11rust/cdp/src/lib.rs+47−7rust/core/src/lib.rs+18−1rust/dashboard/assets/dashboard.html+8−6rust/dashboard/src/lib.rs+27rust/host-win/Cargo.toml+2rust/host-win/src/lib.rs+129−5rust/host/src/lib.rs+22rust/identity/Cargo.toml+5rust/identity/assets/badge-generic-pdf.pngrust/identity/assets/badge-generic-web.pngrust/identity/assets/fg-700.ttfrust/identity/src/badge.rs+153rust/identity/src/lib.rs+2−140rust/identity/src/tile.rs+171rust/identity/tests/svg_favicon.rs+13rust/identity/wiki-favicon.svg+1rust/session/src/lib.rs+16−3rust/vault/src/lib.rs+42rust/verbs/assets/consumer-eme.js+34rust/verbs/assets/render-probe.js+10rust/verbs/assets/verb-catalog.json+124rust/verbs/src/activity.rs+26−8rust/verbs/src/annotate.rs+43−35rust/verbs/src/events.rs+49−2rust/verbs/src/extdrive.rs+667rust/verbs/src/extras.rs+134−6rust/verbs/src/identity.rs+129−39rust/verbs/src/input.rs+86−8rust/verbs/src/lib.rs+816−43rust/verbs/src/park.rs+25−3rust/verbs/src/record.rs+67−1rust/verbs/src/recovery.rs+382−12rust/verbs/src/settings.rs+6−1rust/verbs/src/tabs.rs+48−5rust/verbs/src/transport.rs+260rust/verbs/src/vault.rs+43rust/verbs/src/window.rs+19tools/gen-assets.py+43−4tools/ship.sh+5rust/Cargo.lock+382−15@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-browser", "pup-cdp",@@ -24,14 +24,39 @@ dependencies = [ "pup-verbs", "serde_json", "tiny_http",+ "ureq", ] +[[package]]+name = "allocator-api2"+version = "0.2.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"++[[package]]+name = "arrayref"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"++[[package]]+name = "arrayvec"+version = "0.7.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"+ [[package]] name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]]+name = "autocfg"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"+ [[package]] name = "base64" version = "0.22.1"@@ -44,6 +69,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]]+name = "bitflags"+version = "2.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"+ [[package]] name = "block-buffer" version = "0.10.4"@@ -53,12 +84,24 @@ dependencies = [ "generic-array", ] +[[package]]+name = "bytemuck"+version = "1.25.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"+ [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]]+name = "byteorder-lite"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"+ [[package]] name = "bytes" version = "1.12.1"@@ -87,6 +130,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]]+name = "color_quant"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"++[[package]]+name = "core_maths"+version = "0.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"+dependencies = [+ "libm",+]+ [[package]] name = "cpufeatures" version = "0.2.17"@@ -121,6 +179,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]]+name = "data-url"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"+ [[package]] name = "digest" version = "0.10.7"@@ -148,6 +212,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]]+name = "euclid"+version = "0.22.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"+dependencies = [+ "num-traits",+]+ [[package]] name = "fdeflate" version = "0.3.7"@@ -174,6 +247,28 @@ dependencies = [ "zlib-rs", ] +[[package]]+name = "float-cmp"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"++[[package]]+name = "foldhash"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"++[[package]]+name = "fontdue"+version = "0.9.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d"+dependencies = [+ "hashbrown 0.15.5",+ "ttf-parser",+]+ [[package]] name = "form_urlencoded" version = "1.2.2"@@ -204,6 +299,27 @@ dependencies = [ "wasi", ] +[[package]]+name = "gif"+version = "0.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"+dependencies = [+ "color_quant",+ "weezl",+]++[[package]]+name = "hashbrown"+version = "0.15.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"+dependencies = [+ "allocator-api2",+ "equivalent",+ "foldhash",+]+ [[package]] name = "hashbrown" version = "0.17.1"@@ -336,6 +452,40 @@ dependencies = [ "icu_properties", ] +[[package]]+name = "image"+version = "0.25.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"+dependencies = [+ "bytemuck",+ "byteorder-lite",+ "color_quant",+ "gif",+ "image-webp",+ "moxcms",+ "num-traits",+ "png 0.18.1",+ "zune-core",+ "zune-jpeg",+]++[[package]]+name = "image-webp"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"+dependencies = [+ "byteorder-lite",+ "quick-error",+]++[[package]]+name = "imagesize"+version = "0.13.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"+ [[package]] name = "indexmap" version = "2.14.2"@@ -343,7 +493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent",- "hashbrown",+ "hashbrown 0.17.1", ] [[package]]@@ -352,12 +502,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "kurbo"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"+dependencies = [+ "arrayvec",+ "euclid",+ "smallvec",+]+ [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]]+name = "libm"+version = "0.2.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"+ [[package]] name = "litemap" version = "0.8.3"@@ -396,6 +563,25 @@ dependencies = [ "simd-adler32", ] +[[package]]+name = "moxcms"+version = "0.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"+dependencies = [+ "num-traits",+ "pxfm",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]+ [[package]] name = "once_cell" version = "1.21.4"@@ -408,13 +594,32 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]]+name = "pico-args"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"+ [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [- "bitflags",+ "bitflags 1.3.2",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]++[[package]]+name = "png"+version = "0.18.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"+dependencies = [+ "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2",@@ -450,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-core", "pup-host",@@ -462,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.413"+version = "2.0.594" dependencies = [ "serde_json", "thiserror",@@ -472,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.413"+version = "2.0.594" dependencies = [ "serde", "serde_json",@@ -482,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-cdp", "pup-core",@@ -495,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.413"+version = "2.0.594" dependencies = [ "serde", "serde_json",@@ -504,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-core", "pup-host",@@ -520,14 +725,17 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.413"+version = "2.0.594" dependencies = [+ "fontdue",+ "image",+ "resvg", "serde_json", ] [[package]] name = "pup-session"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-core", "serde",@@ -537,7 +745,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.413"+version = "2.0.594" dependencies = [ "pup-core", "pup-host",@@ -548,10 +756,10 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.413"+version = "2.0.594" dependencies = [ "base64",- "png",+ "png 0.17.16", "pup-browser", "pup-cdp", "pup-core",@@ -563,6 +771,18 @@ dependencies = [ "ureq", ] +[[package]]+name = "pxfm"+version = "0.1.30"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"++[[package]]+name = "quick-error"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"+ [[package]] name = "quote" version = "1.0.47"@@ -602,6 +822,29 @@ dependencies = [ "getrandom", ] +[[package]]+name = "resvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"+dependencies = [+ "log",+ "pico-args",+ "rgb",+ "svgtypes",+ "tiny-skia",+ "usvg",+]++[[package]]+name = "rgb"+version = "0.8.53"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"+dependencies = [+ "bytemuck",+]+ [[package]] name = "ring" version = "0.17.14"@@ -616,6 +859,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"+ [[package]] name = "rustls" version = "0.23.43"@@ -718,6 +967,21 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]]+name = "simplecss"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"+dependencies = [+ "log",+]++[[package]]+name = "siphasher"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"+ [[package]] name = "smallvec" version = "1.16.0"@@ -730,12 +994,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]]+name = "strict-num"+version = "0.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"+dependencies = [+ "float-cmp",+]+ [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]]+name = "svgtypes"+version = "0.15.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"+dependencies = [+ "kurbo",+ "siphasher",+]+ [[package]] name = "syn" version = "2.0.119"@@ -789,6 +1072,32 @@ dependencies = [ "syn 2.0.119", ] +[[package]]+name = "tiny-skia"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"+dependencies = [+ "arrayref",+ "arrayvec",+ "bytemuck",+ "cfg-if",+ "log",+ "png 0.17.16",+ "tiny-skia-path",+]++[[package]]+name = "tiny-skia-path"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"+dependencies = [+ "arrayref",+ "bytemuck",+ "strict-num",+]+ [[package]] name = "tiny_http" version = "0.12.0"@@ -811,6 +1120,15 @@ dependencies = [ "zerovec", ] +[[package]]+name = "ttf-parser"+version = "0.25.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"+dependencies = [+ "core_maths",+]+ [[package]] name = "tungstenite" version = "0.24.0"@@ -877,6 +1195,28 @@ dependencies = [ "serde", ] +[[package]]+name = "usvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"+dependencies = [+ "base64",+ "data-url",+ "flate2",+ "imagesize",+ "kurbo",+ "log",+ "pico-args",+ "roxmltree",+ "simplecss",+ "siphasher",+ "strict-num",+ "svgtypes",+ "tiny-skia-path",+ "xmlwriter",+]+ [[package]] name = "utf-8" version = "0.7.6"@@ -919,6 +1259,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]]+name = "weezl"+version = "0.1.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"+ [[package]] name = "windows-sys" version = "0.52.0"@@ -1007,6 +1353,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]]+name = "xmlwriter"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"+ [[package]] name = "yoke" version = "0.8.3"@@ -1121,3 +1473,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"++[[package]]+name = "zune-core"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"++[[package]]+name = "zune-jpeg"+version = "0.5.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"+dependencies = [+ "zune-core",+]
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.437"+version = "2.0.594" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+25−1@@ -63,10 +63,12 @@ fn main() { let bridge_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let state_dir = h.state_dir(); let _ = std::fs::create_dir_all(&state_dir);+ pup_core::set_log_file(pup_core::data_subdir("logs", state_dir.join("pup-logs")).join("pup.log"));+ pup_core::log(format!("[pup] {} starting; log file {}", pup_core::version(), pup_core::log_file().map(|p| p.display().to_string()).unwrap_or_default())); let ab: Arc<dyn pup_core::AbClient> = match Loopback::from_env() { Some(l) => Arc::new(l), None => { pup_core::log("[ab] ADOM_DIRECT_API_URL not set: desktop verbs degrade (hand-launched exe?)"); Arc::new(NoAb) } }; let ctx = Arc::new(Ctx { host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),- sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),+ sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), focus_emul: Mutex::new(Default::default()), pending_press: Mutex::new(Default::default()), browser_stall: Mutex::new(Default::default()), browser_idle_since: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(), bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()), vault: pup_vault::Vault::new(&state_dir), cards: pup_vault::Cards::new(&state_dir), consent: pup_verbs::consent::Registry::new(), tab_recs: Default::default(), screen_rec: Default::default(), highfps: Default::default(), last_audit: Mutex::new(None), });@@ -110,6 +112,7 @@ fn main() { if c.prefer_cft() || cands.is_empty() { c.installer.ensure_background(c.host.clone()); } } pup_verbs::identity::boot(&c);+ pup_verbs::restore_extdrive(&c); pup_verbs::recovery::recover_at_boot(&c); pup_verbs::recovery::start_health_loop(c.clone()); pup_verbs::highfps::start_loop(c.clone());@@ -128,6 +131,27 @@ fn main() { (Method::Get, "/health") => { let _ = req.respond(json_response(200, &pup_dashboard::health(&c))); } (Method::Get, "/status") => { let _ = req.respond(json_response(200, &pup_dashboard::status(&c))); } (Method::Options, _) => { let _ = req.respond(Response::empty(204)); }+ // pup-bridge#109: the extension-driven control channel. The pup extension polls for one+ // command and posts the result; this is how pup drives a browser that carries NO CDP port.+ (Method::Get, "/extcmd") => {+ let lane = url.split('?').nth(1).unwrap_or("").split('&')+ .find_map(|kv| kv.strip_prefix("lane=")).unwrap_or("").to_string();+ let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane)));+ }+ (Method::Post, "/exthello") => {+ let mut raw = String::new();+ let mut req = req;+ let _ = req.as_reader().read_to_string(&mut raw);+ pup_verbs::extdrive::hello(serde_json::from_str(&raw).unwrap_or(json!({})));+ let _ = req.respond(json_response(200, &json!({ "ok": true })));+ }+ (Method::Post, "/extresult") => {+ let mut raw = String::new();+ let mut req = req;+ let _ = req.as_reader().read_to_string(&mut raw);+ pup_verbs::extdrive::put_result(serde_json::from_str(&raw).unwrap_or(json!({})));+ let _ = req.respond(json_response(200, &json!({ "ok": true })));+ } (Method::Post, "/command") => { let caller = caller_from(&req); let mut raw = String::new();
rust/browser/src/ext_assets.rs+22@@ -3,13 +3,16 @@ /// (source folder, staged folder name under ~/.adom, files) pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[ ("extension", "pup-extension", &[+ ("annotate-overlay.js", include_bytes!("../../../src/extension/annotate-overlay.js")), ("content.js", include_bytes!("../../../src/extension/content.js")), ("fg-500.woff2", include_bytes!("../../../src/extension/fg-500.woff2")), ("fg-700.woff2", include_bytes!("../../../src/extension/fg-700.woff2")), ("icon128.png", include_bytes!("../../../src/extension/icon128.png")), ("icon16.png", include_bytes!("../../../src/extension/icon16.png")), ("icon48.png", include_bytes!("../../../src/extension/icon48.png")),+ ("identity-shim.js", include_bytes!("../../../src/extension/identity-shim.js")), ("manifest.json", include_bytes!("../../../src/extension/manifest.json")),+ ("nbe-cdp.js", include_bytes!("../../../src/extension/nbe-cdp.js")), ("popup.html", include_bytes!("../../../src/extension/popup.html")), ("popup.js", include_bytes!("../../../src/extension/popup.js")), ("satoshi-400.woff2", include_bytes!("../../../src/extension/satoshi-400.woff2")),@@ -22,6 +25,7 @@ pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[ ("icon128.png", include_bytes!("../../../src/extension-identity/icon128.png")), ("icon16.png", include_bytes!("../../../src/extension-identity/icon16.png")), ("icon48.png", include_bytes!("../../../src/extension-identity/icon48.png")),+ ("identity-worker.js", include_bytes!("../../../src/extension-identity/identity-worker.js")), ("identity.html", include_bytes!("../../../src/extension-identity/identity.html")), ("identity.js", include_bytes!("../../../src/extension-identity/identity.js")), ("manifest.json", include_bytes!("../../../src/extension-identity/manifest.json")),@@ -43,4 +47,22 @@ pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[ ("icon48.png", include_bytes!("../../../src/extension-annotate/icon48.png")), ("manifest.json", include_bytes!("../../../src/extension-annotate/manifest.json")), ]),+ ("extension-nbe", "pup-nbe-extension", &[+ (".nbe-source", include_bytes!("../../../src/extension-nbe/.nbe-source")),+ ("icons/blank16.png", include_bytes!("../../../src/extension-nbe/icons/blank16.png")),+ ("icons/blank48.png", include_bytes!("../../../src/extension-nbe/icons/blank48.png")),+ ("icons/icon128.png", include_bytes!("../../../src/extension-nbe/icons/icon128.png")),+ ("icons/icon16.png", include_bytes!("../../../src/extension-nbe/icons/icon16.png")),+ ("icons/icon48.png", include_bytes!("../../../src/extension-nbe/icons/icon48.png")),+ ("manifest.json", include_bytes!("../../../src/extension-nbe/manifest.json")),+ ("src/cdp.js", include_bytes!("../../../src/extension-nbe/src/cdp.js")),+ ("src/native.js", include_bytes!("../../../src/extension-nbe/src/native.js")),+ ("src/offscreen-record.html", include_bytes!("../../../src/extension-nbe/src/offscreen-record.html")),+ ("src/offscreen-record.js", include_bytes!("../../../src/extension-nbe/src/offscreen-record.js")),+ ("src/popup.html", include_bytes!("../../../src/extension-nbe/src/popup.html")),+ ("src/popup.js", include_bytes!("../../../src/extension-nbe/src/popup.js")),+ ("src/record.js", include_bytes!("../../../src/extension-nbe/src/record.js")),+ ("src/sw.js", include_bytes!("../../../src/extension-nbe/src/sw.js")),+ ("toolbar.json", include_bytes!("../../../src/extension-nbe/toolbar.json")),+ ]), ];
rust/browser/src/lib.rs+281−11@@ -19,11 +19,11 @@ pub mod chrome_stable; pub fn detect_chrome(host: &dyn Host) -> Option<Candidate> { list_system_browsers(host).into_iter().find(|c| c.kind == "chrome") } /// Chrome extension ids (deterministic from each manifest's `key`, so staging path does not matter).-pub const EXT_IDS: &[(&str, &str)] = &[("pup-extension", "liomcaladmooagjiebkfgegciadapnpi"), ("pup-annotate-extension", "ghadnmahlldiigemdbekmhcbfcpkmclg"), ("pup-identity-extension", "mngfopllhdfgomjphhejmcdimcohcgnf"), ("pup-identity-anon-extension", "mhoenaodccbppcinfbaifamkgfhbakbe")];+pub const EXT_IDS: &[(&str, &str)] = &[("pup-extension", "liomcaladmooagjiebkfgegciadapnpi"), ("pup-annotate-extension", "ghadnmahlldiigemdbekmhcbfcpkmclg"), ("pup-identity-extension", "mngfopllhdfgomjphhejmcdimcohcgnf"), ("pup-identity-anon-extension", "mhoenaodccbppcinfbaifamkgfhbakbe"), ("pup-nbe-extension", "nncjgceobmlenfliamnibncjojpedbcc")]; /// The Chrome for Testing build pup drives. Same build id the Node build pinned through /// puppeteer 24.43, so both builds share one cache folder. Bump deliberately, with a ship note.-pub const CFT_BUILD_ID: &str = "148.0.7778.97";+pub const CFT_BUILD_ID: &str = "152.0.7977.82"; pub const CFT_MIN_FREE_MB: u64 = 1200; const UNPACK_STALL: Duration = Duration::from_secs(90); const UNPACK_MAX: Duration = Duration::from_secs(15 * 60);@@ -77,6 +77,13 @@ pub fn read_default(host: &dyn Host) -> Option<Value> { pub fn write_default(host: &dyn Host, cand: &Candidate, forced: bool) { let p = host.cft_cache_dir().join("pup-browser-default.json");+ // pup-bridge#109: a normal launch must NEVER clobber the user's pin. get_or_launch calls this with+ // forced:false after every launch, which downgraded a `pup_use {browser:"chrome"}` pin to forced:false, so+ // the pin survived exactly one launch and then silently reverted to Chrome for Testing (John measured the+ // pin "not taking" in #109).+ if !forced {+ if let Some(d) = read_default(host) { if d.get("forced").and_then(|f| f.as_bool()) == Some(true) { return; } }+ } let _ = fs::create_dir_all(p.parent().unwrap()); let v = json!({ "kind": cand.kind, "executablePath": cand.executable_path.to_string_lossy().replace('\\', "/"), "source": cand.source, "buildId": cand.build_id, "forced": forced, "verifiedAt": pup_core::now_ms() }); let _ = fs::write(p, serde_json::to_string_pretty(&v).unwrap_or_default());@@ -125,7 +132,13 @@ impl Installer { /// Kick the download off in the background (idempotent: one in flight at a time). pub fn ensure_background(&self, host: Arc<dyn Host>) {- if detect_cft(&*host).is_some() { let mut s = self.state.lock().unwrap(); s.phase = "ready".into(); s.executable_path = Some(cft_exe_path(&*host, CFT_BUILD_ID)); return; }+ // 2.0.549: gate on the PINNED build, not "any cached CfT". detect_cft falls back to any cached+ // build so an offline box still drives, but that fallback also masked a version bump: 148 stayed+ // cached, detect_cft returned it, and 152 was never fetched. Now: if the pinned build is present,+ // ready; if not, download it in the background (a fallback build drives meanwhile) so the box+ // upgrades on its own.+ let pinned = cft_exe_path(&*host, CFT_BUILD_ID);+ if pinned.is_file() { let mut s = self.state.lock().unwrap(); s.phase = "ready".into(); s.executable_path = Some(pinned); return; } if self.inflight.swap(true, Ordering::SeqCst) { return; } let state = self.state.clone(); let inflight = self.inflight.clone(); std::thread::Builder::new().name("cft-install".into()).spawn(move || {@@ -357,22 +370,122 @@ fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<Instal /// the toolbar's port.json so the popup finds the live bridge. Returns the dirs to --load-extension, /// in the order the toolbar shows them: annotate pen, toolbar, identity (adom-you) or anonymous tile. pub fn stage_extensions(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool) -> Vec<PathBuf> {+ stage_extensions_lane(host, port, profile, toolbar_on, None, None)+}++/// 2.0.502: `lane` stamps a token into port.json. The extension only serves the extension-DRIVE control+/// channel when it has one, so a NORMAL pup window (which loads the same extension) never answers another+/// thread's ext commands. Without this the channel had no browser identity: any pup browser on the box+/// answered /extcmd, and pup ended up driving a DIFFERENT thread's window (measured 2026-09-13).+pub fn stage_extensions_lane(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool, lane: Option<&str>, identity_shim: Option<String>) -> Vec<PathBuf> { if !toolbar_on { return Vec::new(); }- let root = host.state_dir();+ let root = if lane.is_some() { host.state_dir().join("extlane") } else { host.state_dir() };+ let _ = fs::create_dir_all(&root);+ // Remove any staged extension folder that is no longer one of ours (a retired placeholder like+ // pup-lane-extension left a second browser-window icon in the toolbar until it was deleted by hand,+ // John 2026-09-13). Only prune names matching our own prefix so a user's own files are never touched.+ let keep: std::collections::HashSet<&str> = ext_assets::EXTENSIONS.iter().map(|(_, dst, _)| *dst).collect();+ if let Ok(rd) = fs::read_dir(&root) {+ for e in rd.flatten() {+ let n = e.file_name().to_string_lossy().to_string();+ if n.starts_with("pup-") && n.ends_with("-extension") && !keep.contains(n.as_str()) {+ let _ = fs::remove_dir_all(e.path());+ pup_core::log(format!("[extensions] pruned retired staged folder {n}"));+ }+ }+ } let mut staged: std::collections::HashMap<&str, PathBuf> = std::collections::HashMap::new(); for (src, dst, files) in ext_assets::EXTENSIONS { let dir = root.join(dst); if fs::create_dir_all(&dir).is_err() { continue; } for (name, bytes) in *files { let p = dir.join(name);+ // a staged file may sit in a subfolder (nbe ships src/ and icons/); without this the write+ // fails silently and Chrome rejects the extension for a missing background script+ if let Some(parent) = p.parent() { let _ = fs::create_dir_all(parent); } let same = fs::metadata(&p).map(|m| m.len() == bytes.len() as u64).unwrap_or(false); if !same { let _ = fs::write(&p, bytes); } }- if *src == "extension" { let _ = fs::write(dir.join("port.json"), format!("{{\"port\":{port},\"version\":\"{}\",\"shell\":\"rust\"}}", pup_core::version())); }+ if *src == "extension" {+ // 2.0.543: the consumer-identity shim runs in the page's MAIN world at document_start, where+ // chrome.storage is unreachable, so its values are baked into the file at staging (pup passes+ // them in; empty = leave the file inert).+ if let Some(js) = identity_shim.as_deref() { let _ = fs::write(dir.join("identity-shim.js"), js); }+ let lane_field = lane.map(|l| format!(",\"lane\":\"{l}\"")).unwrap_or_default();+ let _ = fs::write(dir.join("port.json"), format!("{{\"port\":{port},\"version\":\"{}\",\"shell\":\"rust\"{lane_field}}}", pup_core::version()));+ }+ // 2.0.498: stamp the manifest version with pup's own version. Chrome CACHES an unpacked extension's+ // service worker in the profile and only reloads it when the manifest version changes — so every+ // worker.js fix pup shipped kept running as the OLD script on every existing profile (measured on+ // ConfRoomROG with pup_ext_diag: the worker fetched port.json fine and still had no packagedPort).+ // A cold Chrome on the same profile is NOT enough; the version bump is what invalidates it.+ if *dst == "pup-nbe-extension" { staged.insert(dst, dir); continue; }+ let mp = dir.join("manifest.json");+ if let Ok(txt) = fs::read_to_string(&mp) {+ if let Ok(mut m) = serde_json::from_str::<Value>(&txt) {+ let want = pup_core::version().to_string();+ let mut changed = false;+ if m.get("version").and_then(|v| v.as_str()) != Some(want.as_str()) { m["version"] = json!(want); changed = true; }+ // 2.0.499: the version bump alone did NOT make Chrome re-register the cached service worker+ // (ConfRoomROG, measured with pup_ext_diag after a cold browser: still the old script). A new+ // script URL does: stage the worker as worker.<version>.js and point the manifest at it, so+ // every pup release registers a fresh worker. Old copies are removed so the folder stays clean.+ if let Some(sw) = m.get("background").and_then(|b| b.get("service_worker")).and_then(|v| v.as_str()).map(|s| s.to_string()) {+ let base = sw.split('.').next().unwrap_or("worker").to_string();+ let versioned = format!("{base}.{}.js", want.replace('.', "-"));+ if sw != versioned {+ if let Ok(src) = fs::read(dir.join(&base).with_extension("js")).or_else(|_| fs::read(dir.join(&sw))) {+ if let Ok(rd) = fs::read_dir(&dir) { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); if n.starts_with(&format!("{base}.")) && n.ends_with(".js") && n != format!("{base}.js") { let _ = fs::remove_file(e.path()); } } }+ let _ = fs::write(dir.join(&versioned), src);+ m["background"]["service_worker"] = json!(versioned);+ changed = true;+ }+ }+ }+ if changed { let _ = fs::write(&mp, serde_json::to_string_pretty(&m).unwrap_or(txt)); }+ }+ } staged.insert(dst, dir); }- let identity = if profile == "adom-you" { "pup-identity-extension" } else { "pup-identity-anon-extension" };- ["pup-annotate-extension", "pup-extension", identity].iter().filter_map(|k| staged.get(k).cloned()).collect()+ // The anonymous tile means "isolated throwaway jar, logins here are discarded". The extension lane's+ // profile is adom-you-ext: pup-owned and DURABLE, not a throwaway, so the exact-equality check was+ // calling it something it is not (John, 2026-09-13: "why are you doing anonymous? wouldn't that even+ // hurt our ability to load sites cuz they might detect anonymous and think this is a bot"). Any+ // adom-you* jar wears the signed-in identity. NOTE this only corrects the LABEL; the lane still has+ // its own cookie jar, which is the real problem and is tracked separately.+ let identity = if profile.starts_with("adom-you") { "pup-identity-extension" } else { "pup-identity-anon-extension" };+ // A staged folder whose manifest points at a file that is not on disk makes Chrome throw a MODAL+ // "Error Loading Extension" dialog in the user's face and load NOTHING (John hit exactly that when+ // the asset generator dropped nbe's src/ folder, 2026-09-13). Never hand Chrome a folder we have+ // not verified: drop it from the load list and say so in the log instead.+ let sane = |dir: &PathBuf| -> bool {+ let Ok(txt) = fs::read_to_string(dir.join("manifest.json")) else { return false };+ let Ok(m) = serde_json::from_str::<Value>(&txt) else { return false };+ let mut needed: Vec<String> = Vec::new();+ if let Some(sw) = m["background"]["service_worker"].as_str() { needed.push(sw.to_string()); }+ if let Some(p) = m["action"]["default_popup"].as_str() { needed.push(p.to_string()); }+ for (_k, v) in m["icons"].as_object().into_iter().flatten() { if let Some(p) = v.as_str() { needed.push(p.to_string()); } }+ for f in &needed {+ if !dir.join(f).exists() {+ pup_core::log(format!("[extensions] NOT loading {}: manifest needs \"{f}\" and it is not staged", dir.display()));+ return false;+ }+ }+ true+ };+ staged.retain(|_k, dir| sane(dir));+ let mut order = vec!["pup-annotate-extension", "pup-extension", identity];+ // 2.0.506 (John): the extension-driven lane gets a FOURTH teal toolbar button, so a glance at the+ // toolbar says which lane a pup window is on. Three buttons = the CDP lane; four = this one. It is+ // staged ONLY when `lane` is set, which is what makes it a signal rather than decoration.+ // 2.0.515: nbe ALWAYS rides the extension lane. 2.0.509 hid it behind a flag because the lane looked+ // dead with it loaded; that was wrong (the lane was equally dead without it, and the real cause was+ // pup wiping its own lane token in launch_extdrive, fixed in 2.0.513). The flag was never registered+ // as a settings key either, so pup_configure silently ignored it. John needs nbe's icon in the+ // toolbar as the at-a-glance signal that a window is the EXTENSION lane and not the CDP lane:+ // three teal buttons = CDP, four = this lane.+ if lane.is_some() { order.push("pup-nbe-extension"); }+ order.iter().filter_map(|k| staged.get(k).cloned()).collect() } /// Profiles: `<ADOM_BRIDGE_DATA_DIR>/profiles` when ab passes the data dir (pup-bridge#98), else the@@ -387,23 +500,62 @@ pub fn sessions_dir(host: &dyn Host) -> PathBuf { DIR.get_or_init(|| pup_core::data_subdir("sessions", host.state_dir().join("pup-sessions"))).clone() } +/// pup-bridge#109: when on, launch with a near-STOCK flag set. Measured 2026-09-12: nb (the user's real Chrome,+/// extension-driven) loaded 14/14 vendor sites on AdomLapper while pup got 5/14 on the same box in the same+/// minute — and pup driving the real Chrome BINARY failed identically, so the binary is not the difference. The+/// remaining suspects are the profile, the ~25 launch flags (notably --no-sandbox and --test-type, which stock+/// Chrome never carries), and the CDP channel. This isolates the flags.+static STOCK_FLAGS: AtomicBool = AtomicBool::new(false);+pub fn set_stock_flags(on: bool) { STOCK_FLAGS.store(on, Ordering::SeqCst); }+ /// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features). pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Vec<String> {+ if STOCK_FLAGS.load(Ordering::SeqCst) {+ // Only what pup CANNOT work without, plus the sign-in suppression that protects the user's profile.+ let mut v: Vec<String> = vec![+ format!("--user-data-dir={}", profile_dir.display()),+ "--remote-debugging-port=0".into(),+ "--no-first-run".into(), "--no-default-browser-check".into(),+ "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),+ "--disable-features=SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker".into(),+ ];+ if insecure_dev { v.push("--disable-web-security".into()); }+ if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new+ // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure+ // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.+ v.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }+ v.push("--no-startup-window".into());+ return v;+ } let mut v: Vec<String> = vec![ format!("--user-data-dir={}", profile_dir.display()), "--remote-debugging-port=0".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-default-apps".into(), "--disable-sync".into(), "--disable-background-networking".into(), "--disable-infobars".into(),+ // Suppress Chrome's browser sign-in / "set up a work profile" / "turn on sync" prompts so a Google web+ // login NEVER pulls the user off the adom-you CfT profile (v1.8.95; dropped in the Rust port, John saw+ // the intercept bubble return 2026-09-12). The wiki/site cookies we DO want are unaffected.+ "--allow-browser-signin=false".into(), "--disable-signin-promo".into(), "--disable-session-crashed-bubble".into(), "--hide-crash-restore-bubble".into(), "--disable-field-trial-config".into(), "--ash-no-nudges".into(), "--test-type".into(), "--no-sandbox".into(), "--media-router=0".into(), "--install-autogenerated-theme=13,17,23".into(), "--start-maximized".into(),+ // pup-bridge#109: hide navigator.webdriver (the top bot-manager signal on st.com/analog/arrow). The+ // standard, well-tested anti-automation flag; it does not affect the toolbar extension or CDP driving.+ "--disable-blink-features=AutomationControlled".into(),+ // 2.0.498: --disable-quic is BACK. 2.0.483 turned QUIC on to match Chrome's transport; that theory was+ // wrong (the CDP port was the discriminator, real Chrome failed identically) and QUIC binds UDP sockets on+ // 0.0.0.0, which is exactly what makes Windows Firewall prompt "allow Google Chrome for Testing" (measured+ // on winvm: three UDP 0.0.0.0 sockets per launch, none with QUIC off). No prompt beats HTTP/3. "--disable-quic".into(), "--disable-async-dns".into(), "--force-webrtc-ip-handling-policy=disable_non_proxied_udp".into(), "--webrtc-ip-handling-policy=disable_non_proxied_udp".into(),- "--disable-features=OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider".into(),+ format!("--disable-features={DISABLE_FEATURES}"), ]; if high_fps { v.extend(["--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"].map(String::from)); } if insecure_dev { v.push("--disable-web-security".into()); }- if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }+ if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new+ // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure+ // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.+ v.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); } // Node v1.8.51: `--no-startup-window` is THE fix for the focus-steal war AND for the orphan // window. Chrome starts with NO window; pup creates the one it wants over CDP (Target.createTarget, // off-screen, then parked). The Rust port launched with "about:blank" instead, so every profile@@ -414,7 +566,7 @@ pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, e } /// Node: prepareCleanProfile (the Preferences the profile must carry before Chrome opens it).-pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool) {+pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool, host: Option<&dyn Host>) { let default = profile_dir.join("Default"); let _ = fs::create_dir_all(&default); let prefs_path = default.join("Preferences");@@ -434,6 +586,24 @@ pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool) { prefs["extensions"]["pinned_extensions"] = Value::Array(pinned); } prefs["browser"]["promotional_tabs_enabled"] = json!(false);+ // 2.0.492: Google's installers register extensions MACHINE-WIDE (HKCU/HKLM ...\Chrome\Extensions), and+ // Chrome offers them to every NEW profile it creates on that machine — the user sees an "Action required"+ // bubble: "Another program on your computer added an extension" (John hit this in a pup window, 2026-09-12,+ // for "Application Launcher For Drive"). pup owns these profiles and never wants a third party's extension+ // in them. Pre-initialise the extension alert state so the bubble does not greet a fresh pup profile.+ // Best-effort: it suppresses the prompt, it does not un-register the machine-wide entry (that would be a+ // system-wide policy change, which pup must never make to the user's machine).+ prefs["extensions"]["alerts"]["initialized"] = json!(true);+ // 2.0.553: the alerts flag above does NOT gate the "Another program on your computer added an+ // extension" dialog (John hit it again on 2026-09-14 for "Application Launcher For Drive"). Chrome's+ // ExternalInstallManager gates that per-extension on extensions.settings.<id>.ack_external, so+ // pre-acknowledge every machine-wide id. Profile-scoped only: pup never edits the machine's registry.+ if let Some(h) = host {+ for id in h.external_extension_ids() {+ prefs["extensions"]["settings"][&id]["ack_external"] = json!(true);+ }+ }+ prefs["extensions"]["ui"]["developer_mode"] = json!(true); prefs["session"]["restore_on_startup"] = json!(5); prefs["session"]["startup_urls"] = json!([]); prefs["profile"]["password_manager_leak_detection"] = json!(false);@@ -442,6 +612,13 @@ pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool) { let _ = fs::write(&prefs_path, serde_json::to_string(&prefs).unwrap_or_default()); // Chrome refuses to start on a profile whose lock names a dead process; Node cleaned these too. for lock in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = fs::remove_file(profile_dir.join(lock)); }+ // 2.0.477: pup OWNS every tab (it creates them over CDP); Chrome must never restore a session on its own. A+ // killed Chrome leaves tab/window restore state that the next launch replays INTO pup's first window as a+ // phantom extra tab — reproducible every other isolated restart on all three boxes (sr-b came back with 4+ // tabs, 0 live). Wipe the session-restore files (NOT cookies or Local Storage, which pup needs) so there is+ // nothing to restore. exited_cleanly=true above already asks Chrome not to; this removes the data outright.+ for f in ["Current Session", "Current Tabs", "Last Session", "Last Tabs"] { let _ = fs::remove_file(default.join(f)); }+ let _ = fs::remove_dir_all(default.join("Sessions")); } #[derive(Debug, Clone)]@@ -455,6 +632,99 @@ pub fn existing_port(profile_dir: &Path) -> Option<u16> { Some(port) } +/// pup-bridge#109: launch for EXTENSION-DRIVEN mode — the same Chrome for Testing, staged+/// extensions and pup-owned profile, but deliberately NO `--remote-debugging-port`. The debug port+/// is what several vendor edges wall (measured: st.com/mouser RST at HTTP/2, arrow denies over CDP;+/// all three load from this launch). pup talks to it through the extension's poll channel instead.+/// Returns the browser pid; there is no CDP handle by design.+/// The ONE Chrome feature-suppression list, shared by BOTH lanes. 2.0.505: the ext lane carried a+/// hand-trimmed copy that had lost the startup-promo ids, so John saw "Chromium now launches when+/// Windows starts so you can begin browsing instantly" on an ext window. Anything either lane needs+/// suppressed goes HERE; unknown feature names are ignored harmlessly by Chrome.+const DISABLE_FEATURES: &str = "OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider,SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker";++pub fn launch_extdrive(host: &dyn Host, cand: &Candidate, profile_dir: &Path, extensions: &[PathBuf], url: &str) -> Result<u32, String> {+ launch_extdrive_opts(host, cand, profile_dir, extensions, url, false, true)+}+/// `stock`: 2.0.544, an experiment lever for edges that still deny the lane after every header matches the+/// installed Chrome (analog.com, 2026-09-13). Keeps ONLY the flags that stop the Windows Firewall prompt+/// (no QUIC, no Cast/mDNS listener); everything else is Chrome's default, so the TLS ClientHello (ECH and the+/// other feature-gated extensions) is what real Chrome sends.+pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Path, extensions: &[PathBuf], url: &str, stock: bool, field_trials: bool) -> Result<u32, String> {+ if stock {+ let _ = fs::create_dir_all(profile_dir);+ prepare_profile(profile_dir, !extensions.is_empty(), Some(host));+ let mut args: Vec<String> = vec![+ format!("--user-data-dir={}", profile_dir.display()),+ "--no-first-run".into(), "--no-default-browser-check".into(),+ "--disable-quic".into(), "--media-router=0".into(),+ // 2.0.576 CalculateNativeWinOcclusion: stock Chrome stops painting a window that is COMPLETELY covered,+ // so every capture of a pup window buried under the user's maximized browser failed (0/20, 2026-09-14),+ // and the debugger screencast produced no frame either. The CDP lane has always disabled it (see+ // DISABLE_FEATURES). Compositor-only: nothing on the wire changes, which is what "stock" is for here.+ // firewall guards, plus the UI-only startup-promo features (2.0.546: the "Chromium now launches when+ // Windows starts" bar came back with the stock set; these change nothing on the wire). Field trials+ // stay ON here on purpose: --disable-field-trial-config alters feature rollouts and with them the+ // request fingerprint, which is the thing this launch exists to keep stock.+ "--disable-features=CalculateNativeWinOcclusion,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider,WebRtcHideLocalIpsWithMdns,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker".into(),+ "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),+ "--start-maximized".into(), "--disable-infobars".into(),+ // 2.0.574: the extension attaches chrome.debugger for ONE capture when a window is fully occluded;+ // this hides Chrome's "started debugging this browser" bar for that moment. UI-only, not on the wire.+ "--silent-debugger-extension-api".into(),+ ];+ // 2.0.547: the "Chromium now launches when Windows starts" promo is field-trial gated; disabling the+ // named features did not remove it (measured, 2.0.546). laneFieldTrials:off adds the flag that did in+ // 2.0.505; whether it costs anything at analog.com is measured, not assumed.+ if !field_trials { args.push("--disable-field-trial-config".into()); }+ if !extensions.is_empty() { args.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new+ // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure+ // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.+ args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }+ args.push(url.to_string());+ return host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind));+ }+ let _ = fs::create_dir_all(profile_dir);+ prepare_profile(profile_dir, !extensions.is_empty(), Some(host));+ let mut args: Vec<String> = vec![+ format!("--user-data-dir={}", profile_dir.display()),+ "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-default-apps".into(),+ "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),+ // 2.0.504, both missing flags the CDP lane's chrome_switches() has always passed:+ // --start-maximized: without it the window opened at whatever size the profile remembered+ // (John measured ~60% of his screen width). The extension's `fit` action corrects any+ // drift afterwards without raising the window.+ // --disable-infobars: suppresses CfT's "v148 ... is only for automated testing. For regular+ // browsing, use a standard version of Chrome" bar, which John saw on an ext window.+ // docs/WHY-CFT.md has always said pup hides it with this flag. It is NOT wanted for any+ // bot-detection reason and costs nothing: infobars are browser UI, invisible to the page.+ "--start-maximized".into(), "--disable-infobars".into(),+ "--silent-debugger-extension-api".into(), // 2.0.574: see the stock list above+ // 2.0.493: keep the NETWORK-QUIETING flags. Dropping them (2.0.488) re-introduced the Windows Firewall+ // prompt "Do you want to allow public and private networks to access this app? — Google Chrome for+ // Testing" on winvm (John, 2026-09-12): Chrome's mDNS/Cast discovery binds a UDP port on ALL interfaces+ // and Windows prompts. These have nothing to do with the debug port this mode exists to omit.+ "--media-router=0".into(), "--disable-background-networking".into(), "--disable-sync".into(), "--disable-quic".into(),+ format!("--disable-features={DISABLE_FEATURES}"),+ // --disable-field-trial-config turns off the experiment config that GATES promos like the+ // launch-on-startup bar, and is also the version determinism WHY-CFT promises.+ "--disable-field-trial-config".into(),+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp".into(),+ ];+ if !extensions.is_empty() {+ args.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(",")));+ // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new++ // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure++ // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.++ args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(",")));+ }+ args.push(url.to_string());+ host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind))+}+ /// Spawn-verified launch: the browser is started detached, the DevTools port is read from the /// profile's DevToolsActivePort (written by Chrome), and /json/version must answer. pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Result<Launched, String> {@@ -463,7 +733,7 @@ pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: b let _ = fs::remove_file(&port_file); // extensions ride ONLY Chrome for Testing (branded Chrome 137+ dropped --load-extension) let exts: Vec<PathBuf> = if cand.kind == "chrome-for-testing" { extensions.to_vec() } else { Vec::new() };- prepare_profile(profile_dir, !exts.is_empty());+ prepare_profile(profile_dir, !exts.is_empty(), Some(host)); let args = chrome_switches(profile_dir, high_fps, insecure_dev, &exts); let t0 = Instant::now(); let pid = host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("spawn {}: {e}", cand.executable_path.display()))?;
rust/cdp/src/lib.rs+47−7@@ -15,6 +15,9 @@ use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tungstenite::protocol::WebSocketConfig;++/// pup-bridge#109: when on, page_enable skips `Runtime.enable` (the CDP leak bot managers fingerprint).+static QUIET_RUNTIME: AtomicBool = AtomicBool::new(false); use tungstenite::stream::MaybeTlsStream; use tungstenite::{Message, WebSocket}; @@ -37,8 +40,14 @@ pub struct Event { pub method: String, pub params: Value, pub session_id: Option struct Sub { session: Option<String>, method: Option<String>, tx: Sender<Event> } +/// An alternate transport: every command goes through this instead of a websocket. pup's extension lane+/// (2.0.578) forwards to the extension's own DevTools driver (chrome.debugger), so verbs written against+/// this struct run unchanged on a browser that has no debug port.+pub type Via = Box<dyn Fn(Option<&str>, &str, Value, Duration) -> Result<Value, CdpError> + Send + Sync>;+ struct Inner {- sock: Mutex<WebSocket<MaybeTlsStream<TcpStream>>>,+ sock: Option<Mutex<WebSocket<MaybeTlsStream<TcpStream>>>>,+ via: Option<Via>, next_id: AtomicU64, pending: Mutex<HashMap<u64, Sender<Value>>>, subs: Mutex<Vec<Sub>>,@@ -71,7 +80,7 @@ impl Cdp { let _ = tcp.set_nodelay(true); } let inner = Arc::new(Inner {- sock: Mutex::new(sock), next_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), subs: Mutex::new(Vec::new()),+ sock: Some(Mutex::new(sock)), via: None, next_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), subs: Mutex::new(Vec::new()), alive: AtomicBool::new(true), ws_url: ws_url.to_string(), port, }); let reader = inner.clone();@@ -79,6 +88,12 @@ impl Cdp { Ok(Cdp { inner }) } + /// A Cdp whose commands travel through `via` (no socket, no reader). Events are not delivered:+ /// `subscribe` hands back a closed channel so waits return at once instead of timing out.+ pub fn via(via: Via) -> Cdp {+ Cdp { inner: Arc::new(Inner { sock: None, via: Some(via), next_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), subs: Mutex::new(Vec::new()), alive: AtomicBool::new(true), ws_url: "via".into(), port: 0 }) }+ }+ pub fn is_via(&self) -> bool { self.inner.via.is_some() } pub fn is_alive(&self) -> bool { self.inner.alive.load(Ordering::Relaxed) } pub fn ws_url(&self) -> &str { &self.inner.ws_url } pub fn port(&self) -> u16 { self.inner.port }@@ -86,13 +101,15 @@ impl Cdp { /// Send one command (browser-level when `session` is None) and wait for its reply. pub fn send(&self, session: Option<&str>, method: &str, params: Value, timeout: Duration) -> Result<Value, CdpError> { if !self.is_alive() { return Err(CdpError::Closed); }+ if let Some(via) = &self.inner.via { return via(session, method, params, timeout); } let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = channel::<Value>(); self.inner.pending.lock().unwrap().insert(id, tx); let mut msg = json!({ "id": id, "method": method, "params": params }); if let Some(s) = session { msg["sessionId"] = json!(s); } {- let mut sock = self.inner.sock.lock().unwrap();+ let Some(sm) = &self.inner.sock else { return Err(CdpError::Closed) };+ let mut sock = sm.lock().unwrap(); if let Err(e) = sock.send(Message::Text(msg.to_string().into())) { self.inner.pending.lock().unwrap().remove(&id); self.inner.alive.store(false, Ordering::Relaxed);@@ -117,6 +134,7 @@ impl Cdp { /// unsubscribe; the reader prunes dead subscribers on the next event. pub fn subscribe(&self, session: Option<&str>, method: Option<&str>) -> Receiver<Event> { let (tx, rx) = channel::<Event>();+ if self.inner.via.is_some() { drop(tx); return rx; } // no events on a via transport: recv returns Disconnected at once self.inner.subs.lock().unwrap().push(Sub { session: session.map(|s| s.to_string()), method: method.map(|m| m.to_string()), tx }); rx }@@ -141,8 +159,12 @@ impl Cdp { pub fn detach(&self, session_id: &str) { let _ = self.send(None, "Target.detachFromTarget", json!({ "sessionId": session_id }), Duration::from_secs(5)); } /// A NEW OS WINDOW (only Target.createTarget accepts newWindow; Node used exactly this).- pub fn create_target(&self, url: &str, new_window: bool, bounds: Option<(i32, i32, i32, i32)>) -> Result<String, CdpError> {+ /// `focus`: Some(false) creates a new window WITHOUT Chrome asking Windows for the foreground (2.0.449: no+ /// denied activation, so no attention state on the taskbar button); None leaves Chrome's default (the+ /// window asks, and Windows flags the button when it refuses). Tabs always pass None.+ pub fn create_target(&self, url: &str, new_window: bool, bounds: Option<(i32, i32, i32, i32)>, focus: Option<bool>) -> Result<String, CdpError> { let mut p = json!({ "url": url, "newWindow": new_window });+ if let Some(f) = focus { p["focus"] = json!(f); } if let Some((l, t, w, h)) = bounds { p["left"] = json!(l); p["top"] = json!(t); p["width"] = json!(w); p["height"] = json!(h); } let r = self.send(None, "Target.createTarget", p, Duration::from_secs(20))?; r.get("targetId").and_then(|s| s.as_str()).map(|s| s.to_string()).ok_or_else(|| CdpError::Json("createTarget: no targetId".into()))@@ -162,12 +184,29 @@ impl Cdp { self.send(None, "Browser.setWindowBounds", json!({ "windowId": window_id, "bounds": bounds }), Duration::from_secs(10)).map(|_| ()) } + /// pup-bridge#109: `Runtime.enable` is THE CDP leak bot managers fingerprint in 2026 — the side effect lands+ /// before any page JavaScript runs, so no in-page stealth can mask it. When this is set, pup keeps the Page+ /// domain (navigation, load events, addScriptToEvaluateOnNewDocument) and skips Runtime. Cost: no console /+ /// exception mirroring and no Runtime.bindingCalled, so the credential-capture and floaty bindings go quiet.+ pub fn quiet_runtime(on: bool) { QUIET_RUNTIME.store(on, std::sync::atomic::Ordering::SeqCst); }+ pub fn quiet_runtime_on() -> bool { QUIET_RUNTIME.load(std::sync::atomic::Ordering::SeqCst) }+ pub fn page_enable(&self, session: &str) -> Result<(), CdpError> { self.send(Some(session), "Page.enable", json!({}), Duration::from_secs(10)).map(|_| ())?;- let _ = self.send(Some(session), "Emulation.setFocusEmulationEnabled", json!({ "enabled": true }), Duration::from_secs(5)); // a background page must still take keystrokes (puppeteer sets this on every page)+ if Self::quiet_runtime_on() { return Ok(()); }+ // 2.0.458: focus emulation is NO LONGER set here. It used to be set on every attached page+ // ("puppeteer sets this on every page"), but a page that reports focus also reports VISIBLE, so+ // Chrome never throttled that tab's timers and Memory Saver never discarded it. pup_verbs::focus_emulate+ // now puts it on just the tabs pup needs it on. self.send(Some(session), "Runtime.enable", json!({}), Duration::from_secs(10)).map(|_| ()) } + /// Emulation.setFocusEmulationEnabled: make this page report focus even while its window sits behind the+ /// user's windows, so CDP input lands and focus/caret events fire. Costly to leave on (see page_enable).+ pub fn set_focus_emulation(&self, session: &str, enabled: bool) {+ let _ = self.send(Some(session), "Emulation.setFocusEmulationEnabled", json!({ "enabled": enabled }), Duration::from_secs(5));+ }+ /// Navigate and wait for the load event (or the timeout, which is NOT an error: the page is /// still moving; callers report `loaded:false`). pub fn navigate(&self, session: &str, url: &str, wait: Duration) -> Result<(String, bool), CdpError> {@@ -246,7 +285,7 @@ impl Cdp { pub fn close(&self) { self.inner.alive.store(false, Ordering::Relaxed);- if let Ok(mut s) = self.inner.sock.lock() { let _ = s.close(None); }+ if let Some(sm) = &self.inner.sock { if let Ok(mut s) = sm.lock() { let _ = s.close(None); } } } } @@ -254,7 +293,8 @@ fn reader_loop(inner: Arc<Inner>) { let mut idle = 0u32; while inner.alive.load(Ordering::Relaxed) { let msg = {- let mut sock = match inner.sock.lock() { Ok(s) => s, Err(_) => break };+ let Some(sm) = &inner.sock else { break };+ let mut sock = match sm.lock() { Ok(s) => s, Err(_) => break }; match sock.read() { Ok(m) => Some(m), Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => None,
rust/core/src/lib.rs+18−1@@ -201,7 +201,24 @@ impl Settings { // ─── Logging (stdout, ab tails it into ~/.adom/bridge-logs/puppeteer.log) ─────────────── -pub fn log(line: impl AsRef<str>) { println!("{}", line.as_ref()); }+static LOG_FILE: std::sync::OnceLock<std::sync::Mutex<Option<std::path::PathBuf>>> = std::sync::OnceLock::new();++/// 2.0.445: pup prints to stdout AND appends to a rolling file (ab wipes its per-spawn capture, so+/// there was no trace to read after "it showed and then hid"). 2 MB, one rotation (pup.log.1).+pub fn set_log_file(path: std::path::PathBuf) {+ if let Some(d) = path.parent() { let _ = std::fs::create_dir_all(d); }+ *LOG_FILE.get_or_init(|| std::sync::Mutex::new(None)).lock().unwrap() = Some(path);+}+pub fn log_file() -> Option<std::path::PathBuf> { LOG_FILE.get().and_then(|m| m.lock().unwrap().clone()) }++pub fn log(line: impl AsRef<str>) {+ let line = line.as_ref();+ println!("{line}");+ let Some(path) = log_file() else { return };+ use std::io::Write;+ if std::fs::metadata(&path).map(|m| m.len() > 2_000_000).unwrap_or(false) { let _ = std::fs::rename(&path, path.with_extension("log.1")); }+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { let _ = writeln!(f, "{} {line}", iso_now()); }+} pub fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) }
rust/dashboard/assets/dashboard.html+8−6@@ -536,11 +536,13 @@ const SETTINGS_DEF=[ {k:'overlaySize',l:'Badge size',d:'How big the badge is drawn, as a percent of full size. Each card is the real tile at that size.',sel:['50','66','75','100'],selLabels:{'50':'50%, smallest, maximum text clearance','66':'66%','75':'75%, balanced','100':'100%, full size'},dep:'overlayBadges'}, {k:'taskbarGrouping',l:'AUMID grouping',d:'Only applies when AUMID icons are on. split = one taskbar button per window. grouped = windows grouped BY CATEGORY when icon-style is "category" (up to 5 buttons: signed-in wiki, public wiki, open web, Adom app, plain: for example all your signed-in-wiki windows collapse under the one padlock button); with icon-style "standard" grouped collapses everything under a single Adom Pup button.',sel:['split','grouped'],depMode:'aumid',imgBy:{split:'groupsplit',grouped:'groupgrouped'}}, {k:'aumidIconStyle',l:'AUMID icon style',d:'Only applies when AUMID icons are on: standard = one pup tile for every window; category = the custom set (wiki signed-out, wiki signed-in padlock, world icon for the open web, adom app icon, mixed).',sel:['standard','category'],depMode:'aumid',imgBy:{standard:'iconstylestd',category:'iconstylecat'}},- {k:'jumpLists',l:'Jump lists',d:'The menu Windows shows when you right-click a pup taskbar icon (real screenshot below, captured on this machine). Requires AUMID icons on. Quirk: the FIRST right-click can blip the menu closed; right-click again about a second later and it holds (a Windows shell timing race, not a pup bug).',img:'jumplist'},+ {k:'jumpLists',l:'Jump lists',d:'The menu Windows shows when you right-click a pup taskbar icon (real screenshot below, captured on this machine). Requires AUMID icons on. Quirk: the FIRST right-click flashes the menu and the second holds. Measured cause (ab#155): a registry-only identity has no Start Menu shortcut for the shell to anchor the list to. The Start Menu anchor toggle below fixes it at the cost of one Start Menu folder.',img:'jumplist'}, {k:'jlViewTasks',l:'',d:'Wiki view tasks: Switch to logged-in view / Switch to public view on every wiki window.',dep:'jumpLists',subtgl:1,img:'jlviewtasks'}, {k:'jlCloseAll',l:'',d:'The Close all pup windows task on every pup icon.',dep:'jumpLists',subtgl:1,img:'jlcloseall'}, {k:'jlDashboard',l:'',d:'The Open pup dashboard task on every pup icon: one click opens the live window/tab/thread map.',dep:'jumpLists',subtgl:1,img:'jldashboard'}, {k:'jlAnnotate',l:'',d:'The Annotate this page task: draw on the page; Done captures it for the AI thread that owns the window.',dep:'jumpLists',subtgl:1,img:'jlannotate'},+ {k:'aumidShortcut',l:'',d:'Start Menu anchor: write one small visible shortcut per pup identity into a collapsed "Adom Pup" folder in the Start Menu, which is the only thing that makes the jump list stay up on the FIRST right-click (measured: hidden shortcuts and folders do not anchor). Off keeps the Start Menu clean and the first right-click flashes. Entries also show under Recently added and in Start search for a while. Needs ab 2.1.49.',dep:'jumpLists',subtgl:1},+ {k:'winAttentionFlash',l:'Windows attention flash on new windows',d:'Windows lights a taskbar button (the attention state FlashWindowEx sets) when a window asks for the foreground and is refused because you are in another app. Off: pup creates every new window without asking, so nothing is refused and nothing lights up. On: Chrome asks as usual, and each new pup window you were not looking at gets the flash until you click it.'}, {k:'annCaptionBar',l:'Caption bar on captures',d:'Bake a thin context strip under every annotation/snip capture (thread, page, time) so any AI you paste it into sees where it came from.',img:'anncapbar'}, {k:'toolbarExtension',l:'Adom toolbar in Chrome',d:'The Adom Pup extension button in the browser toolbar (annotate, snip, view toggle, dashboard, owner, close). Requires the one-time extension install.',img:'toolbarext'}, {k:'tbSnipHotkey',l:'',d:'Alt+Shift+A starts annotate (Auto: capture on mouse-up); Alt+Shift+M starts it in Multi (compose several shapes, capture once on Done).',dep:'toolbarExtension',subtgl:1},@@ -607,8 +609,8 @@ async function runImport(){ // ===== Settings renderer (adom-ui-design / settings-layout, v2.0.296) ===== // One continuously scrolling pane: sections (h2) > cards (h3) > rows, a sidebar that jumps within it // and scroll-spies, every row a breadcrumb name + explanation + (real) screenshot + control + default.-const SET_NAMES={taskbarMode:'Identity mode',overlayBadges:'Favicon overlay badges',overlaySize:'Badge size',taskbarGrouping:'Grouping',aumidIconStyle:'Icon style',jumpLists:'Jump lists',jlViewTasks:'Wiki view tasks',jlCloseAll:'Close all windows task',jlDashboard:'Open dashboard task',jlAnnotate:'Annotate this page task',annCaptionBar:'Caption bar on captures',toolbarExtension:'Adom toolbar in Chrome',tbSnipHotkey:'Snip hotkey',tbFloatyPencil:'Corner pencil',floatyHitPx:'Pencil hit area',taskbarActivity:'Activity signal',fgCaption:'Caption on AI foreground',fgCaptionMs:'Minimum caption time',notMineCaption:'Not-by-Adom caption',sleepRecovery:'Sleep and crash recovery',autoLogin:'Auto-fill saved logins',captureLogins:'Capture logins to the vault',reportIssues:'Issue-report hints',titleTag:'ai-thread tag in the title',titlePrefix:'Thread name prefixed to the title',ownerChip:'Floating owner chip',minimalTouch:'Minimal-touch mode'};-const SET_DEFAULTS={taskbarMode:'Tiles',overlayBadges:'On',overlaySize:'75%, balanced',taskbarGrouping:'Split',aumidIconStyle:'Category',jumpLists:'On',jlViewTasks:'On',jlCloseAll:'On',jlDashboard:'On',jlAnnotate:'On',annCaptionBar:'On',toolbarExtension:'On',tbSnipHotkey:'On',tbFloatyPencil:'Off',floatyHitPx:'24px',taskbarActivity:'Progress bar',fgCaption:'On',fgCaptionMs:'3.5s',notMineCaption:'Off',sleepRecovery:'On',autoLogin:'On',captureLogins:'On',reportIssues:'On',titleTag:'Off',titlePrefix:'Off',ownerChip:'On',minimalTouch:'On'};+const SET_NAMES={winAttentionFlash:'Windows attention flash',taskbarMode:'Identity mode',overlayBadges:'Favicon overlay badges',overlaySize:'Badge size',taskbarGrouping:'Grouping',aumidIconStyle:'Icon style',jumpLists:'Jump lists',jlViewTasks:'Wiki view tasks',jlCloseAll:'Close all windows task',jlDashboard:'Open dashboard task',jlAnnotate:'Annotate this page task',aumidShortcut:'Start Menu anchor (first-click jump list)',annCaptionBar:'Caption bar on captures',toolbarExtension:'Adom toolbar in Chrome',tbSnipHotkey:'Snip hotkey',tbFloatyPencil:'Corner pencil',floatyHitPx:'Pencil hit area',taskbarActivity:'Activity signal',fgCaption:'Caption on AI foreground',fgCaptionMs:'Minimum caption time',notMineCaption:'Not-by-Adom caption',sleepRecovery:'Sleep and crash recovery',autoLogin:'Auto-fill saved logins',captureLogins:'Capture logins to the vault',reportIssues:'Issue-report hints',titleTag:'ai-thread tag in the title',titlePrefix:'Thread name prefixed to the title',ownerChip:'Floating owner chip',minimalTouch:'Minimal-touch mode'};+const SET_DEFAULTS={winAttentionFlash:'Off',taskbarMode:'Tiles',overlayBadges:'On',overlaySize:'75%, balanced',taskbarGrouping:'Split',aumidIconStyle:'Category',jumpLists:'On',jlViewTasks:'On',jlCloseAll:'On',jlDashboard:'On',jlAnnotate:'On',aumidShortcut:'Off',annCaptionBar:'On',toolbarExtension:'On',tbSnipHotkey:'On',tbFloatyPencil:'Off',floatyHitPx:'24px',taskbarActivity:'Progress bar',fgCaption:'On',fgCaptionMs:'3.5s',notMineCaption:'Off',sleepRecovery:'On',autoLogin:'On',captureLogins:'On',reportIssues:'On',titleTag:'Off',titlePrefix:'Off',ownerChip:'On',minimalTouch:'On'}; // choice cards: the option name + the one-line tradeoff + the REAL screenshot per option const SET_CHOICES={ overlaySize:[{v:'50',n:'50%',d:'Smallest badge, maximum clearance for the tile text.',img:'obsize50',alt:'The pup tile with a small favicon badge in its corner'},@@ -630,14 +632,14 @@ const SET_SECTIONS=[ {id:'c-aumid',t:'AUMID options',status:st=>st.taskbarMode==='aumid'?'active':'inert in '+st.taskbarMode+' mode',desc:'These two only apply in AUMID mode, where each window carries its own button.',rows:['taskbarGrouping','aumidIconStyle'],depMode:'aumid'}, ]}, {id:'s-jumplists',t:'Jump lists',cards:[- {id:'c-jl',t:'Right-click menu',status:st=>st.jumpLists==='on'?'on':'off',desc:'The menu Windows shows when you right-click a pup taskbar button. pup registers the tasks below through the Windows jump-list API, so they appear exactly where every other app puts them. Requires an identity mode other than plain.',rows:['jumpLists','jlViewTasks','jlCloseAll','jlDashboard','jlAnnotate']},+ {id:'c-jl',t:'Right-click menu',status:st=>st.jumpLists==='on'?'on':'off',desc:'The menu Windows shows when you right-click a pup taskbar button. pup registers the tasks below through the Windows jump-list API, so they appear exactly where every other app puts them. Requires an identity mode other than plain.',rows:['jumpLists','jlViewTasks','jlCloseAll','jlDashboard','jlAnnotate','aumidShortcut']}, ]}, {id:'s-capture',t:'Annotate and capture',cards:[ {id:'c-capbar',t:'Captures',status:st=>st.annCaptionBar==='on'?'caption bar on':'caption bar off',desc:'When you draw on a page and finish, pup captures the marked-up region as a PNG and copies it to your clipboard.',rows:['annCaptionBar']}, {id:'c-toolbar',t:'Adom toolbar in Chrome',status:st=>st.toolbarExtension==='on'?'on':'off',desc:'A small family of extension buttons that ride the Chrome for Testing launch: the pup menu, the annotate pen, and your adom-you identity tile. They are staged on every launch, so they need no setup.',rows:['toolbarExtension','tbSnipHotkey','tbFloatyPencil','floatyHitPx']}, ]}, {id:'s-activity',t:'Activity and foreground',cards:[- {id:'c-activity',t:'Taskbar activity signal',status:st=>({progress:'progress bar',flash:'orange flash',off:'off'})[st.taskbarActivity]||st.taskbarActivity,desc:'What a window’s taskbar button does while pup is working on it. pup keeps a running average of how long each operation takes (applying the window icon, taking a screenshot) and fills the bar over that estimate, capping just short of done until the work actually finishes. Windows only.',rows:['taskbarActivity']},+ {id:'c-activity',t:'Taskbar activity signal',status:st=>({progress:'progress bar',flash:'orange flash',off:'off'})[st.taskbarActivity]||st.taskbarActivity,desc:'What a window’s taskbar button does while pup is working on it. pup keeps a running average of how long each operation takes (applying the window icon, taking a screenshot) and fills the bar over that estimate, capping just short of done until the work actually finishes. Windows only.',rows:['taskbarActivity','winAttentionFlash']}, {id:'c-captions',t:'Foreground captions',status:st=>st.fgCaption==='on'?'on':'off',desc:'pup opens every window in the background and never brings one to your screen without a reason. When an AI does raise a window, it must state why, and that reason is shown as an on-screen caption BEFORE the window comes up, naming the thread that asked. Caption time scales with the amount of text: the slider sets the minimum, and longer reasons extend at reading speed up to 12 seconds.',rows:['fgCaption','fgCaptionMs','notMineCaption']}, ]}, {id:'s-logins',t:'Logins',cards:[@@ -651,7 +653,7 @@ const SET_SECTIONS=[ {id:'c-safety',t:'Recovery and safety',status:st=>st.sleepRecovery==='on'?'recovery on':'recovery off',desc:'What pup does when things go wrong, and how much it touches your windows beyond driving the pages inside them.',rows:['sleepRecovery','reportIssues','minimalTouch']}, ]}, ];-const SET_PREFIX={taskbarMode:'Taskbar: ',overlayBadges:'Taskbar: ',overlaySize:'Overlay badges: ',taskbarGrouping:'AUMID: ',aumidIconStyle:'AUMID: ',jumpLists:'Taskbar: ',jlViewTasks:'Jump lists: ',jlCloseAll:'Jump lists: ',jlDashboard:'Jump lists: ',jlAnnotate:'Jump lists: ',annCaptionBar:'Captures: ',toolbarExtension:'Chrome: ',tbSnipHotkey:'Toolbar: ',tbFloatyPencil:'Toolbar: ',floatyHitPx:'Corner pencil: ',taskbarActivity:'Taskbar: ',fgCaption:'Foreground: ',fgCaptionMs:'Caption: ',notMineCaption:'Foreground: ',sleepRecovery:'Recovery: ',autoLogin:'Vault: ',captureLogins:'Vault: ',reportIssues:'Safety: ',titleTag:'Title: ',titlePrefix:'Title: ',ownerChip:'In page: ',minimalTouch:'Safety: '};+const SET_PREFIX={winAttentionFlash:'Taskbar: ',taskbarMode:'Taskbar: ',overlayBadges:'Taskbar: ',overlaySize:'Overlay badges: ',taskbarGrouping:'AUMID: ',aumidIconStyle:'AUMID: ',jumpLists:'Taskbar: ',jlViewTasks:'Jump lists: ',jlCloseAll:'Jump lists: ',jlDashboard:'Jump lists: ',jlAnnotate:'Jump lists: ',aumidShortcut:'Jump lists: ',annCaptionBar:'Captures: ',toolbarExtension:'Chrome: ',tbSnipHotkey:'Toolbar: ',tbFloatyPencil:'Toolbar: ',floatyHitPx:'Corner pencil: ',taskbarActivity:'Taskbar: ',fgCaption:'Foreground: ',fgCaptionMs:'Caption: ',notMineCaption:'Foreground: ',sleepRecovery:'Recovery: ',autoLogin:'Vault: ',captureLogins:'Vault: ',reportIssues:'Safety: ',titleTag:'Title: ',titlePrefix:'Title: ',ownerChip:'In page: ',minimalTouch:'Safety: '}; const SET_DEFS=Object.fromEntries(SETTINGS_DEF.map(d=>[d.k,d])); const SET_SHOT_PENDING=new Set(['tbFloatyPencil','titleTag','titlePrefix','notMineCaption']); let _dlgSelected=null;
rust/dashboard/src/lib.rs+27@@ -222,6 +222,33 @@ pub fn route(ctx: &std::sync::Arc<Ctx>, caller: &Caller, method: &str, path: &st ("POST", "/dashboard/action") => Some(Reply::Json(200, action(ctx, caller, body))), ("GET", "/api/status") => Some(Reply::Json(200, api_status(ctx))), ("GET", "/identity") => Some(Reply::Json(200, identity(ctx))),+ // 2.0.534: the identity extension paints the user's avatar on its toolbar button (John: "you should be+ // showing my avatar up in your button"). The bytes come through pup so the extension needs no host+ // permission for wherever the avatar lives; 10-minute cache like identity() itself.+ ("GET", "/identity/avatar") => {+ static AV: std::sync::Mutex<Option<(Instant, String, Vec<u8>)>> = std::sync::Mutex::new(None);+ if let Some((t, m, b)) = AV.lock().unwrap().as_ref() { if t.elapsed() < Duration::from_secs(600) { return Some(Reply::Bytes(200, m.clone(), b.clone(), Some("public, max-age=600".into()))); } }+ let id = identity(ctx);+ let mut got: Option<(String, Vec<u8>)> = None;+ if let Some(b64) = id["avatarB64"].as_str() {+ let raw = b64.split("base64,").last().unwrap_or(b64);+ if let Ok(bytes) = pup_cdp::base64_decode(raw) { got = Some(("image/png".into(), bytes)); }+ }+ if got.is_none() {+ if let Some(u) = id["avatarUrl"].as_str() {+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(8)).redirects(4).build();+ if let Ok(r) = agent.get(u).call() {+ let mime = r.header("Content-Type").unwrap_or("image/png").split(';').next().unwrap_or("image/png").to_string();+ let mut b = Vec::new(); let _ = r.into_reader().take(2 * 1024 * 1024).read_to_end(&mut b);+ if !b.is_empty() { got = Some((mime, b)); }+ }+ }+ }+ match got {+ Some((m, b)) => { *AV.lock().unwrap() = Some((Instant::now(), m.clone(), b.clone())); Some(Reply::Bytes(200, m, b, Some("public, max-age=600".into()))) }+ None => Some(Reply::Json(404, json!({ "ok": false, "error": "no avatar for this identity" }))),+ }+ } _ => hydrogen::route(method, path, query, body), } }
rust/host-win/Cargo.toml+2@@ -16,6 +16,8 @@ windows-sys = { version = "0.59", features = [ "Win32_UI_HiDpi", "Win32_System_Threading", "Win32_System_ProcessStatus",+ "Win32_System_Diagnostics_ToolHelp",+ "Win32_System_SystemInformation", "Win32_Graphics_Gdi", "Win32_Security_Credentials", ] }
rust/host-win/src/lib.rs+129−5@@ -15,13 +15,13 @@ mod imp { use windows_sys::Win32::System::Threading::{GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE}; use windows_sys::Win32::UI::HiDpi::GetDpiForWindow; use windows_sys::Win32::UI::WindowsAndMessaging::{- EnumWindows, GetClassNameW, GetForegroundWindow, GetPropW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible,- SetPropW, SetWindowPos, SystemParametersInfoW, HWND_BOTTOM, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER,+ EnumWindows, GetClassNameW, GetForegroundWindow, GetPropW, GetWindow, GetWindowLongPtrW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible,+ LoadImageW, PostMessageW, SetPropW, SetWindowPos, SystemParametersInfoW, GWL_STYLE, GW_OWNER, HWND_BOTTOM, IMAGE_ICON, LR_LOADFROMFILE, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_SETICON, WS_CAPTION, }; fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() } - struct EnumCtx { pids: Vec<u32>, class: Option<String>, out: Vec<HostWindow> }+ struct EnumCtx { pids: Vec<u32>, class: Option<String>, out: Vec<HostWindow>, frames_all: bool } unsafe extern "system" fn enum_cb(hwnd: HWND, lp: LPARAM) -> BOOL { let ctx = &mut *(lp as *mut EnumCtx);@@ -33,7 +33,11 @@ mod imp { let class = String::from_utf16_lossy(&cls[..n.max(0) as usize]); if let Some(want) = &ctx.class { if &class != want { return 1; } } let visible = IsWindowVisible(hwnd) != 0;- if !visible { return 1; }+ if ctx.frames_all {+ // frame windows only: unowned and captioned (a browser frame; not Chrome's tooltips, popups, or helper windows)+ if !GetWindow(hwnd, GW_OWNER).is_null() { return 1; }+ if (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32) & WS_CAPTION != WS_CAPTION { return 1; }+ } else if !visible { return 1; } let mut t = [0u16; 512]; let tn = GetWindowTextW(hwnd, t.as_mut_ptr(), t.len() as i32); let title = String::from_utf16_lossy(&t[..tn.max(0) as usize]);@@ -46,10 +50,49 @@ mod imp { impl HostWindows for WinHost { // Node: enumChromeWidgetWindows (via ab desktop_enum_windows) / win32.findChromeWindowByTitle fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {- let mut ctx = EnumCtx { pids: pids.to_vec(), class: class_filter.map(|s| s.to_string()), out: Vec::new() };+ let mut ctx = EnumCtx { pids: pids.to_vec(), class: class_filter.map(|s| s.to_string()), out: Vec::new(), frames_all: false }; unsafe { EnumWindows(Some(enum_cb), &mut ctx as *mut EnumCtx as LPARAM); } ctx.out }+ fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> {+ let mut ctx = EnumCtx { pids: Vec::new(), class: class_filter.map(|s| s.to_string()), out: Vec::new(), frames_all: true };+ unsafe { EnumWindows(Some(enum_cb), &mut ctx as *mut EnumCtx as LPARAM); }+ ctx.out+ }+ // ab window_identity.rs set_identity, in-process: IPropertyStore on the window (SHGetPropertyStoreForWindow),+ // PKEY_AppUserModel_ID / RelaunchCommand / RelaunchDisplayNameResource / RelaunchIconResource as VT_LPWSTR,+ // Commit, then WM_SETICON big+small from the .ico. Raw COM like taskbar_tab (no windows-rs dependency).+ fn stamp_identity(&self, hwnd: i64, app_id: &str, display: &str, icon: &str, relaunch: &str) -> bool {+ #[repr(C)] struct Guid { d1: u32, d2: u16, d3: u16, d4: [u8; 8] }+ #[repr(C)] struct PropKey { fmtid: Guid, pid: u32 }+ #[repr(C)] struct PropVariant { vt: u16, r1: u16, r2: u16, r3: u16, val: *const u16, pad: usize }+ #[repr(C)] struct VTable { qi: usize, add_ref: usize, release: unsafe extern "system" fn(*mut c_void) -> u32, get_count: usize, get_at: usize, get_value: usize,+ set_value: unsafe extern "system" fn(*mut c_void, *const PropKey, *const PropVariant) -> i32, commit: unsafe extern "system" fn(*mut c_void) -> i32 }+ #[repr(C)] struct Obj { vt: *const VTable }+ #[link(name = "shell32")] extern "system" { fn SHGetPropertyStoreForWindow(hwnd: HWND, riid: *const Guid, out: *mut *mut c_void) -> i32; }+ extern "system" { fn CoInitializeEx(reserved: *mut c_void, coinit: u32) -> i32; }+ const IID_STORE: Guid = Guid { d1: 0x886D8EEB, d2: 0x8CF2, d3: 0x4446, d4: [0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99] };+ const FMT: Guid = Guid { d1: 0x9F4C2855, d2: 0x9F79, d3: 0x4B39, d4: [0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3] };+ let (w_id, w_cmd, w_name, w_icon) = (wide(app_id), wide(relaunch), wide(display), wide(icon));+ unsafe {+ let _ = CoInitializeEx(std::ptr::null_mut(), 0x2);+ let mut p: *mut c_void = std::ptr::null_mut();+ if SHGetPropertyStoreForWindow(hwnd as HWND, &IID_STORE, &mut p) < 0 || p.is_null() { return false; }+ let vt = &*(*(p as *mut Obj)).vt;+ let set = |pid: u32, w: &Vec<u16>| -> bool { let pv = PropVariant { vt: 31 /* VT_LPWSTR */, r1: 0, r2: 0, r3: 0, val: w.as_ptr(), pad: 0 }; (vt.set_value)(p, &PropKey { fmtid: FMT, pid }, &pv) >= 0 };+ let ok = set(5, &w_id) & set(2, &w_cmd) & set(4, &w_name) & set(3, &w_icon);+ let committed = (vt.commit)(p) >= 0;+ (vt.release)(p);+ // Window icon AFTER the commit and POSTED, never sent: Chrome's UI thread is mid-initialisation at+ // this moment and a SendMessage would wait on it (measured 2.0.446: 60 to 80 ms, leaving a 4 ms+ // margin before the first show). The button's picture resolves through the AUMID anyway.+ for (cx, which) in [(32, 1usize), (16, 0usize)] {+ let h = LoadImageW(std::ptr::null_mut(), w_icon.as_ptr(), IMAGE_ICON, cx, cx, LR_LOADFROMFILE);+ if !h.is_null() { PostMessageW(hwnd as HWND, WM_SETICON, which, h as isize); }+ }+ ok && committed+ }+ } // Node: win32.isWindow / osHwndAlive fn is_alive(&self, hwnd: i64) -> bool { unsafe { IsWindow(hwnd as HWND) != 0 } } // Node: tagWindowSession (SetProp "AdomPupSid=<sid>" = 1)@@ -144,9 +187,90 @@ mod imp { let _ = std::fs::remove_file(&f); out.ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default() }+ fn pids_for_image(&self, image: &str) -> Vec<u32> {+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS};+ let want = image.to_ascii_lowercase();+ let mut out = Vec::new();+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+ if snap as isize == -1 { return out; }+ let mut e: PROCESSENTRY32W = std::mem::zeroed();+ e.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;+ if Process32FirstW(snap, &mut e) != 0 {+ loop {+ let n = e.szExeFile.iter().position(|&c| c == 0).unwrap_or(e.szExeFile.len());+ let name = String::from_utf16_lossy(&e.szExeFile[..n]).to_ascii_lowercase();+ if name == want { out.push(e.th32ProcessID); }+ if Process32NextW(snap, &mut e) == 0 { break; }+ }+ }+ }+ out+ }+ fn external_extension_ids(&self) -> Vec<String> {+ const PS: &str = "$k=@('HKCU:\\Software\\Google\\Chrome\\Extensions','HKLM:\\Software\\Google\\Chrome\\Extensions','HKLM:\\Software\\Wow6432Node\\Google\\Chrome\\Extensions'); foreach($p in $k){ if(Test-Path $p){ Get-ChildItem $p | ForEach-Object { $_.PSChildName } } }";+ self.run_shell_script(PS, std::time::Duration::from_secs(8))+ .lines().map(|l| l.trim().to_string())+ .filter(|l| l.len() == 32 && l.chars().all(|c| c.is_ascii_lowercase()))+ .collect()+ }+ fn installed_chrome_version(&self) -> Option<String> {+ let ps = "$ErrorActionPreference='SilentlyContinue'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe').'(default)'; if(-not $p){$p=(Get-ItemProperty 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe').'(default)'}; if($p -and (Test-Path $p)){ (Get-Item $p).VersionInfo.ProductVersion }";+ let out = self.run_shell_script(ps, std::time::Duration::from_secs(8));+ let v = out.split_whitespace().next().unwrap_or("").trim().to_string();+ let parts: Vec<&str> = v.split('.').collect();+ if parts.len() == 4 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) { Some(v) } else { None }+ } fn download_file(&self, url: &str, dest: &std::path::Path) -> Result<(), String> { match pup_core::http_download(url, dest) { Ok(_) => Ok(()), Err(e) => Err(e) } }+ // GlobalMemoryStatusEx: what the machine has and what is left. pup reports this to the calling AI on+ // every open (2.0.462, John: "i suppose you should be automatically telling it what % of ram was even+ // left on the box"), because a thread that opened twenty component pages had no way to know it was+ // spending 200 MB apiece until the laptop was thrashing.+ fn memory_status(&self) -> Option<(u64, u64)> {+ #[repr(C)] struct MemStatusEx { length: u32, memory_load: u32, total_phys: u64, avail_phys: u64, total_page: u64, avail_page: u64, total_virtual: u64, avail_virtual: u64, avail_ext: u64 }+ extern "system" { fn GlobalMemoryStatusEx(buf: *mut MemStatusEx) -> i32; }+ unsafe {+ let mut m: MemStatusEx = std::mem::zeroed();+ m.length = std::mem::size_of::<MemStatusEx>() as u32;+ if GlobalMemoryStatusEx(&mut m) == 0 { return None; }+ Some((m.total_phys / 1_048_576, m.avail_phys / 1_048_576))+ }+ }++ /// Working set of `pid` and every descendant (a browser process owns its renderers and GPU process).+ fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS};+ use windows_sys::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};+ if pid == 0 { return None; }+ let mut pairs: Vec<(u32, u32)> = Vec::new(); // (pid, ppid)+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+ if snap as isize == -1 { return None; }+ let mut e: PROCESSENTRY32W = std::mem::zeroed();+ e.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;+ if Process32FirstW(snap, &mut e) != 0 {+ loop { pairs.push((e.th32ProcessID, e.th32ParentProcessID)); if Process32NextW(snap, &mut e) == 0 { break; } }+ }+ windows_sys::Win32::Foundation::CloseHandle(snap);+ }+ let mut tree = vec![pid];+ let mut i = 0;+ while i < tree.len() { let parent = tree[i]; for (c, p) in &pairs { if *p == parent && !tree.contains(c) { tree.push(*c); } } i += 1; }+ let mut total: u64 = 0;+ unsafe {+ for p in &tree {+ let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, *p);+ if h.is_null() { continue; }+ let mut c: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();+ if GetProcessMemoryInfo(h, &mut c, std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32) != 0 { total += c.WorkingSetSize as u64; }+ windows_sys::Win32::Foundation::CloseHandle(h);+ }+ }+ Some(total / 1_048_576)+ }+ fn kill(&self, pid: u32) -> bool { if pid == 0 || pid == std::process::id() { return false; } // never the bridge itself, never "no pid" unsafe {
rust/host/src/lib.rs+22@@ -28,6 +28,13 @@ pub trait HostWindows: Send + Sync { /// Every visible top-level window owned by one of `pids` (Windows: class Chrome_WidgetWin_1 /// when `class_filter` is given). Empty on platforms without native windows support yet. fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow>;+ /// Every top-level FRAME window (unowned, captioned) of the class, visible or not. Birth uses it to+ /// catch a newborn browser window before Chrome shows it (2.0.446). Empty off Windows.+ fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> { let _ = class_filter; Vec::new() }+ /// Write the AppUserModel identity (id, relaunch command, display name, icon) straight onto the+ /// window's property store, in-process, and set its window icon: what ab's desktop_set_window_identity+ /// does, minus the round-trip, so it can land in the milliseconds before Chrome's first show.+ fn stamp_identity(&self, hwnd: i64, app_id: &str, display: &str, icon: &str, relaunch: &str) -> bool { let _ = (hwnd, app_id, display, icon, relaunch); false } fn is_alive(&self, hwnd: i64) -> bool; /// Tag a window with a pup-owned marker (Windows: SetProp "AdomPupSid=<sid>") so it can be /// recognised after a bridge restart. Returns false when tagging is unsupported.@@ -60,12 +67,27 @@ pub trait HostWindows: Send + Sync { pub trait HostProcess: Send + Sync { fn alive(&self, pid: u32) -> bool;+ /// (total MB, available MB) of physical RAM on this machine. None where it cannot be read.+ /// 2.0.462: an AI opening pages on someone's laptop has to be able to see what it is spending.+ fn memory_status(&self) -> Option<(u64, u64)> { None }+ /// Resident memory (MB) of a process AND its descendants: a browser plus every renderer and GPU process.+ fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> { let _ = pid; None } /// Spawn a browser DETACHED from this bridge (it must survive a bridge restart). Returns the pid. fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32>; fn kill(&self, pid: u32) -> bool; /// Run a script through the platform shell and return stdout (Windows: powershell -Command; empty /// on platforms without one). For the credential-import scan and the real-Chrome installer. fn run_shell_script(&self, _script: &str, _timeout: std::time::Duration) -> String { String::new() }+ /// Extension ids Chrome was told about MACHINE-WIDE (HKCU/HKLM ...\Chrome\Extensions). Chrome offers+ /// each to every NEW profile and raises the "Another program on your computer added an extension"+ /// bubble; pup pre-acknowledges them per profile. Empty off Windows.+ fn external_extension_ids(&self) -> Vec<String> { Vec::new() }+ /// The installed CONSUMER Chrome version (e.g. "152.0.7936.90"), so pup can present exactly the browser+ /// the user runs. None when there is no installed Chrome (CfT-only box) or off Windows.+ fn installed_chrome_version(&self) -> Option<String> { None }+ /// Every live pid running `image` (e.g. "chrome.exe"). Used to find a browser pup launched but cannot+ /// address by the pid it spawned: Chrome's launcher process exits at once and hands off to a new pid.+ fn pids_for_image(&self, _image: &str) -> Vec<u32> { Vec::new() } /// Download a URL to a file, following redirects. Returns Ok(path) or an error string. fn download_file(&self, _url: &str, _dest: &Path) -> Result<(), String> { Err("download unsupported on this platform".into()) } }
rust/identity/Cargo.toml+5@@ -6,4 +6,9 @@ license.workspace = true description = "Thread tiles, AUMID naming, badge composition and the taskbar-mode policy. Pure image code plus AbClient calls; no OS calls." [dependencies]+fontdue = "0.9"+# 2.0.583: SVG favicons (the wiki serves ONLY an SVG icon; every wiki window wore the generic globe). Pure Rust;+# no text or embedded-raster features: favicons need neither and the Windows cross-build stays C-free.+resvg = { version = "0.45", default-features = false }+image = { version = "0.25", default-features = false, features = ["png", "ico", "jpeg", "gif", "bmp", "webp"] } serde_json.workspace = true
rust/identity/assets/badge-generic-pdf.pngadded⋯ 1 unchanged line ⋯
rust/identity/assets/badge-generic-web.pngadded⋯ 1 unchanged line ⋯
rust/identity/assets/fg-700.ttfadded⋯ 1 unchanged line ⋯
rust/identity/src/badge.rsadded+153@@ -0,0 +1,153 @@+//! The taskbar overlay badge (the active tab's favicon on an adaptive card), composed IN THE BINARY.+//! Until 2.0.568 this was JavaScript evaluated inside the page (badge_js), which a strict+//! Content-Security-Policy refused (Google Docs, claude.ai, the wiki), so those windows never got an+//! overlay ("no badge could be composed"; John, 2026-09-14, the all-engineering agenda). Same card as+//! before: 48 px, corner radius 6, #202124 (or #f1f3f4 when the icon is dark-dominant AND transparent),+//! the icon cover-fitted into a 44 px inset box, then scaled to overlaySize percent and pinned north-east+//! so the thread tile's bottom line of text stays readable (2.0.262).+use image::{imageops, Rgba, RgbaImage};++pub const BSZ: u32 = 48;+const FIN: u32 = 2;+/// Chrome's grey globe and the PDF sheet, the SVGs from generic_icon_svg rasterised once at 48 px.+pub static GENERIC_WEB: &[u8] = include_bytes!("../assets/badge-generic-web.png");+pub static GENERIC_PDF: &[u8] = include_bytes!("../assets/badge-generic-pdf.png");++pub struct Analysis { pub luminance: Option<u32>, pub transparent: bool, pub dark_dominant: bool }++/// PNG, ICO, JPEG, GIF, BMP, WebP. SVG favicons are not decodable here (no rasteriser in the binary) and+/// fall through to the next candidate or the generic glyph.+/// SVG or not: the first bytes name the format. Anything else starting with `<` is an HTML error page+/// served where an image was expected, and stays rejected.+pub fn is_svg(bytes: &[u8]) -> bool {+ let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]).to_ascii_lowercase();+ let t = head.trim_start();+ t.starts_with("<svg") || (t.starts_with("<?xml") && head.contains("<svg")) || (t.starts_with("<!--") && head.contains("<svg"))+}+/// Rasterize an SVG favicon to a square RGBA image (fit inside `px`, centered). Straight alpha out.+fn rasterize_svg(bytes: &[u8], px: u32) -> Option<RgbaImage> {+ let tree = resvg::usvg::Tree::from_data(bytes, &resvg::usvg::Options::default()).ok()?;+ let size = tree.size();+ let (w, h) = (size.width(), size.height());+ if w <= 0.0 || h <= 0.0 { return None; }+ let scale = (px as f32 / w).min(px as f32 / h);+ let (ow, oh) = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));+ let mut pm = resvg::tiny_skia::Pixmap::new(ow, oh)?;+ resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut());+ let mut out = RgbaImage::new(ow, oh);+ for (i, p) in pm.pixels().iter().enumerate() {+ let c = p.demultiply();+ let (x, y) = ((i as u32) % ow, (i as u32) / ow);+ out.put_pixel(x, y, image::Rgba([c.red(), c.green(), c.blue(), c.alpha()]));+ }+ Some(out)+}+/// The favicon-sourcing guard: an image is anything that is not an HTML page. SVG passes.+pub fn looks_like_image(bytes: &[u8]) -> bool { !bytes.trim_ascii_start().starts_with(b"<") || is_svg(bytes) }+pub fn decode(bytes: &[u8]) -> Option<RgbaImage> {+ if bytes.is_empty() { return None; }+ if bytes.trim_ascii_start().starts_with(b"<") { return if is_svg(bytes) { rasterize_svg(bytes, 64) } else { None }; }+ image::load_from_memory(bytes).ok().map(|i| i.to_rgba8())+}+pub fn generic(is_pdf: bool) -> RgbaImage {+ decode(if is_pdf { GENERIC_PDF } else { GENERIC_WEB }).unwrap_or_else(|| RgbaImage::new(BSZ, BSZ))+}++/// Node analyseIcon: mean luminance of the opaque pixels of a 16 px downscale, and whether any pixel is see-through.+pub fn analyse(img: &RgbaImage) -> Analysis {+ let small = imageops::resize(img, 16, 16, imageops::FilterType::Triangle);+ let (mut sum, mut n, mut clear) = (0f64, 0u32, 0u32);+ for p in small.pixels() {+ if p[3] < 250 { clear += 1; }+ if p[3] > 40 { sum += 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; n += 1; }+ }+ let luminance = if n > 0 { Some((sum / n as f64).round() as u32) } else { None };+ Analysis { luminance, transparent: clear > 0, dark_dominant: luminance.map(|l| l < 80).unwrap_or(false) }+}++fn rounded_cov(x: u32, y: u32, s: f32, r: f32) -> f32 {+ let (cx, cy) = (x as f32 + 0.5 - s / 2.0, y as f32 + 0.5 - s / 2.0);+ let (dx, dy) = ((cx.abs() - (s / 2.0 - r)).max(0.0), (cy.abs() - (s / 2.0 - r)).max(0.0));+ (0.5 - ((dx * dx + dy * dy).sqrt() - r)).clamp(0.0, 1.0)+}++/// The finished 48 x 48 PNG.+pub fn compose(icon: &RgbaImage, pct: u32, light_card: bool) -> Vec<u8> {+ let fill = if light_card { [0xf1, 0xf3, 0xf4] } else { [0x20, 0x21, 0x24] };+ let mut card = RgbaImage::new(BSZ, BSZ);+ for y in 0..BSZ { for x in 0..BSZ { let a = (rounded_cov(x, y, BSZ as f32, 6.0) * 255.0).round() as u8; card.put_pixel(x, y, Rgba([fill[0], fill[1], fill[2], a])); } }+ // cover-fit into the inset box, centred (drawImage semantics: no clip, the canvas edge clips)+ let bx = (BSZ - FIN * 2) as f32;+ let (iw, ih) = (icon.width().max(1) as f32, icon.height().max(1) as f32);+ let sc = (bx / iw).max(bx / ih);+ let (dw, dh) = (((iw * sc).round() as u32).max(1), ((ih * sc).round() as u32).max(1));+ let scaled = imageops::resize(icon, dw, dh, imageops::FilterType::Lanczos3);+ let (ox, oy) = ((FIN as f32 + (bx - dw as f32) / 2.0).round() as i64, (FIN as f32 + (bx - dh as f32) / 2.0).round() as i64);+ imageops::overlay(&mut card, &scaled, ox, oy);+ let out = if pct < 100 {+ let h = (((BSZ as f32) * pct as f32 / 100.0).round() as u32).max(8);+ let small = imageops::resize(&card, h, h, imageops::FilterType::Lanczos3);+ let mut c2 = RgbaImage::new(BSZ, BSZ);+ imageops::overlay(&mut c2, &small, (BSZ - h) as i64, 0); // north-east, the tile text stays clear+ c2+ } else { card };+ crate::png_encode_rgba(&out.into_raw(), BSZ, BSZ)+}++/// Crop a PNG (device pixels) to a rect; used by the extension lane's annotate capture, where the+/// whole viewport is captured and the clip is applied here.+pub fn crop_png(png: &[u8], x: u32, y: u32, w: u32, h: u32) -> Option<Vec<u8>> {+ let img = image::load_from_memory(png).ok()?.to_rgba8();+ if img.width() == 0 || img.height() == 0 { return None; }+ let x = x.min(img.width() - 1); let y = y.min(img.height() - 1);+ let w = w.max(1).min(img.width() - x); let h = h.max(1).min(img.height() - y);+ let sub = imageops::crop_imm(&img, x, y, w, h).to_image();+ Some(crate::png_encode_rgba(&sub.into_raw(), w, h))+}++/// The annotate capture with its caption strip (26 px, #0d1117, #00d4cb text) and a 320 px-wide preview,+/// composed in the binary (2.0.569; it was an in-page canvas, which a page CSP refused).+/// Returns (png, preview_rgba, preview_w, preview_h). The preview is RAW RGBA, not PNG: the overlay+/// paints it with putImageData (its v2.0.135 rule, so a page CSP can never veto it as an image load).+/// 2.0.573: the first port handed it a PNG, ImageData threw on the byte length, and John got a toast+/// with no thumbnail ("why the fuck do you keep regressing").+pub fn annotate_compose(png: &[u8], caption: Option<&str>) -> Option<(Vec<u8>, Vec<u8>, u32, u32)> {+ let img = image::load_from_memory(png).ok()?.to_rgba8();+ let (w, h) = (img.width().max(1), img.height().max(1));+ let bar = if caption.is_some() { 26 } else { 0 };+ let mut out = RgbaImage::new(w, h + bar);+ imageops::overlay(&mut out, &img, 0, 0);+ if let Some(text) = caption {+ for y in h..h + bar { for x in 0..w { out.put_pixel(x, y, Rgba([0x0d, 0x11, 0x17, 255])); } }+ if let Some(f) = crate::tile::font_handle() {+ let mut buf = out.into_raw();+ crate::tile::draw_text(&f, &mut buf, w, h + bar, text, 12.0, 8.0, h as f32 + bar as f32 / 2.0 + 4.0, [0x00, 0xd4, 0xcb]);+ out = RgbaImage::from_raw(w, h + bar, buf)?;+ }+ }+ let pw = w.min(320); let ph = ((h as f32) * pw as f32 / w as f32).round().max(1.0) as u32;+ let preview = imageops::resize(&img, pw, ph, imageops::FilterType::Triangle);+ Some((crate::png_encode_rgba(&out.into_raw(), w, h + bar), preview.into_raw(), pw, ph))+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn composes_generic_and_a_real_icon() {+ for is_pdf in [false, true] {+ let g = generic(is_pdf); assert_eq!((g.width(), g.height()), (48, 48));+ let a = analyse(&g); assert!(!a.dark_dominant || !a.transparent);+ let png = compose(&g, 75, false); assert!(png.len() > 200 && png.starts_with(b"\x89PNG"));+ let png100 = compose(&g, 100, true); assert!(png100.len() > 200);+ }+ // a dark, transparent icon (the GitHub-cat case) must pick the light card+ let mut cat = RgbaImage::new(32, 32);+ for y in 8..24 { for x in 8..24 { cat.put_pixel(x, y, Rgba([20, 20, 20, 255])); } }+ let a = analyse(&cat); assert!(a.dark_dominant && a.transparent, "{:?} {} {}", a.luminance, a.transparent, a.dark_dominant);+ let dir = std::env::temp_dir().join("pup-badge-samples"); let _ = std::fs::create_dir_all(&dir);+ std::fs::write(dir.join("cat-75.png"), compose(&cat, 75, a.dark_dominant && a.transparent)).unwrap();+ std::fs::write(dir.join("web-75.png"), compose(&generic(false), 75, false)).unwrap();+ std::fs::write(dir.join("web-100.png"), compose(&generic(false), 100, false)).unwrap();+ }+}
rust/identity/src/lib.rs+2−140@@ -13,7 +13,8 @@ pub const TILE_GEN: &str = "t9"; pub const ICON_GEN: &str = "9"; pub const TITLE_LABEL: &str = "ai-thread"; -pub static FONT_WOFF2: &[u8] = include_bytes!("../../../src/extension/fg-700.woff2");+pub mod tile;+pub mod badge; pub static ICONS: &[(&str, &[u8])] = &[ ("adom-pup.ico", include_bytes!("../../../src/icons/adom-pup.ico")), ("pup-cat-default9.ico", include_bytes!("../../../src/icons/pup-cat-default9.ico")),@@ -85,72 +86,10 @@ pub fn category_icon(cat: Option<&str>, wiki_authed: bool) -> &'static str { pub const TILE_SIZES: [u32; 8] = [16, 20, 24, 32, 40, 48, 64, 256]; /// The tile is rendered ONCE at this size (8x the largest DIB entry, per the brand pipeline) and every /// ICO entry is produced from it by `brand_downscale`.-pub const TILE_RENDER: u32 = 512; /// The thread-tile canvas script: Node v2.0.172 sizing rules on the Adom rounded plate (corner radius = /// size / 5.5, the pup icon family's own spec), TWO lines at most (three read too small, John /// 2026-09-06), names with separators wrapped on whole words when that fits at the floor, else Node's /// character split; never an ellipsis (every character on a tile is identity). Resolves to base64 of the-/// raw RGBA pixels of the TILE_RENDER canvas; the bridge does the brand downscale for every ICO size.-pub fn tile_js(name: &str) -> String {- let nm = serde_json::to_string(name).unwrap_or_else(|_| "\"thread\"".into());- let font = base64_encode(FONT_WOFF2);- let render = TILE_RENDER;- format!(r#"(async () => {{- try {{- try {{ const f = new FontFace('FGtile', 'url(data:font/woff2;base64,{font})'); await f.load(); document.fonts.add(f); }} catch (e) {{}}- const nm = {nm}; const box = 256, R = {render};- const cv = document.createElement('canvas'); cv.width = box; cv.height = box; const ctx = cv.getContext('2d');- const setF = (s) => {{ ctx.font = '700 ' + s + "px FGtile,'Segoe UI',sans-serif"; }};- const wid = (t, s) => {{ setF(s); return ctx.measureText(t).width; }};- const FLOOR = box * 0.36;- const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.78 : box * 0.90; // text starts at 7.5% in; 0.90 keeps the right edge inside the plate (Node: 0.92 from 5.5%)- const fitSize = (lines) => {{ let s = box * 0.9; for (let i = 0; i < 90; i++) {{ let ok = true; for (let j = 0; j < lines.length; j++) {{ if (wid(lines[j], s) > wFor(j, lines.length)) {{ ok = false; break; }} }} if (ok && lines.length * s * 0.95 <= box * 0.88) break; s *= 0.95; }} return s; }};- let best = null, bf = 0;- // whole words first (split on spaces, after hyphens and slashes), two lines, only if it fits at or above the floor- const toks = nm.split(/\s+/).flatMap(t => t.split(/(?<=[-\/])/)).filter(Boolean);- if (toks.length > 1) {{- for (let k = 1; k < toks.length; k++) {{- const glue = (t) => t.reduce((a, x) => a + ((a && !/[-\/]$/.test(a)) ? ' ' : '') + x, '');- const c = [glue(toks.slice(0, k)), glue(toks.slice(k))]; const f = fitSize(c);- if (f >= FLOOR && f > bf) {{ bf = f; best = c; }}- }}- const one = fitSize([nm]); if (one >= FLOOR && one > bf) {{ bf = one; best = [nm]; }}- }}- if (!best) {{- const cands = [[nm]]; const sp = nm.lastIndexOf(' '), hy = nm.indexOf('-');- if (sp > 0) cands.push([nm.slice(0, sp), nm.slice(sp + 1)]); else if (hy > 0) cands.push([nm.slice(0, hy + 1), nm.slice(hy + 1)]);- if (sp < 0 && hy < 0 && nm.length >= 7) {{ const m = Math.ceil(nm.length / 2); cands.push([nm.slice(0, m), nm.slice(m)]); }}- best = [nm]; bf = 0;- for (const c of cands) {{ const f = fitSize(c); if (f > bf) {{ bf = f; best = c; }} }}- if (bf < FLOOR) {{- bf = FLOOR;- const take = (str, maxw) => {{ let n = 0; for (let i = 1; i <= str.length; i++) {{ if (wid(str.slice(0, i), FLOOR) <= maxw) n = i; else break; }} return n; }};- let rest = String(nm).trim(); const out = [];- for (let ln = 0; ln < 2 && rest; ln++) {{- let n = take(rest, wFor(ln, 2));- if (n >= rest.length) {{ out.push(rest); rest = ''; break; }}- if (n < 1) n = 1;- const seg = rest.slice(0, n + 1);- const bp = Math.max(seg.lastIndexOf(' '), seg.lastIndexOf('-'));- const cut = (bp >= Math.ceil(n * 0.5)) ? bp + (seg.charAt(bp) === '-' ? 1 : 0) : n;- out.push(rest.slice(0, cut).trim());- rest = rest.slice(cut).replace(/^[ -]+/, '');- }}- best = out.filter(Boolean);- }}- }}- // draw at R (8x the 64 px entry): rounded plate rx = R/5.5, name bottom-left in the on-accent colour- const big = document.createElement('canvas'); big.width = R; big.height = R; const c = big.getContext('2d');- const k = R / box, r = Math.round(R / 5.5);- c.beginPath(); c.moveTo(r, 0); c.arcTo(R, 0, R, R, r); c.arcTo(R, R, 0, R, r); c.arcTo(0, R, 0, 0, r); c.arcTo(0, 0, R, 0, r); c.closePath(); c.fillStyle = '#00b8b1'; c.fill();- c.font = '700 ' + (bf * k) + "px FGtile,'Segoe UI',sans-serif"; c.fillStyle = '#05221f'; c.textAlign = 'left'; c.textBaseline = 'alphabetic';- const lh = bf * k * 0.95, mx = R * 0.075, baseY = R - R * 0.075 - bf * k * 0.14;- best.forEach((ln, i) => c.fillText(ln, mx, baseY - (best.length - 1 - i) * lh));- const d = c.getImageData(0, 0, R, R).data; let bin = ''; const CH = 8192; for (let i = 0; i < d.length; i += CH) bin += String.fromCharCode.apply(null, d.subarray(i, Math.min(i + CH, d.length)));- return JSON.stringify({{ lines: best, size: Math.round(bf), rgba: btoa(bin) }});- }} catch (e) {{ return null; }}-}})()"#)-} /// A multi-entry ICO: small sizes as 32-bit DIBs (BGRA bottom-up + an all-opaque AND mask, Node /// v2.0.160), the 256 entry as PNG. `raw` = (size, RGBA bytes), `png256` = the PNG bytes.@@ -181,84 +120,7 @@ pub fn ico_from_tiles(raw: &[(u32, Vec<u8>)], png256: &[u8]) -> Vec<u8> { out } -/// Node genericIconSvg: the Chrome-style grey globe, or the PDF sheet for a .pdf tab. NO Adom mark-/// (v1.9.331 / 2.0.192: the tile already carries Adom identity).-pub fn generic_icon_svg(is_pdf: bool) -> String {- let plate = r##"<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>"##;- if is_pdf {- format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">{plate}<path d="M13 8 h15 l8 8 v24 a1 1 0 0 1-1 1 H14 a1 1 0 0 1-1-1 V9 a1 1 0 0 1 1-1 Z" fill="#ffffff" stroke="#9aa0a6" stroke-width="1.6"/><path d="M28 8 v8 h8 Z" fill="#dadce0"/><rect x="13" y="26" width="23" height="12" rx="2" fill="#e34f4f"/><text x="24.5" y="35.4" font-family="Segoe UI,Arial,sans-serif" font-size="9" font-weight="700" fill="#ffffff" text-anchor="middle">PDF</text></svg>"##)- } else {- format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">{plate}<circle cx="24" cy="24" r="13" fill="none" stroke="#5f6368" stroke-width="3"/><ellipse cx="24" cy="24" rx="5.6" ry="13" fill="none" stroke="#5f6368" stroke-width="2.6"/><path d="M11.4 24 H36.6" stroke="#5f6368" stroke-width="2.6"/><path d="M13.6 16.4 H34.4 M13.6 31.6 H34.4" stroke="#5f6368" stroke-width="2"/></svg>"##)- }-} -/// The overlay badge, Node's compositor (v1.9.203 48 px canvas, v2.0.191 light card only for a-/// dark AND transparent favicon, v2.0.262 the finished badge scaled to `pct` and tucked north-east,-/// FIN = 2 px inset, card #202124 / #f1f3f4). Fetched in-page so cookies and CORS behave like the-/// page; `fallback` is a data URL the bridge fetched out of band (Node's second rail). Resolves to a-/// JSON string: {dataUrl, kind: page-favicon | generic-web | generic-pdf, format, transparent,-/// darkDominant, luminance, href, via}.-pub fn badge_js(pct: u32, fallback: Option<&str>) -> String {- let js = r##"(async () => {- const pct = __PCT__, FB = __FALLBACK__;- const isPdf = /\.pdf(\?|#|$)/i.test(location.href);- const cands = [];- for (const l of document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')) { if (l.href && !l.href.startsWith('data:,')) cands.push({ href: l.href, via: 'in-page <link rel=icon> fetch' }); }- cands.push({ href: location.origin + '/favicon.ico', via: 'in-page /favicon.ico fetch' });- if (FB) cands.push({ href: FB.href, data: FB.data, via: 'bridge fetch of /favicon.ico (in-page favicon path came up empty)' });- const load = (src) => new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = src; });- let img = null, href = null, via = null, format = null;- for (const c of cands) {- try {- let src, type;- if (c.data) { src = c.data; type = (c.data.match(/^data:([^;]+)/) || [])[1] || 'image'; }- else { const r = await fetch(c.href, { credentials: 'include' }); if (!r.ok) continue; const b = await r.blob(); if (!b.size) continue; type = b.type || 'image'; if (!/image|icon|octet/.test(type)) continue;- // data: URL first: a page CSP (img-src) commonly allows data: but not blob: (wiki.adom.inc, measured 2026-09-06); an SVG with no intrinsic size gets one- let du = await new Promise((res) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(null); fr.readAsDataURL(b); });- if (du && /svg/.test(type)) { try { const txt = await b.text(); if (!/<svg[^>]*\swidth=/.test(txt)) { du = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(txt.replace('<svg ', '<svg width="128" height="128" ')))); } } catch (e) {} }- src = du || URL.createObjectURL(b); }- let im; try { im = await load(src); } catch (e) { if (c.data || !src.startsWith('data:')) continue; try { im = await load(URL.createObjectURL(await (await fetch(c.href, { credentials: 'include' })).blob())); } catch (e2) { continue; } }- if (!(im.naturalWidth > 0) && !/svg/.test(type)) continue;- if (!im.naturalWidth) { im.width = 64; im.height = 64; }- img = im; href = c.href; via = c.via; format = /svg/.test(type) ? 'svg' : /png/.test(type) ? 'png' : /icon|ico/.test(type) ? 'ico' : /jpe?g/.test(type) ? 'jpeg' : /gif/.test(type) ? 'gif' : (type || '?').replace('image/', '');- break;- } catch (e) {}- }- let kind = 'page-favicon', transparent = null, darkDominant = false, lum = null;- if (!img) {- kind = isPdf ? 'generic-pdf' : 'generic-web'; format = 'svg'; href = isPdf ? 'generic:pdf' : 'generic:page'; via = 'generated (no favicon existed anywhere in this window)';- const svg = __GENERIC_WEB__; const svgPdf = __GENERIC_PDF__;- img = await load('data:image/svg+xml;base64,' + btoa(isPdf ? svgPdf : svg)); img.width = 48; img.height = 48;- transparent = false;- } else {- // Node analyseIcon: mean luminance of opaque pixels, and whether any pixel is see-through- const pr = document.createElement('canvas'); pr.width = 16; pr.height = 16; const pc = pr.getContext('2d'); pc.drawImage(img, 0, 0, 16, 16);- const d = pc.getImageData(0, 0, 16, 16).data; let sum = 0, n = 0, clear = 0;- for (let i = 0; i < d.length; i += 4) { if (d[i + 3] < 250) clear++; if (d[i + 3] > 40) { sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; n++; } }- lum = n ? Math.round(sum / n) : null; transparent = clear > 0; darkDominant = lum !== null && lum < 80;- }- const BSZ = 48, FIN = 2;- const lightCard = !!(darkDominant && transparent);- const cv = document.createElement('canvas'); cv.width = BSZ; cv.height = BSZ; const c = cv.getContext('2d');- const rr = (x, y, w, h, r) => { c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath(); };- rr(0, 0, BSZ, BSZ, 6); c.fillStyle = lightCard ? '#f1f3f4' : '#202124'; c.fill();- // fit: cover into the inset box- const box = BSZ - FIN * 2, iw = img.naturalWidth || img.width || 48, ih = img.naturalHeight || img.height || 48, sc = Math.max(box / iw, box / ih), dw = iw * sc, dh = ih * sc;- c.drawImage(img, FIN + (box - dw) / 2, FIN + (box - dh) / 2, dw, dh);- let out = cv;- if (pct < 100) {- const h = Math.max(8, Math.round(BSZ * pct / 100));- const cv2 = document.createElement('canvas'); cv2.width = BSZ; cv2.height = BSZ; const c2 = cv2.getContext('2d');- c2.drawImage(cv, BSZ - h, 0, h, h); // north-east, the tile text stays clear- out = cv2;- }- return JSON.stringify({ dataUrl: out.toDataURL('image/png'), kind, format, transparent, darkDominant, luminance: lum, href, via });-})()"##;- let fb = match fallback { Some(d) => format!("{{ href: {}, data: {} }}", serde_json::Value::String(d.split(',').next().unwrap_or("").to_string()), serde_json::Value::String(d.to_string())), None => "null".into() };- js.replace("__PCT__", &pct.to_string()).replace("__FALLBACK__", &fb)- .replace("__GENERIC_WEB__", &serde_json::Value::String(generic_icon_svg(false)).to_string())- .replace("__GENERIC_PDF__", &serde_json::Value::String(generic_icon_svg(true)).to_string())-} // ── the brand raster pipeline, dependency-free (gallia/brand/logos/SKILL.md, the May 2026 shootout) ── // resvg at 8x -> PIL Lanczos -> ImageMagick unsharp 0.5x0.5+1.5+0.02 -> alpha < 25 clamped to 0.
rust/identity/src/tile.rsadded+171@@ -0,0 +1,171 @@+//! The thread tile, rendered IN THE BINARY. Until 2.0.559 this was JavaScript evaluated inside whatever+//! page the window was on (the Node bridge had no way to draw text and the Rust port kept that), so the+//! icon depended on which site was open and a strict Content-Security-Policy (claude.ai, the wiki)+//! silently downgraded the button to a nameless icon. John, 2026-09-14: "it should just be in pup's rust+//! binary". Same layout as the browser version (two lines at most, whole words first, a size floor,+//! then a character-fit fallback), same plate (rx = s/5.5, #00b8b1, name bottom-left in #05221f), every+//! ICO entry rasterised at its own pixel size. No page, no lane, no CSP: the tile exists at birth.+use fontdue::{Font, FontSettings};++/// Familjen Grotesk 700, the same face the browser renderer loaded as "FGtile" (fg-700.woff2 -> TTF).+static FONT_TTF: &[u8] = include_bytes!("../assets/fg-700.ttf");+const BOX: f32 = 256.0;+const PLATE: [u8; 3] = [0x00, 0xb8, 0xb1];+const INK: [u8; 3] = [0x05, 0x22, 0x1f];++fn font() -> Option<Font> { Font::from_bytes(FONT_TTF, FontSettings::default()).ok() }++/// canvas measureText: advances plus kerning, at `px`.+fn width(f: &Font, text: &str, px: f32) -> f32 {+ let mut w = 0.0; let mut prev: Option<char> = None;+ for c in text.chars() {+ if let Some(p) = prev { if let Some(k) = f.horizontal_kern(p, c, px) { w += k; } }+ w += f.metrics(c, px).advance_width; prev = Some(c);+ }+ w+}++fn w_for(i: usize, total: usize) -> f32 { if total == 2 && i == 0 { BOX * 0.78 } else { BOX * 0.90 } }++fn fit_size(f: &Font, lines: &[String]) -> f32 {+ let mut s = BOX * 0.9;+ for _ in 0..90 {+ let ok = lines.iter().enumerate().all(|(j, l)| width(f, l, s) <= w_for(j, lines.len()));+ if ok && lines.len() as f32 * s * 0.95 <= BOX * 0.88 { break; }+ s *= 0.95;+ }+ s+}++/// The browser's `split(/(?<=[-\/])/)`: split AFTER every '-' or '/', keeping the delimiter on the left.+fn split_after_delims(t: &str) -> Vec<String> {+ let mut out = Vec::new(); let mut cur = String::new();+ for c in t.chars() { cur.push(c); if c == '-' || c == '/' { out.push(std::mem::take(&mut cur)); } }+ if !cur.is_empty() { out.push(cur); }+ out+}+fn glue(t: &[String]) -> String {+ t.iter().fold(String::new(), |a, x| { let sep = if !a.is_empty() && !(a.ends_with('-') || a.ends_with('/')) { " " } else { "" }; a + sep + x })+}+fn take_chars(s: &[char], n: usize) -> String { s[..n.min(s.len())].iter().collect() }++/// Lines and font size for a name: a direct port of the browser layout.+pub fn layout(f: &Font, nm: &str) -> (Vec<String>, f32) {+ let floor = BOX * 0.36;+ let mut best: Option<Vec<String>> = None; let mut bf = 0.0f32;+ let toks: Vec<String> = nm.split_whitespace().flat_map(split_after_delims).filter(|t| !t.is_empty()).collect();+ if toks.len() > 1 {+ for k in 1..toks.len() {+ let c = vec![glue(&toks[..k]), glue(&toks[k..])];+ let fs = fit_size(f, &c);+ if fs >= floor && fs > bf { bf = fs; best = Some(c); }+ }+ let one = fit_size(f, &[nm.to_string()]);+ if one >= floor && one > bf { bf = one; best = Some(vec![nm.to_string()]); }+ }+ if best.is_none() {+ let chars: Vec<char> = nm.chars().collect();+ let mut cands: Vec<Vec<String>> = vec![vec![nm.to_string()]];+ let sp = nm.rfind(' '); let hy = nm.find('-');+ if let Some(sp) = sp.filter(|&i| i > 0) { cands.push(vec![nm[..sp].to_string(), nm[sp + 1..].to_string()]); }+ else if let Some(hy) = hy.filter(|&i| i > 0) { cands.push(vec![nm[..hy + 1].to_string(), nm[hy + 1..].to_string()]); }+ if sp.is_none() && hy.is_none() && chars.len() >= 7 { let m = (chars.len() + 1) / 2; cands.push(vec![take_chars(&chars, m), chars[m..].iter().collect()]); }+ let mut b = vec![nm.to_string()]; bf = 0.0;+ for c in &cands { let fs = fit_size(f, c); if fs > bf { bf = fs; b = c.clone(); } }+ if bf < floor {+ bf = floor;+ let take = |s: &[char], maxw: f32| -> usize { let mut n = 0; for i in 1..=s.len() { if width(f, &take_chars(s, i), floor) <= maxw { n = i; } else { break; } } n };+ let mut rest: Vec<char> = nm.trim().chars().collect(); let mut out: Vec<String> = Vec::new();+ for ln in 0..2 {+ if rest.is_empty() { break; }+ let mut n = take(&rest, w_for(ln, 2));+ if n >= rest.len() { out.push(rest.iter().collect()); rest.clear(); break; }+ if n < 1 { n = 1; }+ let seg: Vec<char> = rest[..(n + 1).min(rest.len())].to_vec();+ let bp = seg.iter().rposition(|&c| c == ' ').map(|a| a as i64).unwrap_or(-1).max(seg.iter().rposition(|&c| c == '-').map(|a| a as i64).unwrap_or(-1));+ let cut = if bp >= ((n as f32) * 0.5).ceil() as i64 { bp as usize + if seg[bp as usize] == '-' { 1 } else { 0 } } else { n };+ out.push(rest[..cut].iter().collect::<String>().trim().to_string());+ let mut r2: Vec<char> = rest[cut..].to_vec(); while r2.first().map(|&c| c == ' ' || c == '-').unwrap_or(false) { r2.remove(0); } rest = r2;+ }+ b = out.into_iter().filter(|l| !l.is_empty()).collect();+ }+ best = Some(b);+ }+ (best.unwrap_or_else(|| vec![nm.to_string()]), bf)+}++/// One RGBA tile at `s` px: anti-aliased rounded plate, the name bottom-left, glyphs rasterised at this size.+pub fn draw(f: &Font, lines: &[String], bf: f32, s: u32) -> Vec<u8> {+ let sf = s as f32; let k = sf / BOX; let r = sf / 5.5;+ let mut px = vec![0u8; (s * s * 4) as usize];+ // plate: signed distance to the rounded rectangle, one sample per pixel centre with a half-pixel ramp+ for y in 0..s { for x in 0..s {+ let (cx, cy) = (x as f32 + 0.5 - sf / 2.0, y as f32 + 0.5 - sf / 2.0);+ let (dx, dy) = ((cx.abs() - (sf / 2.0 - r)).max(0.0), (cy.abs() - (sf / 2.0 - r)).max(0.0));+ let d = (dx * dx + dy * dy).sqrt() - r;+ let cov = (0.5 - d).clamp(0.0, 1.0);+ let i = ((y * s + x) * 4) as usize;+ px[i] = PLATE[0]; px[i + 1] = PLATE[1]; px[i + 2] = PLATE[2]; px[i + 3] = (cov * 255.0).round() as u8;+ } }+ // text: canvas textAlign left, textBaseline alphabetic, lh = 0.95 em, margin 7.5%, baseline lifted 0.14 em+ let fpx = bf * k; let lh = fpx * 0.95; let mx = sf * 0.075; let base_y = sf - sf * 0.075 - fpx * 0.14;+ for (i, line) in lines.iter().enumerate() {+ let by = base_y - (lines.len() as f32 - 1.0 - i as f32) * lh;+ draw_text(f, &mut px, s, s, line, fpx, mx, by, INK);+ }+ px+}++/// Blit a line of text (left-aligned, alphabetic baseline) onto an RGBA buffer, glyphs rasterised by+/// fontdue at exactly `fpx`, coverage-blended into RGB. Shared by the tile and the annotate caption bar.+pub fn draw_text(f: &Font, px: &mut [u8], w: u32, h: u32, text: &str, fpx: f32, x0: f32, baseline: f32, ink: [u8; 3]) {+ let mut x = x0; let mut prev: Option<char> = None;+ for c in text.chars() {+ if let Some(p) = prev { if let Some(kn) = f.horizontal_kern(p, c, fpx) { x += kn; } }+ let (m, bm) = f.rasterize(c, fpx);+ let ox = (x + m.xmin as f32).round() as i64; let oy = (baseline - (m.ymin as f32 + m.height as f32)).round() as i64;+ for gy in 0..m.height { for gx in 0..m.width {+ let (tx, ty) = (ox + gx as i64, oy + gy as i64);+ if tx < 0 || ty < 0 || tx >= w as i64 || ty >= h as i64 { continue; }+ let a = bm[gy * m.width + gx] as f32 / 255.0; if a <= 0.0 { continue; }+ let idx = ((ty as u32 * w + tx as u32) * 4) as usize;+ for ch in 0..3 { px[idx + ch] = (px[idx + ch] as f32 * (1.0 - a) + ink[ch] as f32 * a).round() as u8; }+ } }+ x += m.advance_width; prev = Some(c);+ }+}+pub fn font_handle() -> Option<Font> { font() }++/// The finished .ico for a thread name: every TILE_SIZES entry drawn at its own size.+pub fn render_ico(name: &str) -> Option<Vec<u8>> {+ let f = font()?;+ let (lines, bf) = layout(&f, name);+ let mut raw: Vec<(u32, Vec<u8>)> = Vec::new(); let mut png256: Vec<u8> = Vec::new();+ for &sz in crate::TILE_SIZES.iter() {+ let px = draw(&f, &lines, bf, sz);+ if sz >= 256 { png256 = crate::png_encode_rgba(&px, sz, sz); } else { raw.push((sz, px)); }+ }+ Some(crate::ico_from_tiles(&raw, &png256))+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn renders_every_entry_and_writes_samples() {+ let f = font().expect("font");+ let dir = std::env::temp_dir().join("pup-tile-samples"); let _ = std::fs::create_dir_all(&dir);+ for name in ["pup-bridge", "pup-ab-smoke", "kicad-bridge-esc-routing", "bt556", "sxsw", "Logic gate molecule family"] {+ let (lines, bf) = layout(&f, name);+ assert!(!lines.is_empty() && lines.len() <= 2 && bf > 0.0, "{name}: {lines:?} {bf}");+ for sz in [256u32, 48, 32, 16] {+ let px = draw(&f, &lines, bf, sz);+ std::fs::write(dir.join(format!("{}-{sz}.png", crate::thread_slug(name))), crate::png_encode_rgba(&px, sz, sz)).unwrap();+ }+ let ico = render_ico(name).expect("ico");+ assert!(ico.len() > 2000, "{name}: ico {} bytes", ico.len());+ std::fs::write(dir.join(format!("{}.ico", crate::thread_slug(name))), ico).unwrap();+ eprintln!("{name}: {lines:?} at {bf:.1}px");+ }+ }+}
rust/identity/tests/svg_favicon.rsadded+13@@ -0,0 +1,13 @@+// 2.0.583: the wiki serves ONLY an SVG favicon; the compositor must rasterize it (it wore the generic globe before).+#[test]+fn wiki_svg_favicon_rasterizes() {+ let bytes = include_bytes!("../wiki-favicon.svg");+ assert!(pup_identity::badge::is_svg(bytes), "the wiki favicon must be recognised as SVG");+ assert!(pup_identity::badge::looks_like_image(bytes), "SVG must pass the favicon guard");+ let im = pup_identity::badge::decode(bytes).expect("the SVG must rasterize");+ let (w, h) = im.dimensions();+ assert!(w >= 32 && h >= 32 && w <= 64 && h <= 64, "unexpected size {w}x{h}");+ let painted = im.pixels().filter(|p| p[3] > 0).count();+ assert!(painted > 100, "only {painted} painted pixels: the render is blank");+ assert!(!pup_identity::badge::looks_like_image(b"<!doctype html><html><body>404</body></html>"), "HTML must still be rejected");+}
rust/identity/wiki-favicon.svgadded+1@@ -0,0 +1 @@+<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 244 244"><defs><style>.cls-1{fill:none;}.cls-1,.cls-2,.cls-3,.cls-4{stroke-width:0px;}.cls-2{fill:#8C6BF7;}.cls-3{fill:#fff;}.cls-4{fill:#00204F;}.cls-5{clip-path:url(#clippath);}</style><clipPath id="clippath"><rect class="cls-1" x="2.33" y="2.33" width="239.33" height="239.33"/></clipPath></defs><path class="cls-3" d="M184.3,59.7c-16.95-16.95-41.26-20.14-54.29-7.11-5.29,5.29-7.9,12.43-8.01,20.18h0s0,0,0,0c0,.03,0,.05,0,.08v48.09c0,.58.47,1.05,1.05,1.05h47.57-.01c7.99,0,15.37-2.6,20.79-8.02,13.03-13.03,9.84-37.33-7.11-54.29Z"/><path class="cls-3" d="M59.7,184.3c16.95,16.95,41.26,20.14,54.29,7.11,5.29-5.29,7.9-12.43,8.01-20.18h0s0,0,0,0c0-.03,0-.05,0-.08v-48.09c0-.58-.47-1.05-1.05-1.05h-47.57.01c-7.99,0-15.37,2.6-20.79,8.02-13.03,13.03-9.84,37.33,7.11,54.29Z"/><g class="cls-5"><path class="cls-2" d="M206.67,241.67H37.33c-19.33,0-34.99-15.67-34.99-34.99V37.33C2.33,18,18,2.33,37.33,2.33h169.34c19.33,0,34.99,15.67,34.99,34.99v169.34c0,19.33-15.67,34.99-34.99,34.99"/><path class="cls-4" d="M173.76,70.24c-14.09-14.09-34.28-16.73-45.1-5.91-4.39,4.39-6.56,10.33-6.66,16.77h0v40.03c0,.48.39.88.88.88h39.52,0c6.64,0,12.77-2.16,17.28-6.66,10.82-10.82,8.18-31.02-5.91-45.1"/><path class="cls-4" d="M70.24,173.76c14.09,14.09,34.28,16.73,45.1,5.91,4.39-4.39,6.56-10.33,6.66-16.77h0v-40.03c0-.48-.39-.88-.88-.88h-39.52,0c-6.64,0-12.77,2.16-17.28,6.66-10.82,10.82-8.18,31.02,5.91,45.1"/></g></svg>
rust/session/src/lib.rs+16−3@@ -25,11 +25,19 @@ pub struct Tab { pub struct Session { pub id: String, pub profile: String,+ /// "cdp" (a DevTools port drives it) or "ext" (pup's extension channel drives it, no port). The window is+ /// a first-class session either way: identity, overlay, progress, park, recovery all key on this struct.+ pub transport: String, pub owner: Option<String>, pub owner_container: Option<String>, pub created_ms: u64, pub created_iso: String, pub hwnd: Option<i64>,+ /// The CDP Browser window id (Browser.getWindowForTarget), the key that ties user-opened tabs and a+ /// replaced last tab back to THIS window (2.0.453).+ pub window_id: Option<i64>,+ /// When the last tab target was destroyed while the OS window may still be alive (2.0.453 grace).+ pub tabless_since_ms: u64, pub pid: Option<u32>, pub cdp_port: u16, pub tabs: Vec<Tab>,@@ -72,7 +80,7 @@ impl Session { /// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null. pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } } pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {- Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, born_at_ms: pup_core::now_ms(), user_active_at_ms: 0, raise_granted: false, overlay_why: None, overlay_led: None, last_paint_sig: None, last_real_fav: None, generic_retried_url: None, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None }+ Session { id: id.into(), profile: profile.into(), transport: "cdp".into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, window_id: None, tabless_since_ms: 0, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, born_at_ms: pup_core::now_ms(), user_active_at_ms: 0, raise_granted: false, overlay_why: None, overlay_led: None, last_paint_sig: None, last_real_fav: None, generic_retried_url: None, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None } } pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id } pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }@@ -84,10 +92,15 @@ impl Session { /// recover the other's windows). pub fn file_json(&self) -> Value { json!({- "sessionId": self.id, "profile": self.profile, "url": self.active().map(|t| t.url.clone()),+ "sessionId": self.id, "profile": self.profile, "transport": self.transport, "url": self.active().map(|t| t.url.clone()), "pid": self.pid, "cdpPort": self.cdp_port, "foreground": self.foreground, "owner": self.owner,- "appId": self.app_id, "_hwnd": self.hwnd, "createdAt": self.created_iso, "updatedAt": pup_core::iso_now(),+ "appId": self.app_id, "_hwnd": self.hwnd, "windowId": self.window_id, "createdAt": self.created_iso, "updatedAt": pup_core::iso_now(), "targetId": self.active().map(|t| t.target_id.clone()), "shell": "rust",+ // 2.0.461: EVERY tab, not just the active one. The file used to carry a single url, so when a+ // window came back (a Chrome restart, a wedged browser, a crash) the other tabs were simply gone:+ // John lost 19 of 20 component tabs that way on 2026-09-10 and I had to retype them from a log.+ "tabs": self.tabs.iter().map(|t| json!({ "url": t.url, "title": t.title })).collect::<Vec<_>>(),+ "activeTab": self.active_tab, }) } }
rust/vault/src/lib.rs+42@@ -124,6 +124,48 @@ impl Vault { removed } + /// Every stored login WITH its password, for a pup-to-pup vault sync. host, username, password,+ /// updatedAt (so the other box can newest-wins on a conflict). Only ever handed to a LOCAL or+ /// same-account bridge caller by the verb — never a cloud relay, never an AI.+ pub fn export_all(&self, host: &dyn HostSecrets) -> Vec<Value> {+ let mut out = Vec::new();+ for e in self.read_index() {+ let hostname = e["host"].as_str().unwrap_or("");+ let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("");+ if hostname.is_empty() { continue; }+ if let Some(pw) = host.secret_get(CRED_SERVICE, account) {+ out.push(json!({ "host": hostname, "username": e["username"].as_str().unwrap_or(""), "password": pw, "updatedAt": e["updatedAt"].as_str().or(e["addedAt"].as_str()).unwrap_or(""), "sourceAccount": e["sourceAccount"] }));+ }+ }+ out+ }++ /// Merge entries [{host, username, password, updatedAt}] from another box's vault. Newest-wins on a+ /// password conflict (the incoming updatedAt vs the local one); identical entries are skipped.+ pub fn import_merge(&self, host: &dyn HostSecrets, entries: &[Value]) -> (u64, u64, u64) {+ let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);+ for e in entries {+ let hostname = e["host"].as_str().unwrap_or("").trim();+ let (user, pass, incoming_ts) = (e["username"].as_str().unwrap_or(""), e["password"].as_str().unwrap_or(""), e["updatedAt"].as_str().unwrap_or(""));+ if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }+ let local = self.read_index().into_iter().find(|x| x["host"].as_str() == Some(hostname));+ match &local {+ None => { if self.set(host, hostname, if user.is_empty() { "(unknown)" } else { user }, pass).is_ok() { added += 1; } else { skipped += 1; } }+ Some(le) => {+ let account = le["keytarAccount"].as_str().or(Some(hostname)).unwrap_or(hostname);+ let local_pw = host.secret_get(CRED_SERVICE, account).unwrap_or_default();+ let same = local_pw == pass && (user.is_empty() || le["username"].as_str() == Some(user));+ if same { skipped += 1; continue; }+ let local_ts = le["updatedAt"].as_str().or(le["addedAt"].as_str()).unwrap_or("");+ // newest-wins: only overwrite when the incoming entry is newer than ours+ if incoming_ts > local_ts { if self.set(host, hostname, if user.is_empty() { le["username"].as_str().unwrap_or("(unknown)") } else { user }, pass).is_ok() { updated += 1; } else { skipped += 1; } }+ else { skipped += 1; }+ }+ }+ }+ (added, updated, skipped)+ }+ pub fn touch_used(&self, hostname: &str) { let mut entries = self.read_index(); if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }
rust/verbs/assets/consumer-eme.jsadded+34@@ -0,0 +1,34 @@+(() => {+ try {+ const orig = navigator.requestMediaKeySystemAccess && navigator.requestMediaKeySystemAccess.bind(navigator);+ if (!orig) return;+ const shim = function requestMediaKeySystemAccess(keySystem, configs) {+ return orig(keySystem, configs).catch((e) => {+ if (keySystem === 'com.widevine.alpha') {+ const cfg = (configs && configs[0]) || {};+ return Promise.resolve({+ keySystem,+ getConfiguration: () => ({+ label: cfg.label || '',+ initDataTypes: cfg.initDataTypes || ['cenc'],+ audioCapabilities: cfg.audioCapabilities || [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO' }],+ videoCapabilities: cfg.videoCapabilities || [{ contentType: 'video/mp4; codecs="avc1.42E01E"', robustness: 'SW_SECURE_CRYPTO' }],+ distinctiveIdentifier: 'optional', persistentState: 'optional', sessionTypes: ['temporary'],+ }),+ createMediaKeys: () => orig(keySystem, configs).then((a) => a.createMediaKeys()),+ });+ }+ throw e;+ });+ };+ // Detectors call Function.prototype.toString on APIs they suspect are overridden. Present this shim (and the+ // patched toString itself) as native code so the override is not itself the tell.+ const FT = Function.prototype.toString;+ const native = new Map();+ native.set(shim, 'function requestMediaKeySystemAccess() { [native code] }');+ const patchedToString = function toString() { const s = native.get(this); return s !== undefined ? s : FT.call(this); };+ native.set(patchedToString, 'function toString() { [native code] }');+ Object.defineProperty(Function.prototype, 'toString', { value: patchedToString, writable: true, configurable: true });+ Object.defineProperty(navigator, 'requestMediaKeySystemAccess', { value: shim, writable: true, configurable: true });+ } catch (e) {}+})();
rust/verbs/assets/render-probe.js+10@@ -1,10 +1,20 @@ (async () => { try { if (document.readyState !== 'complete') { await Promise.race([new Promise((r) => window.addEventListener('load', r, { once: true })), new Promise((r) => setTimeout(r, 1500))]); } } catch (e) {} const t = (document.body ? (document.body.innerText || '') : '').trim();+ // Chrome's net-error interstitial (ERR_HTTP2_PROTOCOL_ERROR, ERR_CONNECTION_*, a bot-wall RST, etc.).+ // It renders as its own document with #main-frame-error and a hidden .error-code carrying the code.+ let errPage = false, netError = '';+ try {+ const ec = document.querySelector('.error-code');+ netError = ec ? (ec.textContent || '').trim() : '';+ const cls = (document.documentElement && document.documentElement.className) || '';+ errPage = !!netError || !!document.querySelector('#main-frame-error') || /(^|\s)neterror(\s|$)/.test(cls) || /^chrome-error:/i.test(location.href);+ } catch (e) {} return { title: document.title || '', bodyChars: t.length, head: t.slice(0, 400), elements: document.body ? document.body.querySelectorAll('*').length : 0, styleSheets: document.styleSheets ? document.styleSheets.length : 0,+ errPage, netError, brokenImgs: (document.readyState === 'complete' && document.images) ? Array.from(document.images).filter((i) => { const src = i.getAttribute('src'); return !!(src && src.trim()) && i.complete && i.naturalWidth === 0; }).length : null, }; })()
rust/verbs/assets/verb-catalog.json+124@@ -1678,6 +1678,73 @@ "summary": "This list \u2014 the bridge's verbs + schemas, for AD's Verbs tab.", "timeoutSeconds": 10 },+ {+ "example": {+ "selector": "select[name=timezone]",+ "label": "Central Time"+ },+ "hint": "Sets the option through the native setter and fires input+change so framework-bound selects update; works on both lanes (DevTools evaluate, so page CSP and Trusted Types cannot refuse it). For custom dropdowns (Material, React Select) use pup_click on the control, then pup_click the option by text. option_not_found lists the first 30 options.",+ "input": {+ "selector": "required str",+ "value": "optional str (option value)",+ "label": "optional str (visible text, case-insensitive, substring ok)",+ "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+ },+ "longRunning": false,+ "name": "pup_select",+ "output": {+ "success": "bool"+ },+ "pitfalls": [],+ "related": [+ "pup_fill",+ "pup_click",+ "pup_type"+ ],+ "statusVerb": null,+ "summary": "Choose a <select> option by value or visible label, in the background (fires input+change).",+ "timeoutSeconds": 20+ },+ {+ "example": {},+ "hint": "START HERE for window management: everything YOUR thread owns \u2014 windows, tabs, urls, titles, state \u2014 with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you \u2014 omit sessionId in any pup verb to target your own window, or pass \"ai-thread\" to address a thread by name (the CLI's --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.",+ "input": {+ "ai-thread": "optional str \u2014 inspect another thread's windows (read-only listing); default = the calling thread. The CLI flag's exact spelling; aiThread also accepted"+ },+ "longRunning": false,+ "name": "pup_list_sessions",+ "output": {+ "success": "bool"+ },+ "pitfalls": [],+ "related": [],+ "statusVerb": null,+ "summary": "Alias of pup_my_windows: list the sessions owned by the calling ai-thread. Passive, never launches a browser.",+ "timeoutSeconds": 10+ },+ {+ "example": {+ "sessionId": "work"+ },+ "hint": "Closes the whole session and all its tabs. Destructive.",+ "input": {+ "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+ },+ "longRunning": false,+ "name": "pup_close_all_windows",+ "output": {+ "success": "bool"+ },+ "pitfalls": [+ "this ends the session \u2014 its sessionId is no longer valid afterward"+ ],+ "related": [+ "pup_list_windows"+ ],+ "statusVerb": null,+ "summary": "Close every pup window owned by the calling ai-thread (pup_close_window with all:true). Other threads' windows are never touched.",+ "timeoutSeconds": 20+ }, { "name": "pup_import_scan", "summary": "List the Chromium browser profiles on this desktop that have saved passwords (Chrome/Edge/Brave). Windows-only. Read-only discovery; the decrypt is a separate, consented step.",@@ -1695,6 +1762,63 @@ "pup_credentials" ], "pitfalls": []+ },+ {+ "name": "pup_vault_sync",+ "summary": "Merge this desktop's adom-you logins with another desktop's, BOTH directions, so both boxes hold the union (newest-wins on conflict). One consent toast on this box; passwords move pup-to-pup and never touch an AI.",+ "input": {+ "peer": "required str (the other desktop's name from `targets`)",+ "reason": "required str, 8+ chars, shown to the user"+ },+ "output": {+ "success": "bool"+ },+ "timeoutSeconds": 60,+ "statusVerb": null,+ "longRunning": false,+ "example": {},+ "hint": "Use when two of the user's desktops have each learned different logins and you want both complete. Not a one-way push: it is a real union.",+ "related": [+ "pup_credentials",+ "pup_import_browser_logins"+ ],+ "pitfalls": []+ },+ {+ "name": "pup_vault_export",+ "summary": "Internal to pup_vault_sync: hand this box's stored logins (with passwords) to a same-account peer desktop's pup. Refused for cloud/relay callers.",+ "input": {},+ "output": {+ "success": "bool"+ },+ "timeoutSeconds": 30,+ "statusVerb": null,+ "longRunning": false,+ "example": {},+ "hint": "You do not call this directly; pup_vault_sync calls it on the peer.",+ "related": [+ "pup_vault_sync"+ ],+ "pitfalls": []+ },+ {+ "name": "pup_vault_import",+ "summary": "Internal to pup_vault_sync: merge a peer desktop's logins into this box's vault (newest-wins). Refused for cloud/relay callers.",+ "input": {+ "entries": "array of {host, username, password, updatedAt}"+ },+ "output": {+ "success": "bool"+ },+ "timeoutSeconds": 30,+ "statusVerb": null,+ "longRunning": false,+ "example": {},+ "hint": "You do not call this directly; pup_vault_sync calls it on the peer.",+ "related": [+ "pup_vault_sync"+ ],+ "pitfalls": [] } ] }
rust/verbs/src/activity.rs+26−8@@ -18,7 +18,7 @@ const DEFAULT_EST_MS: [(&str, u64); 4] = [("icon-apply", 2200), ("screenshot", 9 static TIMING: Mutex<Option<HashMap<String, (u64, u64)>>> = Mutex::new(None); // op -> (n, avgMs), persisted pub fn mode(ctx: &Ctx) -> String { let m = ctx.settings.get("taskbarActivity", "progress"); if m == "flash" || m == "off" { m } else { "progress".into() } }-fn minimal_touch(ctx: &Ctx) -> bool { ctx.settings.get("minimalTouch", "on") == "on" }+pub fn minimal_touch(ctx: &Ctx) -> bool { ctx.settings.get("minimalTouch", "on") == "on" } fn timing_file(ctx: &Ctx) -> std::path::PathBuf { ctx.host.state_dir().join("pup-verb-timing.json") } fn load(ctx: &Ctx) {@@ -55,26 +55,36 @@ fn push(ctx: &Ctx, hwnd: i64, value: Option<u64>) { /// Node withTaskbarProgress: a bar that fills over the running-average estimate for `op`, capped at /// 92% until the work really finishes, then 100% and cleared. Records the true duration. pub fn with_progress<T>(ctx: &Ctx, sid: &str, op: &str, work: impl FnOnce() -> T) -> T {- if !cfg!(windows) || mode(ctx) != "progress" || minimal_touch(ctx) { return work(); } let Some(hwnd) = ctx.sessions.get(sid).and_then(|s| s.hwnd) else { return work() };+ let r = progress_on_hwnd(ctx, hwnd, op, work);+ ctx.sessions.with_mut(sid, |x| x.progress_count += 1);+ r+}++/// The ONE progress-bar implementation: a bar that fills over the running-average estimate for `op`,+/// capped at 92% until the work finishes, then 100% and cleared. Both entry points use it — the+/// session-keyed `with_progress` and the extension lane (which has no session), so the lane no longer+/// carries a hand-rolled flat bar (John, 2026-09-13: "why ... you aren't sharing your own code").+pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() -> T) -> T {+ if !cfg!(windows) || mode(ctx) != "progress" || minimal_touch(ctx) { return work(); } let Some(arc) = ctx.arc() else { return work() }; let est = estimate_ms(ctx, op).max(200); let started = std::time::Instant::now(); let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); let d2 = done.clone(); push(ctx, hwnd, Some(2));+ let arc2 = arc.clone(); std::thread::Builder::new().name("taskbar-progress".into()).spawn(move || { while !d2.load(std::sync::atomic::Ordering::Relaxed) { std::thread::sleep(Duration::from_millis(180)); if d2.load(std::sync::atomic::Ordering::Relaxed) { break; } let f = (started.elapsed().as_millis() as f64 / est as f64).min(0.92);- push(&arc, hwnd, Some(((f * 100.0) as u64).max(2)));+ push(&arc2, hwnd, Some(((f * 100.0) as u64).max(2))); } }).ok(); let r = work(); done.store(true, std::sync::atomic::Ordering::Relaxed); record(ctx, op, started.elapsed().as_millis() as u64); push(ctx, hwnd, Some(100));- if let Some(arc) = ctx.arc() { std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(220)); push(&arc, hwnd, None); }); }- ctx.sessions.with_mut(sid, |x| { x.progress_count += 1; x.progress_ms += started.elapsed().as_millis() as u64; });+ std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(220)); push(&arc, hwnd, None); }); r } @@ -104,10 +114,18 @@ pub fn mark_icon_applied(ctx: &Ctx, sid: &str) { ctx.sessions.with_mut(sid, |x| pub fn clear_attention(ctx: &Ctx, hwnd: i64, caller: &Caller) -> String { if !cfg!(windows) { return "not-windows".into(); } if let Ok(fg) = ctx.ab.call("desktop_get_foreground", json!({}), caller, Duration::from_secs(4)) { if fg["hwnd"].as_i64() == Some(hwnd) { return "already-foreground".into(); } }- match ctx.ab.call("desktop_clear_window_attention", json!({ "hwnd": hwnd }), caller, Duration::from_secs(8)) {- Ok(r) => if r["cleared"].as_bool() == Some(true) || r["ok"].as_bool() == Some(true) { "cleared".into() } else { "error".into() },+ let r = match ctx.ab.call("desktop_clear_window_attention", json!({ "hwnd": hwnd }), caller, Duration::from_secs(8)) {+ Ok(r) => if r["cleared"].as_bool() == Some(true) || r["ok"].as_bool() == Some(true) { "cleared".to_string() } else { "error".to_string() }, Err(e) => format!("error: {e}"),- }+ };+ // 2.0.585 (adom-bridge#199): ab's clear RAISES the window to z1 (measured: z16 -> z1 on every call), which undid+ // every park at open and after every navigation. We only get here when the window is NOT the user's foreground+ // (checked above), so putting it back at the bottom restores the resting state and can never take a window+ // from the user. Remove once ab's verb stops touching placement.+ // 2.0.588: re-check AFTER the clear (the user may have raised it during the round trip): a foreground window+ // is the user's and is never touched.+ if ctx.host.foreground() != hwnd { ctx.host.send_to_bottom_no_activate(hwnd); }+ r } /// The birth signal: `flash` = finite four-count orange flash (never forever); `progress` = the
rust/verbs/src/annotate.rs+43−35@@ -3,14 +3,13 @@ //! iTXt sidecar, the clipboard (ab desktop_clipboard_set, adom-bridge#145) and the on-page //! confirmation toast. Ports startAnnotation / processAnnotateCapture / addCaptionBar / pngAddITXt. -use crate::input::resolve_tab;-use crate::{browser_for, touch, Ctx};+use crate::{touch, Ctx}; use pup_core::{err, ok, Caller}; use serde_json::{json, Value}; use std::sync::Arc; use std::time::{Duration, Instant}; -pub const OVERLAY_JS: &str = include_str!("../assets/annotate-overlay.js");+pub const OVERLAY_JS: &str = include_str!("../../../src/extension/annotate-overlay.js"); // ONE copy: shipped in the extension (file-injected on the ext lane), evaluated on CDP pub const FLOATY_JS: &str = include_str!("../assets/floaty.js"); const T: Duration = Duration::from_secs(10); @@ -21,69 +20,78 @@ pub fn floaty_script(ctx: &Ctx) -> String { /// pup_annotate {tool: pen|snip, mode: auto|multi}: arm the overlay and watch for the capture. pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {- let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+ let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let Some(t) = s.active().cloned() else { return err("no_tab", "the window has no active tab", None) };+ let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e }; let tool = args["tool"].as_str().unwrap_or("pen").to_string(); let mode = args["mode"].as_str().filter(|m| *m == "multi" || *m == "auto").map(|m| m.to_string());- if let Err(e) = b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ if (!window.__pupStartAnnotate) {{ {OVERLAY_JS} }} return !!window.__pupStartAnnotate; }})()"), T) { return err("annotate_inject_failed", e.to_string(), None); }- let call = format!("window.__pupStartAnnotate({}, {})", serde_json::to_string(&tool).unwrap(), mode.as_ref().map(|m| serde_json::to_string(m).unwrap()).unwrap_or("null".into()));- if let Err(e) = b.cdp.evaluate(&t.cdp_session, &call, T) { return err("annotate_start_failed", e.to_string(), None); }- pup_core::log(format!("[annotate] \"{}\" overlay started (by {}, tool={tool}, mode={})", s.id, caller.thread, mode.as_deref().unwrap_or("default")));- if let Some(a) = ctx.arc() { watch(a, s.id.clone(), t.cdp_session.clone(), caller.clone()); }+ // 2.0.569: both lanes. The overlay is one file in the extension folder: file-injected on the extension+ // lane (a page CSP cannot refuse that, unlike eval; Google Docs and the wiki used to fail here), and+ // evaluated over CDP on the CDP lane.+ if let Err(e) = crate::transport::inject_script(&lane, &t, "annotate-overlay.js", OVERLAY_JS, "window.__pupStartAnnotate") { return pup_core::merge(e, json!({ "errorCode": "annotate_inject_failed" })); }+ let mode_arg = mode.as_ref().map(|m| json!(m)).unwrap_or(Value::Null);+ if let Err(e) = crate::transport::call_window_fn(&lane, &t, "__pupStartAnnotate", &[json!(tool), mode_arg]) { return pup_core::merge(e, json!({ "errorCode": "annotate_start_failed" })); }+ pup_core::log(format!("[annotate] \"{}\" overlay started (by {}, tool={tool}, mode={}, lane={})", s.id, caller.thread, mode.as_deref().unwrap_or("default"), if crate::transport::is_ext(&s) { "ext" } else { "cdp" }));+ if let Some(a) = ctx.arc() { watch(a, s.id.clone(), caller.clone()); } touch(ctx, &s.id, None);- ok(json!({ "success": true, "output": json!({ "ok": true, "started": true, "raised": false, "sessionId": s.id, "tool": tool, "mode": mode, "armed": true }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,- "_hint": "Overlay armed on the page: the user draws (pen) or drags a region (snip). Auto mode captures on mouse-up; Multi mode captures on Done. The capture lands as a PNG in pup's screenshots folder AND on the user's clipboard (image + JSON sidecar); the owning thread's next pup_screenshot also shows the drawing. Nothing else to call." }))+ // Node v2.0.293: a HUMAN caller (jump-list task, toolbar pen) clicked Annotate, so the window+ // comes to their screen now, captioned; an AI caller arms the overlay in the BACKGROUND and must+ // go through pup_raise_os_window + foregroundReason.+ let user = crate::USER_CALLERS.contains(&caller.thread.as_str());+ let raised = if user {+ match &lane {+ crate::transport::Lane::Cdp(b) => { let _ = b.cdp.send(Some(&t.cdp_session), "Page.bringToFront", json!({}), T); }+ crate::transport::Lane::Ext => { let _ = crate::transport::activate(&lane, &t); }+ }+ crate::window::raise_for_user(ctx, &s, caller, &format!("Annotate: draw on the page, then hit Done ({})", if tool == "snip" { "drag a region" } else { "pen" }))+ } else { false };+ ok(json!({ "success": true, "output": json!({ "ok": true, "started": true, "raised": raised, "sessionId": s.id, "tool": tool, "mode": mode, "armed": true }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,+ "_hint": if user { "Overlay is live and the window is on the user's screen: pen/arrow/box/text, Done captures (the drawing stays visible so the owning AI thread's next screenshot shows it), the X cancels." } else { "Overlay armed in the BACKGROUND. To let the user draw, pup_raise_os_window with a foregroundReason; Done captures to the clipboard and the owning thread's last_annotation." } })) } /// Poll window.__pupAnnotateResult (500 ms, up to 15 min) and process each capture.-pub fn watch(ctx: Arc<Ctx>, sid: String, cdp_session: String, caller: Caller) {+pub fn watch(ctx: Arc<Ctx>, sid: String, caller: Caller) { std::thread::Builder::new().name("annotate-watch".into()).spawn(move || { let t0 = Instant::now(); while t0.elapsed() < Duration::from_secs(15 * 60) { std::thread::sleep(Duration::from_millis(500));- let Some(b) = browser_for(&ctx, &ctx.sessions.get(&sid).map(|s| s.profile).unwrap_or_default()) else { return };- let res = match b.cdp.evaluate(&cdp_session, "(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r || null; })()", Duration::from_secs(5)) { Ok(v) => v, Err(_) => return };+ let Some(s) = ctx.sessions.get(&sid) else { return };+ let Some(t) = s.active().cloned() else { return };+ let Ok(lane) = crate::transport::lane_for(&ctx, &s) else { return };+ let Ok((res, dpr)) = crate::transport::read_window(&lane, &t, "__pupAnnotateResult", true) else { return }; if res.is_null() { continue; }- if let Err(e) = process(&ctx, &sid, &cdp_session, &res, &caller) { pup_core::log(format!("[annotate] \"{sid}\" capture failed: {e}")); }- if res["mode"] == json!("multi") || res["done"] == json!(true) { /* keep watching: the user may draw again */ }+ if let Err(e) = process(&ctx, &sid, &lane, &t, dpr, &res, &caller) { pup_core::log(format!("[annotate] \"{sid}\" capture failed: {e}")); } } }).ok(); } -fn process(ctx: &Ctx, sid: &str, cdp_session: &str, res: &Value, caller: &Caller) -> Result<(), String> {+fn process(ctx: &Ctx, sid: &str, lane: &crate::transport::Lane, t: &pup_session::Tab, dpr: f64, res: &Value, caller: &Caller) -> Result<(), String> { let s = ctx.sessions.get(sid).ok_or("session gone")?;- let b = browser_for(ctx, &s.profile).ok_or("browser gone")?; let is_snip = res["snip"].is_object(); let clip = if is_snip { res["snip"].clone() } else { res["bbox"].clone() }; pup_core::log(format!("[annotate] \"{sid}\" capture: screenshotting ({})", if is_snip { "snip" } else { "tight-crop" }));- let mut p = json!({ "format": "png", "captureBeyondViewport": false });- if clip.is_object() { p["clip"] = json!({ "x": clip["x"].as_f64().unwrap_or(0.0).max(0.0), "y": clip["y"].as_f64().unwrap_or(0.0).max(0.0), "width": clip["w"].as_f64().unwrap_or(1.0).max(1.0), "height": clip["h"].as_f64().unwrap_or(1.0).max(1.0), "scale": 1 }); }- let r = b.cdp.send(Some(cdp_session), "Page.captureScreenshot", p, Duration::from_secs(15)).map_err(|e| e.to_string())?;- let b64 = r["data"].as_str().ok_or("no screenshot data")?.to_string();+ let clip_css = if clip.is_object() { Some((clip["x"].as_f64().unwrap_or(0.0), clip["y"].as_f64().unwrap_or(0.0), clip["w"].as_f64().unwrap_or(1.0), clip["h"].as_f64().unwrap_or(1.0))) } else { None };+ let shot = crate::transport::capture_viewport_png(lane, t, clip_css, dpr).map_err(|e| e["error"].as_str().unwrap_or("capture failed").to_string())?; let owner = s.owner.clone().unwrap_or_else(|| s.id.clone()); let url = s.active().map(|t| t.url.clone()).unwrap_or_default(); let short_url = url.chars().take(80).collect::<String>(); let when = pup_core::iso_now();- // caption bar + preview thumbnail, drawn by the page's canvas (Node: sharp)+ // caption bar + preview thumbnail, composed in the binary (2.0.569; it was an in-page canvas, which a page CSP refused) let caption = format!("pup · {owner}'s window · {short_url} · {}", &when[11..16]);- let js = format!(r#"(async () => {{ const img = new Image(); await new Promise((res, rej) => {{ img.onload = res; img.onerror = rej; img.src = 'data:image/png;base64,{b64}'; }});- const BAR = {bar}; const cv = document.createElement('canvas'); cv.width = img.naturalWidth; cv.height = img.naturalHeight + BAR; const c = cv.getContext('2d');- c.drawImage(img, 0, 0); if (BAR) {{ c.fillStyle = '#0d1117'; c.fillRect(0, img.naturalHeight, cv.width, BAR); c.fillStyle = '#00d4cb'; c.font = '12px Segoe UI, system-ui, sans-serif'; c.textBaseline = 'middle'; c.fillText({cap}, 8, img.naturalHeight + BAR / 2); }}- const pv = document.createElement('canvas'); const pw = Math.min(320, img.naturalWidth); pv.width = pw; pv.height = Math.round(img.naturalHeight * pw / img.naturalWidth); pv.getContext('2d').drawImage(img, 0, 0, pv.width, pv.height);- return {{ png: cv.toDataURL('image/png').split('base64,')[1], preview: {{ b64: pv.toDataURL('image/png').split('base64,')[1], w: pv.width, h: pv.height }} }}; }})()"#, bar = if ctx.settings.get("annCaptionBar", "on") != "off" { 26 } else { 0 }, cap = serde_json::to_string(&caption).unwrap());- let composed = b.cdp.evaluate(cdp_session, &js, Duration::from_secs(15)).map_err(|e| e.to_string())?;- let png_b64 = composed["png"].as_str().unwrap_or(&b64).to_string();- let mut png = pup_cdp::base64_decode(&png_b64).map_err(|e| e.to_string())?;- let sidecar = json!({ "kind": "pup-annotation", "thread": owner, "container": s.owner_container, "sessionId": sid, "url": url, "ts": when, "shapes": res["shapes"], "snip": if is_snip { res["snip"].clone() } else { Value::Null }, "viewport": res["viewport"] });+ let cap = if ctx.settings.get("annCaptionBar", "on") != "off" { Some(caption.as_str()) } else { None };+ let (mut png, preview_rgba, pw, ph) = pup_identity::badge::annotate_compose(&shot, cap).ok_or("could not compose the capture")?;+ let preview = json!({ "b64": pup_identity::base64_encode(&preview_rgba), "w": pw, "h": ph }); // raw RGBA: the overlay paints it, never loads it+ let sidecar = json!({ "kind": "pup-annotation", "thread": owner, "container": s.owner_container, "sessionId": sid, "url": url, "ts": when, "shapes": res["shapes"], "snip": if is_snip { res["snip"].clone() } else { Value::Null }, "bbox": res["bbox"], "viewport": res["viewport"] }); png = png_add_itxt(&png, "pup-annotation", &sidecar.to_string()); let dir = ctx.shots_dir(); let fp = dir.join(format!("annotate-{}{}-{}.png", if is_snip { "snip-" } else { "" }, pup_session::sanitize_sid(sid), pup_core::now_ms())); std::fs::write(&fp, &png).map_err(|e| e.to_string())?; ctx.sessions.with_mut(sid, |x| x.last_annotation = Some(json!({ "path": fp.to_string_lossy(), "ts": pup_core::now_ms(), "shapes": res["shapes"], "snip": sidecar["snip"] }))); let sidecar_text = format!("pup snippet — {owner}'s window — {url} — {when}\n{}", serde_json::to_string_pretty(&sidecar).unwrap_or_default());- let clipped = ctx.ab.call("desktop_clipboard_set", json!({ "image": fp.to_string_lossy().replace('\\', "/"), "text": sidecar_text }), caller, Duration::from_secs(8)).is_ok();+ let clipped = match ctx.ab.call("desktop_clipboard_set", json!({ "image": fp.to_string_lossy().replace('\\', "/"), "text": sidecar_text }), caller, Duration::from_secs(8)) { Ok(_) => true, Err(e) => { pup_core::log(format!("[annotate] \"{sid}\" clipboard copy failed: {e}")); false } }; let line1 = format!("{} copied to your clipboard", if is_snip { "Snippet" } else { "Annotation" });- let _ = b.cdp.evaluate(cdp_session, &format!("(() => {{ if (window.__pupCaptureDone) window.__pupCaptureDone({}, {}, {}); }})()", if clip.is_object() { clip.to_string() } else { "null".into() }, composed["preview"].to_string(), serde_json::to_string(&line1).unwrap()), Duration::from_secs(5));- pup_core::log(format!("[annotate] \"{sid}\" CAPTURED {} -> {} | clipboard {}", if is_snip { format!("SNIP {}x{}", res["snip"]["w"], res["snip"]["h"]) } else if clip.is_object() { format!("tight-crop {}x{}", clip["w"], clip["h"]) } else { "full-view".into() }, fp.display(), if clipped { "ok" } else { "FAILED (ab desktop_clipboard_set)" }));+ let _ = crate::transport::call_window_fn(lane, t, "__pupCaptureDone", &[if clip.is_object() { clip.clone() } else { Value::Null }, preview, json!(line1)]);+ pup_core::log(format!("[annotate] \"{sid}\" CAPTURED {} -> {} | clipboard {}", if is_snip { format!("SNIP {}x{}", res["snip"]["w"], res["snip"]["h"]) } else if clip.is_object() { format!("tight-crop {}x{}", clip["w"], clip["h"]) } else { "viewport".to_string() }, fp.display(), if clipped { "yes" } else { "no" })); Ok(()) }
rust/verbs/src/events.rs+49−2@@ -22,6 +22,7 @@ const ERROR_CAP: usize = 300; pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: TabStates) { let _ = cdp.send(None, "Target.setDiscoverTargets", json!({ "discover": true }), Duration::from_secs(5)); let rx = cdp.subscribe(None, None);+ let pump_started = std::time::Instant::now(); std::thread::Builder::new().name(format!("cdp-events-{profile}")).spawn(move || { while let Ok(ev) = rx.recv() { let sid = ev.session_id.clone().unwrap_or_default();@@ -53,13 +54,58 @@ pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: Ta let (url, title) = (info["url"].as_str().unwrap_or("").to_string(), info["title"].as_str().unwrap_or("").to_string()); for s in sessions.all() { if s.tabs.iter().any(|t| t.target_id == tid) { sessions.with_mut(&s.id, |x| { if let Some(t) = x.tabs.iter_mut().find(|t| t.target_id == tid) { t.url = url.clone(); t.title = title.clone(); } }); } } }+ // Chrome replays targetCreated for every EXISTING target right after setDiscoverTargets; those are+ // recovery's to sort out (2.0.454: the replay was adopted as "user-opened tabs" at boot and swallowed+ // the named sessions' targets). Only targets created after the pump has settled are adopted.+ "Target.targetCreated" if ev.params["targetInfo"]["type"] == json!("page") && pump_started.elapsed() > Duration::from_secs(5) => {+ // 2.0.453: a tab the USER opened in a pup window (Ctrl+T, a link, Chrome restoring a discarded+ // tab) becomes a tab of that window's session, keyed by the CDP Browser window id. Deferred+ // 400 ms so pup's own births (which attach their target themselves) win the race.+ let tid = ev.params["targetInfo"]["targetId"].as_str().unwrap_or("").to_string();+ let url = ev.params["targetInfo"]["url"].as_str().unwrap_or("").to_string();+ if !tid.is_empty() && !url.starts_with("chrome-extension://") && !url.starts_with("devtools://") {+ let (cdp2, sessions2) = (cdp.clone(), sessions.clone());+ std::thread::spawn(move || {+ std::thread::sleep(Duration::from_millis(1500)); // 2.0.467: pup's own opens are tracked within milliseconds now; this margin covers a slow attach+ if sessions2.all().iter().any(|s| s.tabs.iter().any(|t| t.target_id == tid)) { return; }+ let Ok((wid, _)) = cdp2.window_for_target(&tid) else { return };+ let Some(owner) = sessions2.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) else { return };+ // 2.0.473: don't adopt into a window that is mid-restore or just relaunched — the tab is+ // pup's own reopen or Chrome auto-restoring the killed session's page, not a user tab.+ if crate::recovery::restoring(&owner.id) || crate::recovery::recently_relaunched(&owner.id, 15_000) {+ pup_core::log(format!("[tabs] \"{}\": ignored a target that appeared during its restore ({})", owner.id, &url[..url.len().min(80)]));+ return;+ }+ // 2.0.478: never adopt a URL the window already has a tab for. Chrome spawns an extra+ // page target for a page pup already opened (a prerender, a second target for the same+ // URL), and adopting it added a duplicate tab that pup then could not drive — the whole+ // window read 0-live after a restart (isolated single-restart, ~1 in 5). A genuine+ // second tab of the same URL is rare and not worth that failure.+ let base = |u: &str| u.split('?').next().unwrap_or("").trim_end_matches('/').to_string();+ if !base(&url).is_empty() && owner.tabs.iter().any(|t| base(&t.url) == base(&url)) {+ pup_core::log(format!("[tabs] \"{}\": ignored a duplicate target for a URL it already has ({})", owner.id, &url[..url.len().min(80)]));+ return;+ }+ let mut s = owner.clone();+ match crate::attach_tab(&mut s, &cdp2, &tid) {+ Ok(tab_id) => { s.tabless_since_ms = 0; pup_core::log(format!("[tabs] \"{}\": adopted a tab opened outside pup as {tab_id} ({})", s.id, &url[..url.len().min(80)])); sessions2.insert(s.clone()); sessions2.save_file(&s); }+ Err(e) => pup_core::log(format!("[tabs] \"{}\": could not adopt target {tid}: {}", s.id, e["error"].as_str().unwrap_or("?"))),+ }+ });+ }+ } "Target.targetDestroyed" => { let tid = ev.params["targetId"].as_str().unwrap_or("").to_string(); for s in sessions.all() { if let Some(pos) = s.tabs.iter().position(|t| t.target_id == tid) { let left = sessions.with_mut(&s.id, |x| { let t = x.tabs.remove(pos); states.lock().unwrap().remove(&t.cdp_session); if x.active_tab >= x.tabs.len() && !x.tabs.is_empty() { x.active_tab = x.tabs.len() - 1; } x.tabs.len() }).unwrap_or(0);- if left == 0 { pup_core::log(format!("[health] session \"{}\" dead (its last tab was closed outside pup) — removing", s.id)); sessions.remove(&s.id); sessions.delete_file(&s.id); }- else if let Some(x) = sessions.get(&s.id) { sessions.save_file(&x); }+ // 2.0.453: a destroyed LAST tab no longer kills the session on the spot. After a sleep, a Memory+ // Saver discard or a renderer restart Chrome destroys and recreates a tab's target while the OS+ // window stays on screen (John, 2026-09-10: the window was there, pup had dropped it, the+ // toolbar pen did nothing). Keep the window session; the health tick re-attaches a page+ // target in the same Browser window or removes the session once the OS window is gone.+ if left == 0 { pup_core::log(format!("[health] session \"{}\": its last tab target was destroyed outside pup; keeping the window session while Chrome may replace it", s.id)); sessions.with_mut(&s.id, |x| x.tabless_since_ms = pup_core::now_ms()); }+ if let Some(x) = sessions.get(&s.id) { sessions.save_file(&x); } } } }@@ -67,6 +113,7 @@ pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: Ta } } pup_core::log(format!("[events] pump for profile \"{profile}\" ended (browser connection closed)"));+ crate::recovery::kick_health(); // 2.0.470: recover the windows now, not at the next 15 s tick }).ok(); }
rust/verbs/src/extdrive.rsadded+667@@ -0,0 +1,667 @@+//! pup-bridge#109: EXTENSION-DRIVEN windows — pup drives a Chrome that carries NO CDP debug port.+//!+//! Measured on AdomLapper 2026-09-12, same box and minute:+//! pup over CDP st.com + mouser ERR_HTTP2_PROTOCOL_ERROR, arrow Access Denied+//! extension-driven st.com + mouser + arrow ALL LOAD+//! and the extension-driven browser was the SAME Chrome for Testing binary reporting raw+//! Chromium-148 brands with no "Google Chrome" brand at all — the exact identity that was being+//! walled over CDP. So the discriminator is the DevTools debug port, not the browser identity.+//! (analog.com stays denied either way: it denies real Chrome driven by the real extension on a+//! profile without established trust, so that one is profile trust, not a driver problem.)+//!+//! The channel: pup queues ONE command; the extension's service worker polls `/extcmd`, runs it+//! with chrome.tabs / chrome.scripting, and POSTs the result to `/extresult`.++use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++use crate::Ctx;++/// The command channel, 2.0.570: a QUEUE. It was one slot (one pending command, one result), so two+/// callers at once (the annotate watcher's read every 500 ms and a pup_eval, or the health tick's overlay+/// and a verb) overwrote each other, one of them timed out and the annotate watcher died on its first+/// read. Commands queue by id, results are keyed by id, the worker drains the queue on every poll, and+/// everything the worker had taken is re-queued when a fresh hello says it restarted.+static PENDING: Mutex<std::collections::VecDeque<Value>> = Mutex::new(std::collections::VecDeque::new());+static RESULTS: Mutex<Option<std::collections::HashMap<u64, Value>>> = Mutex::new(None);+/// Taken by the worker, not yet answered: id -> (command, taken_at_ms).+static TAKEN: Mutex<Option<std::collections::HashMap<u64, (Value, u64)>>> = Mutex::new(None);+static SEQ: Mutex<u64> = Mutex::new(0);+/// Last time the extension asked for work — how we know an extension-driven browser is alive.+static LAST_POLL: Mutex<u64> = Mutex::new(0);++/// GET /extcmd — the extension asks for work. Returns `{}` when there is nothing to do.+pub fn take_command_for(lane: &str) -> Value {+ // 2.0.502: only the extension-driven browser (the one carrying this launch's lane token) may take work.+ // Every pup browser loads the same extension, so without this a NORMAL window answered and pup drove+ // another thread's window.+ let want = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ if want.is_empty() || lane != want { return json!({}); }+ take_command()+}++pub fn take_command() -> Value {+ let first = *LAST_POLL.lock().unwrap() == 0;+ *LAST_POLL.lock().unwrap() = pup_core::now_ms();+ // 2.0.495: log the FIRST poll so "the extension never called home" is answerable from the log instead of+ // guessed at (ConfRoomROG: the channel stayed dead and there was no way to tell reach from load).+ if first { pup_core::log("[extdrive] the pup extension reached /extcmd — the control channel is live".to_string()); }+ let cmd = PENDING.lock().unwrap().pop_front().unwrap_or(json!({}));+ if let Some(id) = cmd["id"].as_u64() { TAKEN.lock().unwrap().get_or_insert_with(Default::default).insert(id, (cmd.clone(), pup_core::now_ms())); }+ cmd+}++/// Called on a matched hello: if the previous worker instance took a command and never answered, hand it+/// to this one. Returns how many were re-queued.+fn requeue_taken(reason: &str) -> usize {+ let now = pup_core::now_ms();+ let mut taken = TAKEN.lock().unwrap();+ let Some(m) = taken.as_mut() else { return 0 };+ let mut ids: Vec<u64> = m.iter().filter(|(_, (_, at))| now.saturating_sub(*at) >= 1500).map(|(id, _)| *id).collect();+ ids.sort();+ let mut n = 0;+ let mut pending = PENDING.lock().unwrap();+ let results = RESULTS.lock().unwrap();+ for id in ids.into_iter().rev() { // oldest ends up at the front+ if results.as_ref().map(|r| r.contains_key(&id)).unwrap_or(false) { m.remove(&id); continue; }+ if let Some((cmd, _)) = m.remove(&id) {+ pup_core::log(format!("[extdrive] re-issued command {id} ({}) after {reason}", cmd["action"].as_str().unwrap_or("?")));+ pending.push_front(cmd); n += 1;+ }+ }+ n+}++/// POST /exthello — an extension announces itself, whatever lane it holds. Throttled to one line per+/// distinct (lane, match) per 30s so a 1.2s poll loop cannot flood the log.+/// The worker version the running lane browser reported in its last hello ("2-0-541" from /worker.2-0-541.js).+pub static LANE_WORKER: Mutex<String> = Mutex::new(String::new());+/// 2.0.569: after a bridge_install the running extension browser keeps the OLD worker until it relaunches,+/// which only happens when no thread has an ext window open (measured: a 2.0.565 bridge driving a 2.0.562+/// worker for an hour). On each health tick, if the lane's worker is behind, re-stage the extension folder+/// in place and ask the worker to chrome.runtime.reload(): it comes back on the new files with the same lane+/// token, and the bridge re-queues any command it had in flight.+pub fn reload_stale_worker(ctx: &Ctx) {+ static ASKED: Mutex<Option<String>> = Mutex::new(None);+ if !alive(ctx) { return; }+ let Some((have, want)) = lane_worker_stale() else { return };+ if ASKED.lock().unwrap().as_deref() == Some(want.as_str()) { return; }+ *ASKED.lock().unwrap() = Some(want.clone());+ let lane = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ if lane.is_empty() { return; }+ let shim = crate::lane_shim(ctx);+ let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), shim);+ if staged.is_empty() { pup_core::log("[extdrive] worker reload skipped: re-staging the extension produced nothing".to_string()); return; }+ match run(json!({ "action": "reload" }), Duration::from_secs(8)) {+ Ok(_) => pup_core::log(format!("[extdrive] lane worker {} is behind pup {}; re-staged the extension and asked the worker to reload in place", have.replace('-', "."), want.replace('-', "."))),+ Err(e) => pup_core::log(format!("[extdrive] worker reload request failed: {}", e["error"].as_str().unwrap_or("?"))),+ }+}++pub fn lane_worker_stale() -> Option<(String, String)> {+ let w = LANE_WORKER.lock().unwrap().clone();+ if w.is_empty() { return None; }+ let want = pup_core::version().replace('.', "-");+ if w == want { None } else { Some((w, want)) }+}++pub fn hello(v: Value) {+ static LAST: Mutex<Option<std::collections::HashMap<String, u64>>> = Mutex::new(None);+ let lane = v["lane"].as_str().unwrap_or("").to_string();+ if !lane.is_empty() {+ if let Some(w) = v["worker"].as_str().and_then(|p| p.rsplit('/').next()).and_then(|f| f.strip_prefix("worker.")).and_then(|f| f.strip_suffix(".js")) {+ *LANE_WORKER.lock().unwrap() = w.to_string();+ }+ }+ let want = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ let matched = !lane.is_empty() && lane == want;+ if matched { requeue_taken("the lane worker restarted (fresh hello)"); }+ let key = format!("{lane}|{matched}");+ {+ let mut g = LAST.lock().unwrap();+ let m = g.get_or_insert_with(std::collections::HashMap::new);+ let now = pup_core::now_ms();+ if let Some(t) = m.get(&key) { if now.saturating_sub(*t) < 30_000 { return; } }+ m.insert(key, now);+ }+ if !matched && !lane.is_empty() && *LAST_POLL.lock().unwrap() == 0 {+ // 2.0.513: ADOPT. Nothing has ever been served since this pup launched, and a browser on our own+ // profile is asking for work with a lane token. That is an extension browser we started and then+ // lost track of (a pup restart, or a close whose kill missed the main process and took lane.json+ // with it), and it is unkillable because pup no longer knows its pids. Fighting it just spawns a+ // second Chrome that forwards to it and exits. Adopting costs nothing and cannot hijack a working+ // lane, because a working lane has served at least one poll.+ *crate::EXTDRIVE_LANE.lock().unwrap() = lane.clone();+ pup_core::log(format!("[extdrive] adopted the running extension browser (lane {lane}); pup had lost track of it"));+ return;+ }+ if !matched && lane.is_empty() && want.is_empty() && *LAST_POLL.lock().unwrap() == 0 {+ *TOKENLESS_HELLO_AT.lock().unwrap() = pup_core::now_ms();+ pup_core::log("[extdrive] a pup extension is polling with no lane token and pup holds none: the next health tick adopts it".to_string());+ return;+ }+ if matched {+ pup_core::log(format!("[extdrive] hello from the lane browser (lane {lane}) — this one may take work"));+ } else {+ pup_core::log(format!("[extdrive] hello from a pup extension carrying lane {:?} (pup wants {:?}), port.json read={} pkg={} — NOT served",+ lane, want, v["read"], v["pkg"]));+ }+}++/// POST /extresult — the extension reports what happened.+pub fn put_result(v: Value) {+ let Some(id) = v["id"].as_u64() else { return };+ if let Some(m) = TAKEN.lock().unwrap().as_mut() { m.remove(&id); }+ RESULTS.lock().unwrap().get_or_insert_with(Default::default).insert(id, v);+}++/// A killed lane must read as dead at once. `alive()` is heartbeat-based, and a browser killed 3 s after its+/// last poll still counted as alive for 7 more seconds: the next open took the WARM path and waited 60 s on a+/// dead channel until ab timed out (2026-09-13, the first cold open of the 20-site proof).+pub(crate) fn mark_dead() { *LAST_POLL.lock().unwrap() = 0; }++/// The lane as pup_status reports it. There are no lane verbs: a lane window is a pup window.+pub fn lane_health(ctx: &Ctx) -> Value {+ let ago = connected_ms_ago();+ let stale = lane_worker_stale();+ // 2.0.583: LIVE lane windows on glass, not the session count (a dead lane reported "1 window" for 14 h);+ // and an EMPTY pid list means zero, because to the enumerator it means "no filter" (it counted the user's own Chrome and Edge: 7)+ let live_windows = { let p = crate::extdrive_pids(); if p.is_empty() { 0 } else { ctx.host.windows_for_pids(&p, Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).count() } };+ json!({ "connected": alive(ctx), "lastPollMsAgo": if ago > 86_400_000 { Value::Null } else { json!(ago) },+ "processes": crate::extdrive_pids().len(), "profile": "adom-you", "debugPort": false,+ "workerVersion": LANE_WORKER.lock().unwrap().replace('-', "."), "stale": stale.is_some(),+ "_staleHint": stale.map(|(w, want)| format!("the lane browser still runs extension {} while pup is {}; it cycles by itself the moment no extension-lane window is open, or pup_close_window {{all:true}} from every thread cycles it now", w.replace('-', "."), want.replace('-', "."))),+ "sessions": ctx.sessions.all().iter().filter(|s| crate::transport::is_ext(s)).count(),+ "windows": live_windows })+}++pub fn connected_ms_ago() -> u64 { pup_core::now_ms().saturating_sub(*LAST_POLL.lock().unwrap()) }+pub fn last_poll_ms() -> u64 { *LAST_POLL.lock().unwrap() }+/// 2.0.580: a pup extension polled with NO lane token while pup holds none either (both sides empty after a+/// restart whose lane.json did not survive). Refusing it forever left an orphan browser (2026-09-14 20:57).+/// The health tick adopts it: mint a token, stamp it into port.json (the worker re-reads it every poll),+/// and learn the lane's pids from Chrome for Testing frames only, never the user's own Chrome.+static TOKENLESS_HELLO_AT: Mutex<u64> = Mutex::new(0);+pub fn adopt_tokenless(ctx: &Ctx) {+ let at = *TOKENLESS_HELLO_AT.lock().unwrap();+ if at == 0 || pup_core::now_ms().saturating_sub(at) > 90_000 { return; }+ if !crate::EXTDRIVE_LANE.lock().unwrap().is_empty() || last_poll_ms() != 0 { return; }+ let lane = format!("ext-{}", pup_core::now_ms());+ let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), crate::lane_shim(ctx));+ if staged.is_empty() { return; }+ let pids: Vec<u32> = { let mut v: Vec<u32> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.title.ends_with("Google Chrome for Testing")).map(|w| w.pid).collect(); v.sort(); v.dedup(); v };+ *crate::EXTDRIVE_LANE.lock().unwrap() = lane.clone();+ crate::set_extdrive_pids(pids.clone());+ crate::persist_lane(ctx);+ *TOKENLESS_HELLO_AT.lock().unwrap() = 0;+ pup_core::log(format!("[extdrive] adopted a token-less extension browser: minted lane {lane}, stamped it into port.json, {} Chrome for Testing process(es) learned from its windows", pids.len()));+}++/// Queue one command and block until the extension answers it (or the deadline passes).+pub(crate) fn run(cmd: Value, wait: Duration) -> Result<Value, Value> {+ IN_FLIGHT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+ let r = run_inner(cmd, wait);+ IN_FLIGHT.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);+ r+}+fn run_inner(cmd: Value, wait: Duration) -> Result<Value, Value> {+ let id = { let mut s = SEQ.lock().unwrap(); *s += 1; *s };+ let mut c = cmd;+ c["id"] = json!(id);+ PENDING.lock().unwrap().push_back(c);+ let t0 = Instant::now();+ while t0.elapsed() < wait {+ let hit = RESULTS.lock().unwrap().as_mut().and_then(|m| m.remove(&id));+ if let Some(r) = hit {+ if r["ok"] == json!(true) { return Ok(r); }+ return Err(err("ext_command_failed", r["error"].as_str().unwrap_or("the extension could not run it").to_string(), None));+ }+ std::thread::sleep(Duration::from_millis(60));+ }+ PENDING.lock().unwrap().retain(|c| c["id"].as_u64() != Some(id));+ if let Some(m) = TAKEN.lock().unwrap().as_mut() { m.remove(&id); }+ Err(pup_core::merge(+ err("ext_no_answer", format!("the pup extension did not answer within {}s", wait.as_secs()),+ Some("Is an extension-driven window open? pup_open_window starts one. The extension only answers while its browser is running; a normal CDP window does not serve this channel.")),+ json!({ "lastPollMsAgo": connected_ms_ago() }),+ ))+}++/// Liveness is the extension's own heartbeat, NOT the spawned pid: Chrome's launcher process exits+/// immediately and hands off to a new pid, so the pid we spawned is dead within a second while the+/// browser runs fine (that is why pup's normal launch reads the pid back over CDP instead).+/// 2.0.532: a command in flight counts as alive. The worker does not poll while it executes a command (a+/// slow site's tab open holds it for up to 30 s), so the heartbeat stopped, alive() read the lane as dead+/// and the next three tab opens were refused with "browser not running" while the browser was fine+/// (the 20-site proof, 2026-09-13: nordicsemi held the worker; newark, rs-online and infineon were refused).+static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);+pub(crate) fn alive(_ctx: &Ctx) -> bool { connected_ms_ago() < 20_000 || (IN_FLIGHT.load(std::sync::atomic::Ordering::Relaxed) > 0) }++/// The window/tab pup is driving, so navigate/eval do not wander to another window.+static CUR: Mutex<(i64, i64)> = Mutex::new((0, 0)); // (windowId, tabId)+fn pin(r: &Value) {+ let w = r["windowId"].as_i64().unwrap_or(0);+ let t = r["tabId"].as_i64().unwrap_or(0);+ if w > 0 || t > 0 { *CUR.lock().unwrap() = (w, t); }+}+fn aim(c: &mut Value) {+ let (w, t) = *CUR.lock().unwrap();+ if t > 0 { c["tabId"] = json!(t); }+ if w > 0 { c["windowId"] = json!(w); }+}++/// pup_open_window {url} — launch (or reuse) the extension-driven browser and open `url` in it.+pub fn open(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ let r = with_lane_progress(ctx, || open_inner(ctx, _caller, args));+ if r["ok"] != json!(false) { dress_after(ctx, _caller); }+ r+}+fn open_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ let url = args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank").to_string();+ if !alive(ctx) {+ if let Err(e) = crate::launch_extdrive(ctx, &url) { return e; }+ // 2.0.493: return WELL INSIDE the caller's request window. Waiting 45 s here for the extension to call+ // home blew past ab's ~60 s relay on a slower box, so every pup_open_window on ConfRoomROG came back+ // "Request timed out" while the browser was in fact starting fine. Wait briefly; if the channel is not+ // up yet, answer `starting` (NOT an error) and let the caller poll pup_status, the same contract+ // pup_restart_browser uses.+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(20) {+ if connected_ms_ago() < 4000 { break; }+ std::thread::sleep(Duration::from_millis(300));+ }+ if connected_ms_ago() > 8000 {+ dress_window(ctx, _caller);+ return pup_core::merge(ok(json!({ "starting": true, "opened": false, "url": url })), json!({+ "statusVerb": "pup_status", "_hint": "The extension-driven browser is starting; its extension has not called home yet. Poll pup_status until extensionConnected is true (usually a few seconds, longer on a cold Chrome for Testing), then pup_navigate / pup_eval. This is NOT a failure.",+ }));+ }+ dress_window(ctx, _caller);+ fit_now();+ return match run(json!({ "action": "tabs" }), Duration::from_secs(20)) {+ Ok(r) => {+ if let Some(t) = r["tabs"].as_array().and_then(|a| a.iter().find(|t| t["url"].as_str().map(|u| u.starts_with("http")).unwrap_or(false))) { pin(t); }+ pup_core::merge(ok(json!({ "opened": true, "url": url, "tabs": r["tabs"] })),+ json!({ "_hint": "Extension-driven window: NO CDP debug port, so vendor edges that wall automation (st.com, mouser, arrow) load here. Drive it with pup_navigate / pup_eval. CDP-only features (focus emulation, UA override, precise trusted input, annotate) do NOT apply to this window." }))+ }+ Err(e) => e,+ };+ }+ dress_window(ctx, _caller);+ fit_now();+ match run(json!({ "action": "open", "url": url }), Duration::from_secs(60)) {+ Ok(r) => pup_core::merge({ pin(&r); ok(json!({ "opened": true, "url": url, "tabId": r["tabId"], "windowId": r["windowId"] })) },+ json!({ "_hint": "Opened in the existing extension-driven browser. pup_eval reads it; pup_navigate moves it." })),+ Err(e) => e,+ }+}++/// pup_navigate {url, tabId?}+pub fn navigate(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ let r = with_lane_progress(ctx, || navigate_inner(ctx, _caller, args));+ if r["ok"] != json!(false) { dress_after(ctx, _caller); }+ r+}+fn navigate_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ dress_window(ctx, _caller);+ if !alive(ctx) { return not_running(); }+ let mut c = json!({ "action": "navigate", "url": args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank") });+ aim(&mut c);+ if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }+ match run(c, Duration::from_secs(60)) {+ Ok(r) => { pin(&r); ok(json!({ "url": r["url"], "title": r["title"], "tabId": r["tabId"], "_hint": "Navigated through the extension (no CDP). Read it with pup_eval." })) }+ Err(e) => e,+ }+}++/// pup_eval {expr, tabId?} — runs in the page's MAIN world; non-strings come back JSON-encoded.+pub fn eval(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ if !alive(ctx) { return not_running(); }+ let mut c = json!({ "action": "eval", "expr": args.get("expr").and_then(|e| e.as_str()).unwrap_or("") });+ aim(&mut c);+ if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }+ match run(c, Duration::from_secs(45)) {+ Ok(r) => { pin(&r); ok(json!({ "result": r["result"], "tabId": r["tabId"], "url": r["url"] })) }+ Err(e) => e,+ }+}++/// pup_list_tabs {}+pub fn tabs(ctx: &Ctx, _caller: &Caller, _args: &Value) -> Value {+ if !alive(ctx) { return not_running(); }+ match run(json!({ "action": "tabs" }), Duration::from_secs(25)) { Ok(r) => ok(json!({ "tabs": r["tabs"] })), Err(e) => e }+}++/// pup_close_window {tabId?} — a tab, or the whole extension-driven browser when nothing is named.+pub fn close(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ if !alive(ctx) { return ok(json!({ "closed": false, "_hint": "No extension-driven browser was running." })); }+ if let Some(t) = args.get("tabId") {+ return match run(json!({ "action": "close", "tabId": t }), Duration::from_secs(20)) { Ok(_) => ok(json!({ "closed": true })), Err(e) => e };+ }+ let via_ext = run(json!({ "action": "closeAll" }), Duration::from_secs(20)).is_ok();+ *CUR.lock().unwrap() = (0, 0);+ let n = crate::kill_extdrive(ctx);+ ok(json!({ "closed": true, "viaExtension": via_ext, "processesEnded": n, "_hint": "The extension-driven browser is closed. pup_open_window starts a fresh one." }))+}++/// pup_status {}+pub fn status(ctx: &Ctx, _caller: &Caller, _args: &Value) -> Value {+ let a = alive(ctx);+ let ago = connected_ms_ago();+ ok(json!({+ "browserRunning": a,+ "extensionConnected": a && ago < 8000,+ "lastPollMsAgo": if *LAST_POLL.lock().unwrap() == 0 { Value::Null } else { json!(ago) },+ "profile": "adom-you",+ "_hint": "Extension-driven mode carries NO CDP debug port. Measured 2026-09-12: st.com, mouser and arrow load here and fail over CDP on the same box. analog.com stays denied (that one is profile trust, and denies the real extension on a cold profile too).",+ "_next": ["pup_close_window"],+ }))+}++fn not_running() -> Value {+ err("ext_not_running", "no extension-driven browser is open", Some("pup_open_window {url} starts one (Chrome for Testing, pup-owned profile, no debug port)."))+}++/// pup_status {} — read the pup extension's SERVICE WORKER directly over CDP in the normal (CDP) browser:+/// is the worker even running, what port did it read from port.json, and what does its own findPort() return.+/// This answers "the extension never called home" from inside the extension instead of by guessing.+pub fn diag(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you");+ let Some(b) = crate::browser_for(ctx, profile) else { return err("no_cdp_browser", format!("no CDP browser on profile \"{profile}\""), Some("Open a normal pup window first (pup_open_window); the diagnostic attaches to the extension worker inside that browser.")) };+ let targets = b.cdp.targets().unwrap_or_default();+ let ext_id = pup_browser::EXT_IDS.iter().find(|(n, _)| *n == "pup-extension").map(|(_, id)| *id).unwrap_or("");+ let workers: Vec<Value> = targets.iter().filter(|t| t["type"] == json!("service_worker") && t["url"].as_str().map(|u| u.contains(ext_id)).unwrap_or(false)).cloned().collect();+ let all_ext: Vec<Value> = targets.iter().filter(|t| t["url"].as_str().map(|u| u.starts_with("chrome-extension://")).unwrap_or(false)).map(|t| json!({ "type": t["type"], "url": t["url"] })).collect();+ if workers.is_empty() {+ return pup_core::merge(ok(json!({ "workerRunning": false, "extensionTargets": all_ext })), json!({ "_hint": "The pup extension's service worker is NOT running in this browser: nothing to poll pup with. Either the extension did not load (--load-extension path, a manifest error) or the worker was never started." }));+ }+ let tid = workers[0]["targetId"].as_str().unwrap_or("").to_string();+ let sid = match b.cdp.attach(&tid) { Ok(s) => s, Err(e) => return err("attach_failed", e.to_string(), None) };+ let expr = "(async () => { const o = { hasPackagedPort: typeof packagedPort, hasFindPort: typeof findPort, hasPoll: typeof pupExtPoll }; try { o.pkg = await packagedPort(); } catch (e) { o.pkgErr = String(e); } try { const f = await findPort(); o.found = f ? f.port : null; } catch (e) { o.findErr = String(e); } try { const r = await fetch(chrome.runtime.getURL('port.json')); o.portJsonStatus = r.status; o.portJsonText = (await r.text()).slice(0, 120); } catch (e) { o.portJsonErr = String(e); } try { o.storagePort = (await chrome.storage.local.get('pupPort')).pupPort; } catch (e) {} return JSON.stringify(o); })()";+ let r = b.cdp.send(Some(&sid), "Runtime.evaluate", json!({ "expression": expr, "awaitPromise": true, "returnByValue": true }), Duration::from_secs(20));+ let _ = b.cdp.send(None, "Target.detachFromTarget", json!({ "sessionId": sid }), Duration::from_secs(5));+ match r {+ Ok(v) => {+ let raw = v["result"]["value"].as_str().unwrap_or("").to_string();+ let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({ "raw": raw, "exception": v["exceptionDetails"] }));+ ok(json!({ "workerRunning": true, "workerUrl": workers[0]["url"], "insideWorker": parsed, "pupPort": ctx.port, "_hint": "insideWorker is the extension's OWN view: pkg = the port it read from port.json, found = the port its findPort() settled on (null = it cannot reach pup), portJsonStatus/Err = whether it can even read its packaged port.json." }))+ }+ Err(e) => err("worker_eval_failed", e.to_string(), None),+ }+}+/// pup_open_tab {url} — open a TAB in the extension-driven window (the lane could only make new windows).+pub fn tab(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ let r = with_lane_progress(ctx, || tab_inner(ctx, _caller, args));+ if r["ok"] != json!(false) { dress_after(ctx, _caller); }+ r+}+fn tab_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ dress_window(ctx, _caller);+ let url = args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank").to_string();+ if !alive(ctx) { return open(ctx, _caller, args); }+ let mut c = json!({ "action": "newtab", "url": url });+ aim(&mut c);+ match run(c, Duration::from_secs(70)) {+ Ok(r) => { let w = r["windowId"].as_i64().unwrap_or(0); if w > 0 { CUR.lock().unwrap().0 = w; }+ ok(json!({ "opened": true, "url": r["url"], "title": r["title"], "tabId": r["tabId"], "windowId": r["windowId"],+ "_hint": "Tab opened in the extension-driven window (no CDP debug port). pup_eval {tabId} reads it." })) }+ Err(e) => e,+ }+}++/// pup_screenshot {tabId?} — PNG of a tab via chrome.tabs.captureVisibleTab (no CDP).+pub fn screenshot(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ if !alive(ctx) { return not_running(); }+ let mut c = json!({ "action": "screenshot" });+ aim(&mut c);+ if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }+ match run(c, Duration::from_secs(60)) {+ Ok(r) => {+ let data = r["dataUrl"].as_str().unwrap_or("");+ let b64 = data.split(',').nth(1).unwrap_or("");+ match pup_cdp::base64_decode(b64) {+ Ok(bytes) => {+ let path = ctx.shots_dir().join(format!("ext-{}.png", pup_core::now_ms()));+ let _ = std::fs::write(&path, &bytes);+ ok(json!({ "filePath": path.to_string_lossy().replace('\\', "/"), "sizeKB": bytes.len() / 1024,+ "tabId": r["tabId"], "_hint": "PNG of the extension-driven tab (captureVisibleTab: the VISIBLE area of the active tab, not a full-page CDP capture). READ the file before asserting what it shows." }))+ }+ Err(e) => err("screenshot_decode_failed", e.to_string(), None),+ }+ }+ Err(e) => e,+ }+}++/// pup_switch_tab {tabId} — bring a tab to the front of the extension-driven window.+pub fn activate(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {+ dress_window(ctx, _caller);+ if !alive(ctx) { return not_running(); }+ let mut c = json!({ "action": "activate" });+ aim(&mut c);+ if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }+ match run(c, Duration::from_secs(30)) { Ok(r) => ok(json!({ "tabId": r["tabId"] })), Err(e) => e }+}+/// 2.0.503: give the extension-driven window the SAME taskbar identity and placement a normal pup window+/// gets. Both live in the CDP birth path, so shipping this lane without them regressed the user experience:+/// John saw a raw "Chrome for Testing" taskbar icon and a window about 60% of his screen width. Neither+/// needs CDP — pup stamps identity through ab on a plain HWND, and ab can place the window.+/// Give every extension-driven window the same look a CDP pup window has: MAXIMIZED, wearing the+/// owning thread's taskbar tile. Idempotent (a window is dressed once), so it is cheap to call from+/// every verb rather than only from the cold-launch path.+///+/// 2.0.504, after John: "why is your window sizing so screwed up ... your pup window is like 60% the+/// width of my screen?" and "why is your windows taskbar icon still wrong?". Three real bugs, all here:+/// 1. `launch_extdrive` never passed `--start-maximized` (the CDP lane's `chrome_switches` does), so+/// Chrome opened at whatever size its profile remembered: measured 1575px of a 2582px work area.+/// 2. this ran ONLY on the cold-launch path of `open`, and that path returns early with+/// `starting:true`, so on a warm browser it never ran at all: pup.log had ZERO stamp lines.+/// 3. the placement call named its args `width`/`height` while ab takes `w`/`h`, and the result was+/// swallowed with `let _ =`, so the rejection was invisible. Maximizing needs no bounds math and+/// no DPI correction (this exe is DPI-unaware, so SPI_GETWORKAREA lies about the work area).+/// Put the lane's window at the work area in the NORMAL state. Run on every open: dress_window fits a+/// window only the first time it dresses it, so a window that later ends up minimized (John has now hit+/// this three times: pup said the lane was up and there was nothing on his screen) never got re-asserted.+/// chrome.windows.update with state normal also restores a minimized window, which ab geometry must not+/// do here because it would raise the window.++/// Late rebind. An ext session whose birth outran open_window_ext's hwnd wait keeps hwnd None for+/// ever, so identity::stamp has nothing to stamp and the button wears Chrome's STOCK icon (John,+/// 2026-09-14: kicad's aiflow-overlay window, "did you regress on the windows taskbar icons?"). The+/// 2.0.545 birth (about:blank, identity shim, then the real URL) can take longer than that wait on a+/// cold browser. Runs every health tick and on pup_rescan: bind every unbound ext session to the lane+/// window and stamp it, exactly as a timely birth would have.+pub fn rebind_unbound(ctx: &Ctx) -> usize {+ // Three ways an ext window ends up wearing Chrome's stock icon, all seen 2026-09-14 in pup.log:+ // born bound to a TRANSIENT hwnd (the about:blank launch window Chrome replaces: "HWND 13510448 is not+ // a valid window"), re-adopted by recovery after a bridge restart with the right hwnd but NO stamp,+ // or never bound at all. One test covers them: unbound, bound to a dead hwnd, or bound and unstamped.+ let unbound: Vec<String> = ctx.sessions.all().into_iter()+ .filter(|s| crate::transport::is_ext(s) && (s.hwnd.is_none() || s.app_id.is_none() || !s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)))+ .map(|s| s.id).collect();+ if unbound.is_empty() { return 0; }+ let caller = crate::identity::internal_caller();+ let mut n = 0;+ for sid in unbound {+ let Some(sess) = ctx.sessions.get(&sid) else { continue };+ let Some(h) = lane_hwnd_for(ctx, &sess) else { continue };+ // Bind the OS handle AND Chrome's own windowId: windowId is exact and survives title changes, so a+ // later pass can verify this binding instead of re-deriving it from a title.+ let wid = window_id_for_hwnd(ctx, &sid);+ ctx.sessions.with_mut(&sid, |x| { x.hwnd = Some(h); if x.window_id.is_none() { x.window_id = wid; } });+ if let Some(s) = ctx.sessions.get(&sid) { ctx.sessions.save_file(&s); }+ crate::identity::stamp(ctx, &sid, &caller, true);+ crate::activity::on_open(ctx, &sid, h, &caller);+ pup_core::log(format!("[ext] bound window {h} to session \"{sid}\" and stamped its identity (late rebind)"));+ n += 1;+ }+ n+}++/// Close windows no session owns whose every tab is about:blank: the startup window a lane relaunch or a+/// concurrent open leaves behind (John, 2026-09-14: a stray "about:blank - Google Chrome for Testing" on+/// his desktop). The CDP lane does the same in close_orphan_blank_targets. `keep` is a window just+/// created for a session that has not been recorded yet.+pub(crate) fn close_orphan_blank_windows(ctx: &Ctx, keep: Option<i64>) -> usize {+ if !alive(ctx) { return 0; }+ let Ok(r) = run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return 0 };+ let owned: std::collections::HashSet<i64> = ctx.sessions.all().iter().filter_map(|s| s.window_id).collect();+ let mut wins: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();+ for t in r["tabs"].as_array().cloned().unwrap_or_default() {+ if let Some(w) = t["windowId"].as_i64() { wins.entry(w).or_default().push(t["url"].as_str().unwrap_or("").to_string()); }+ }+ let mut n = 0;+ for (w, urls) in wins {+ if Some(w) == keep || owned.contains(&w) || urls.is_empty() { continue; }+ if urls.iter().all(|u| u.is_empty() || u == "about:blank") {+ if run(json!({ "action": "close", "windowId": w }), Duration::from_secs(10)).is_ok() { n += 1; pup_core::log(format!("[lane] closed orphan about:blank window {w} that no session owns")); }+ }+ }+ n+}++/// The OS window that belongs to ONE ext session. The lane is one browser, but several threads' windows+/// live in it, so "the lane window" is ambiguous (2026-09-14: the rebind bound kicad's session to another+/// thread's window and then to a dead handle). Chrome titles a window "<active tab title> - Google Chrome+/// for Testing", so match on the session's active tab title; with a single lane window there is nothing+/// to disambiguate and that window is it.+/// Chrome's own windowId for the window holding this session's active tab, read from the extension.+fn window_id_for_hwnd(ctx: &Ctx, sid: &str) -> Option<i64> {+ let s = ctx.sessions.get(sid)?;+ let tab = s.active()?;+ let want = tab.target_id.parse::<i64>().ok()?;+ let r = run(json!({ "action": "tabs" }), Duration::from_secs(10)).ok()?;+ r["tabs"].as_array()?.iter().find(|t| t["tabId"].as_i64() == Some(want)).and_then(|t| t["windowId"].as_i64())+}++pub(crate) fn lane_hwnd_for(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> {+ let pids = crate::extdrive_pids();+ if pids.is_empty() { return None; }+ // A handle another session already owns is NEVER a candidate. Handing one handle to two sessions is+ // what put all-engineering, NSF and pup-bridge on hwnd 590484, each stamping its tile over the last+ // (John, 2026-09-14). This check is why readopt_ext never produced the bug and this path did.+ let taken = crate::taken_hwnds(ctx, &s.id);+ let wins: Vec<pup_host::HostWindow> = ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter()+ .filter(|w| w.visible && w.rect.2 - w.rect.0 > 200 && !taken.contains(&w.hwnd)).collect();+ // The sole-window shortcut is pup-window-targeting rule 4 ("sole window OF A PROFILE"), and it is only+ // sound when this really is the only ext window in play. The ext lane is one profile shared by MANY+ // threads, so applying it per-session handed every asking session the same window. With owned handles+ // already excluded, one remaining candidate and exactly one claimant is the honest version of that rule.+ let ext_sessions = ctx.sessions.all().iter().filter(|x| crate::transport::is_ext(x)).count();+ if wins.len() == 1 && ext_sessions <= 1 { return wins.first().map(|w| w.hwnd); }+ let title = s.active().map(|t| t.title.trim().to_string()).unwrap_or_default();+ if !title.is_empty() {+ let m: Vec<&pup_host::HostWindow> = wins.iter().filter(|w| w.title.trim_start().starts_with(&title)).collect();+ // exactly one title match, or it is not a match: two windows on the same page are indistinguishable+ // by title and picking either is a coin flip that clobbers the loser's taskbar button.+ if m.len() == 1 { return Some(m[0].hwnd); }+ if m.len() > 1 { pup_core::log(format!("[ext] \"{}\": {} lane windows share the title \"{}\" - refusing to guess which is this thread's; leaving it BARE (bare > wrong)", s.id, m.len(), title)); return None; }+ }+ // no unambiguous match: never guess another thread's window; leave it unbound for the next pass+ None+}++/// The lane window's hwnd (there is one lane window; several tabs live in it).+pub(crate) fn lane_hwnd(ctx: &Ctx) -> Option<i64> {+ let pids = crate::extdrive_pids();+ if pids.is_empty() { return None; }+ ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter()+ .filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).map(|w| w.hwnd).next()+}++/// The taskbar contract the CDP lane keeps and this lane skipped (John, 2026-09-13: "why are you+/// causing flashing of the windows taskbar icon ... you're supposed to default to showing progress+/// bars in the icon. and why do i not see the overlay icon"). Same ab calls activity.rs makes:+/// an indeterminate bar while a command runs, cleared when it ends; taskbarActivity off/flash and+/// minimalTouch are honoured exactly as there.+fn with_lane_progress<T>(ctx: &Ctx, work: impl FnOnce() -> T) -> T {+ // ONE progress implementation, in activity.rs. This is just the lane's entry point: find the lane+ // window and hand it the same filling estimated bar the CDP lane gets. My first cut hand-rolled a+ // flat 35% bar and threw ab's reply away; deleted.+ match lane_hwnd(ctx) {+ Some(hwnd) => crate::activity::progress_on_hwnd(ctx, hwnd, "ext-drive", work),+ None => work(),+ }+}++/// After an open / navigate / tab change on this lane: clear the attention tint and paint the overlay+/// for every session on the lane window, through the ONE composer (identity::overlay, in the binary+/// since 2.0.568). The in-page JS composer this used to carry is gone.+fn dress_after(ctx: &Ctx, caller: &Caller) {+ let Some(hwnd) = lane_hwnd(ctx) else { return };+ let _ = crate::activity::clear_attention(ctx, hwnd, caller);+ if ctx.settings.get("overlayBadges", "on") == "off" { return; }+ for s in ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.hwnd.is_some()) { crate::identity::overlay(ctx, &s.id, caller); }+}+pub fn fit_now() {+ match run(json!({ "action": "fit" }), Duration::from_secs(10)) {+ Ok(r) => pup_core::log(format!("[extdrive] fit {} window(s) to the work area {}", r["fitted"], r["workArea"])),+ Err(e) => pup_core::log(format!("[extdrive] fit failed: {e}")),+ }+}++/// Once per lane browser: send real Chrome's brand list on every request (consumerBrands, default on).+pub fn apply_consumer_brands(ctx: &Ctx) {+ // 6 s, not 10: on a cold start this runs before the first page and every second here is on the open's clock.+ static DONE_LANE: Mutex<String> = Mutex::new(String::new());+ let lane = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ if lane.is_empty() || *DONE_LANE.lock().unwrap() == lane { return; }+ let on = ctx.settings.get("consumerBrands", "on") != "off";+ let mut cmd = json!({ "action": "setBrands", "on": on });+ if on { match crate::consumer_identity_strings(ctx) { Some(v) => { for (k, val) in v.as_object().unwrap() { cmd[k] = val.clone(); } } None => { pup_core::log("[lane] consumer identity: no installed Chrome version known; leaving CfT's own".to_string()); return; } } }+ match run(cmd, Duration::from_secs(6)) {+ Ok(r) => { *DONE_LANE.lock().unwrap() = lane; pup_core::log(format!("[lane] consumer identity {}: ua={} brands={} err={}", if on { "ON" } else { "off" }, r["ua"].as_str().unwrap_or("-"), r["brands"].as_str().unwrap_or("-"), r["error"].as_str().unwrap_or("none"))); }+ Err(e) => pup_core::log(format!("[lane] consumer brands not applied: {e}")),+ }+}++pub fn dress_window(ctx: &Ctx, caller: &Caller) {+ apply_consumer_brands(ctx);+ static DRESSED: Mutex<Option<std::collections::HashSet<i64>>> = Mutex::new(None);+ let pids = crate::extdrive_pids();+ if pids.is_empty() { return; }+ let wins = ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1"));+ let name = if caller.thread.is_empty() { "pup".to_string() } else { caller.thread.clone() };+ let mut fresh = 0;+ for w in wins.iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200) {+ {+ let mut g = DRESSED.lock().unwrap();+ let set = g.get_or_insert_with(std::collections::HashSet::new);+ if !set.insert(w.hwnd) { continue; }+ }+ fresh += 1;+ crate::identity::stamp_hwnd(ctx, caller, "pup-extdrive", &name, w.hwnd);+ }+ // Geometry goes through the EXTENSION (chrome.windows.update, plain bounds, logical px both+ // sides), never through ab: pup-bridge-dev's rule is "CDP bounds only, NEVER windowState+ // maximized, NEVER ab desktop_set_window_bounds - both RAISE the window", and this lane's+ // stand-in for CDP is its own extension channel.+ if fresh > 0 {+ match run(json!({ "action": "fit" }), Duration::from_secs(10)) {+ Ok(r) => pup_core::log(format!("[extdrive] fit {} window(s) to the work area {}", r["fitted"], r["workArea"])),+ Err(e) => pup_core::log(format!("[extdrive] fit failed: {e}")),+ }+ }+}++/// 2.0.578: a browser handle for the extension lane whose Cdp forwards every command to the extension's+/// DevTools driver (nbe's cdp.js, via the worker's `cdp` action). Verbs written against BrowserHandle+/// (click, hover, type, press_key, scroll, fill, element/full-res screenshots) run unchanged on this lane.+static EXT_HANDLE: std::sync::Mutex<Option<std::sync::Arc<crate::BrowserHandle>>> = std::sync::Mutex::new(None);+pub fn ext_handle() -> std::sync::Arc<crate::BrowserHandle> {+ let mut g = EXT_HANDLE.lock().unwrap();+ if let Some(h) = g.as_ref() { return h.clone(); }+ let via: pup_cdp::Via = Box::new(|session: Option<&str>, method: &str, params: Value, timeout: Duration| {+ let tab = session.and_then(|s| s.strip_prefix("ext:")).and_then(|x| x.parse::<i64>().ok());+ let mut cmd = json!({ "action": "cdp", "method": method, "params": params, "timeoutMs": timeout.as_millis() as u64 });+ if let Some(t) = tab { cmd["tabId"] = json!(t); }+ match run(cmd, timeout + Duration::from_secs(3)) {+ Ok(r) => Ok(r.get("result").cloned().unwrap_or(Value::Null)),+ Err(e) => Err(pup_cdp::CdpError::Protocol { method: method.into(), code: 0, message: e["error"].as_str().unwrap_or("the extension lane did not answer").to_string() }),+ }+ });+ let h = std::sync::Arc::new(crate::BrowserHandle { cdp: pup_cdp::Cdp::via(via), launched: pup_browser::Launched { pid: 0, cdp_port: 0, candidate: pup_browser::Candidate { kind: "extension-lane".into(), source: "nbe driver".into(), executable_path: std::path::PathBuf::new(), build_id: None }, profile_dir: std::path::PathBuf::new(), reused: true } });+ *g = Some(h.clone()); h+}
rust/verbs/src/extras.rs+134−6@@ -12,6 +12,15 @@ use std::time::Duration; const T: Duration = Duration::from_secs(10); +// pup-bridge#109: downloads in flight, surfaced in pup_readiness.active[] so the poll hint is TRUE. A+// synchronous pup_download used to block up to 90 s, longer than the caller's request window, so the caller+// saw a relay timeout while nothing was pollable. Now a slow download is registered here and finished by a+// background watcher; the caller polls pup_readiness until it clears.+static ACTIVE_DOWNLOADS: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());+pub fn active_download_rows() -> Vec<Value> { ACTIVE_DOWNLOADS.lock().unwrap().clone() }+fn dl_put(row: Value) { let id = row["id"].clone(); let mut m = ACTIVE_DOWNLOADS.lock().unwrap(); m.retain(|r| r["id"] != id); m.push(row); }+fn dl_done(id: &str) { ACTIVE_DOWNLOADS.lock().unwrap().retain(|r| r["id"] != json!(id)); }+ pub fn screenshot_element(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; let (sel, txt, closest) = (args["selector"].as_str(), args["text"].as_str(), args["closest"].as_str());@@ -50,6 +59,20 @@ pub fn screenshot_full_res(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { /// pup_download: click a real download control or an in-page a[download]; Chrome saves it. pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ if let Ok(s0) = crate::resolve(ctx, caller, args) {+ if crate::transport::is_ext(&s0) {+ let Some(url) = args["url"].as_str().filter(|u| !u.is_empty()) else { return err("url_required", "pup_download on the extension lane needs {url}", Some("Selector-driven downloads need CDP: reopen with lane:\"cdp\".")) };+ let lane = match crate::transport::lane_for(ctx, &s0) { Ok(l) => l, Err(e) => return e };+ let wait = args["waitMs"].as_u64().unwrap_or(30000);+ return match crate::transport::download(&lane, url, args["saveAs"].as_str(), wait) {+ Ok(r) => { touch(ctx, &s0.id, None); ok(json!({ "success": true, "sessionId": s0.id, "window": s0.id, "aiThread": s0.owner, "output": json!({ "ok": true, "savedTo": r["savedTo"], "filename": r["filename"], "bytes": r["bytes"], "url": url, "transport": "ext" }) })) }+ Err(e) => e,+ };+ }+ }+ if crate::resolve(ctx, caller, args).map(|s| crate::transport::is_ext(&s)).unwrap_or(false) {+ return err("lane_needs_cdp", "this verb waits on DevTools events, which the extension lane does not stream", Some("Reopen with pup_open_window {lane:\"cdp\"} for recording or selector-driven downloads; everything else works here."));+ } let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; let (sel, url) = (args["selector"].as_str(), args["url"].as_str()); if sel.is_none() && url.is_none() { return err("args_required", "pup_download needs {selector} (a real download control to click) OR {url} (downloaded via an in-page a[download] click)", None); }@@ -61,14 +84,52 @@ pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let triggered = if let Some(sel) = sel { crate::input::click(ctx, caller, &json!({ "window": s.id, "selector": sel, "takeover": true }))["ok"] == json!(true) } else { b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const a = document.createElement('a'); a.href = {}; {} a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000); return true; }})()", serde_json::to_string(url.unwrap()).unwrap(), args["saveAs"].as_str().map(|n| format!("a.download = {};", serde_json::to_string(n).unwrap())).unwrap_or_else(|| "a.setAttribute('download', '');".into())), T).ok() == Some(json!(true)) }; if !triggered { return err("download_trigger_failed", "the download control could not be clicked", Some("Check the selector with pup_eval; the button may need a hover menu open first.")); }- let max = Duration::from_millis(args["waitMs"].as_u64().unwrap_or(90_000).min(240_000));+ // pup-bridge#109: fail FAST if Chrome never starts a download (wrong control, blocked, not a real file),+ // and cap the SYNCHRONOUS wait under the caller's request window (a longer download hands off to a+ // background watcher and is polled via pup_readiness.active[], instead of the caller seeing a relay timeout).+ let sync_cap = Duration::from_millis(args["waitMs"].as_u64().unwrap_or(45_000).min(45_000));+ let begin_deadline = Duration::from_secs(12); let t0 = std::time::Instant::now();- let mut suggested: Option<String> = None; let mut done: Option<Value> = None;- while t0.elapsed() < max {- if let Ok(ev) = began.try_recv() { suggested = ev.params["suggestedFilename"].as_str().map(|x| x.to_string()); }- if let Ok(ev) = progress.recv_timeout(Duration::from_millis(500)) { if ev.params["state"] == json!("completed") { done = Some(ev.params.clone()); break; } if ev.params["state"] == json!("canceled") { return err("download_canceled", "the browser canceled the download", None); } }+ let mut suggested: Option<String> = None; let mut done: Option<Value> = None; let mut began_seen = false; let mut guid = String::new(); let mut last_pct: Option<u64> = None;+ loop {+ if let Ok(ev) = began.try_recv() { began_seen = true; suggested = ev.params["suggestedFilename"].as_str().map(|x| x.to_string()); guid = ev.params["guid"].as_str().unwrap_or("").to_string(); }+ if let Ok(ev) = progress.recv_timeout(Duration::from_millis(400)) {+ began_seen = true;+ let (rec, tot) = (ev.params["receivedBytes"].as_f64().unwrap_or(0.0), ev.params["totalBytes"].as_f64().unwrap_or(0.0));+ last_pct = if tot > 0.0 { Some(((rec / tot) * 100.0).round() as u64) } else { None };+ if guid.is_empty() { guid = ev.params["guid"].as_str().unwrap_or("").to_string(); }+ if ev.params["state"] == json!("completed") { done = Some(ev.params.clone()); break; }+ if ev.params["state"] == json!("canceled") { dl_done(&guid); return err("download_canceled", "the browser canceled the download", None); }+ }+ if !began_seen && t0.elapsed() > begin_deadline {+ return err("download_did_not_start", format!("the control fired but Chrome began no download within {}s", begin_deadline.as_secs()), Some("The selector may be the wrong element, the file may be behind a bot wall (vendor sites often block pup's Chrome for Testing), or the URL is not a real file. Try nbrowser_fetch_url to pull it through the user's real Chrome, or check the control with pup_eval."));+ }+ if t0.elapsed() > sync_cap { break; }+ }+ if done.is_none() && began_seen {+ // Hand a slow download off to a background watcher; register it so pup_readiness.active[] is truthful.+ let id = if guid.is_empty() { format!("dl-{}", pup_core::now_ms()) } else { guid.clone() };+ let fname = suggested.clone().unwrap_or_else(|| "download".into());+ dl_put(json!({ "id": id, "verb": "pup_download", "phase": "download", "stepLabel": format!("Downloading {fname}"), "percent": last_pct, "sessionId": s.id, "startedMs": pup_core::now_ms(), "state": "running" }));+ let (bg, dir2, id2, sess2) = (b.clone(), dir.clone(), id.clone(), s.id.clone());+ std::thread::Builder::new().name("pup-download-watch".into()).spawn(move || {+ let prog = bg.cdp.subscribe(None, Some("Browser.downloadProgress"));+ let start = std::time::Instant::now();+ while start.elapsed() < Duration::from_secs(600) {+ if let Ok(ev) = prog.recv_timeout(Duration::from_millis(1000)) {+ if ev.params["guid"].as_str().map(|g| g == id2).unwrap_or(true) {+ if ev.params["state"] == json!("completed") { pup_core::log(format!("[download] \"{sess2}\" finished {id2} -> {}", dir2)); break; }+ if ev.params["state"] == json!("canceled") { pup_core::log(format!("[download] \"{sess2}\" {id2} canceled")); break; }+ }+ }+ }+ dl_done(&id2);+ }).ok();+ return pup_core::merge(ok(json!({ "success": true, "downloading": true, "guid": id, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "saveDir": dir.replace('\\', "/") })),+ json!({ "statusVerb": "pup_readiness", "_hint": format!("The download started and is still running after {}s; it is registered in pup_readiness.active[]. Poll pup_readiness until it clears, then the file is in {} (pull it with adom-bridge pull_file if you need the bytes).", sync_cap.as_secs(), dir.replace('\\', "/")) })); }- let Some(ev) = done else { return err("download_timeout", format!("no download completed within {}s", max.as_secs()), Some("Nothing may have started (wrong control), or the file is large: pass waitMs for a longer wait, then pup_download again.")) };+ let Some(ev) = done else { return err("download_did_not_start", format!("the control fired but Chrome began no download within {}s", begin_deadline.as_secs()), Some("The selector may be the wrong element, the file may be behind a bot wall, or the URL is not a real file. Try nbrowser_fetch_url to pull it through the user's real Chrome.")) };+ if !guid.is_empty() { dl_done(&guid); } let fname = suggested.clone().or_else(|| args["saveAs"].as_str().map(|x| x.to_string())).unwrap_or_else(|| format!("download-{}", ev["guid"].as_str().unwrap_or("file"))); let mut abs = std::path::PathBuf::from(&dir).join(&fname); if !abs.is_file() { if let Ok(rd) = std::fs::read_dir(&dir) { if let Some(newest) = rd.flatten().filter(|e| e.path().is_file()).filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|m| (m, e.path()))).filter(|(m, _)| m.elapsed().map(|d| d < Duration::from_secs(120)).unwrap_or(false)).max_by_key(|(m, _)| *m) { abs = newest.1; } } }@@ -207,3 +268,70 @@ pub fn move_to_workspace(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { ok(json!({ "success": true, "output": json!({ "ok": true, "movedUrl": url, "windowClosed": closed, "sessionId": s.id, "_hint": if closed { "The page was handed to Hydrogen (its Browser Picker routes it — webview by default) and this pup window closed. The user picks the surface if the picker prompts." } else { "The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true)." } }).to_string() })) }++/// pup_restart_browser {profile}: the remedy for a Chrome whose BROWSER process has wedged (its main thread+/// spinning, so the wheel and the tab strip stop responding while a scrollbar still drags). Every window's+/// tabs are written to its session file first, the browser process is ended, and recovery brings the windows+/// and ALL their tabs back. Added 2.0.461 after John lost an afternoon to exactly this, with me killing the+/// process by hand and retyping 20 tab URLs out of a log.+pub fn restart_browser(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you").to_string();+ let sessions = ctx.sessions.for_profile(&profile);+ if sessions.is_empty() && crate::browser_for(ctx, &profile).is_none() {+ return err("no_browser", format!("no browser is running for profile \"{profile}\""), Some("pup_status lists the live profiles."));+ }+ // 2.0.472: let any in-flight relaunch finish first. Two restarts back to back (or the wedge watchdog firing+ // while a restore runs) otherwise killed a window whose tabs the previous restore was still reopening, and the+ // freshly relaunched window then adopted the stale tabs (triple-restart drill: "back with 6 of 3", 0 live).+ crate::recovery::wait_for_relaunches_to_drain(Duration::from_secs(60));+ let sessions = ctx.sessions.for_profile(&profile);+ let mut saved = Vec::new();+ for s in &sessions { ctx.sessions.save_file(s); saved.push(json!({ "window": s.id, "tabs": s.tabs.len(), "owner": s.owner })); }+ let pid = crate::browser_for(ctx, &profile).map(|b| b.launched.pid).unwrap_or(0);+ let tabs_total: usize = sessions.iter().map(|s| s.tabs.len()).sum();+ pup_core::log(format!("[restart] pup_restart_browser: profile \"{profile}\" pid {pid}, {} window(s) and {tabs_total} tab(s) saved; ending the browser so recovery can bring them back (asked by {})", sessions.len(), caller.thread));+ let killed = if pid > 0 { ctx.host.kill(pid) } else { false };+ ctx.browsers.lock().unwrap().remove(&profile);+ // 2.0.464: WAIT for the profile to be free before returning. Chrome refuses to start a second instance on+ // a locked user-data-dir and exits during launch, so an immediate next open failed with+ // "Target.createTarget: Failed to open" or fell through to system Chrome (measured while surveying+ // component pages, 2026-09-11: four opens in a row broken after a restart).+ let dir = pup_browser::profiles_dir(&*ctx.host).join(&profile);+ let port_file = dir.join("DevToolsActivePort");+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(12) {+ let dead = pid == 0 || !ctx.host.alive(pid);+ if dead && !port_file.exists() { break; }+ if dead && port_file.exists() { let _ = std::fs::remove_file(&port_file); }+ std::thread::sleep(Duration::from_millis(250));+ }+ let waited = t0.elapsed().as_millis() as u64;+ let _ = std::fs::remove_file(&port_file);+ pup_core::log(format!("[restart] profile \"{profile}\" released after {waited} ms (pid alive: {})", pid > 0 && ctx.host.alive(pid)));+ // 2.0.465: bring the windows back HERE, synchronously. Removing the browser handle above also removed the+ // only thing the health loop watches, so the sessions sat in memory pointing at a dead browser and nothing+ // relaunched them: pup_list_windows still listed them with their tabs, and every verb on them answered+ // "the browser behind this window is not connected" (arav-rog drill, 2026-09-11). Each session is dropped+ // from memory (its file stays, with every tab) and relaunched from that file before the verb returns.+ // 2.0.466: the relaunch runs on a helper thread and the verb returns at once. Done inline (2.0.465) it took+ // longer than ab's request window with two windows of three tabs, so the caller saw a timeout while the work+ // completed (arav-rog drill). Each session is dropped from memory (its file stays, with every tab) and+ // relaunched from that file; pup_list_windows shows the windows as they come back.+ let files: Vec<(String, usize, Value)> = sessions.iter().map(|s| (s.id.clone(), s.tabs.len(), s.file_json())).collect();+ // 2.0.468: reserve every id BEFORE dropping it from memory, so the health tick never re-attaches or relaunches+ // one of these files while the restore thread is still working on an earlier window.+ crate::recovery::reserve(&files.iter().map(|(id, _, _)| id.clone()).collect::<Vec<_>>());+ for (id, _, _) in &files { ctx.sessions.remove(id); }+ let restoring: Vec<Value> = files.iter().map(|(id, n, _)| json!({ "window": id, "tabs": n })).collect();+ if let Some(a) = ctx.arc() {+ std::thread::Builder::new().name("restart-restore".into()).spawn(move || {+ for (id, n, f) in files {+ crate::recovery::relaunch_from_file(&a, &f);+ let back = a.sessions.get(&id).map(|x| x.tabs.len()).unwrap_or(0);+ pup_core::log(format!("[restart] \"{id}\" back with {back} of {n} tab(s)"));+ }+ }).ok();+ }+ ok(json!({ "profile": profile, "pid": pid, "killed": killed, "windowsSaved": saved, "tabsSaved": tabs_total, "profileReleasedMs": waited, "restoring": restoring,+ "_hint": if killed { format!("Browser ended. {} window(s) with {tabs_total} tab(s) are being relaunched from their session files right now (about 8 s per window); poll pup_list_windows until the tab counts match `restoring`, then verbs on them work.", restoring.len()) } else { "Could not end the browser process; end it from Task Manager and pup will recover the windows and their tabs the same way.".to_string() } }))+}
rust/verbs/src/identity.rs+129−39@@ -1,13 +1,14 @@ //! Taskbar identity, applied through ab's verbs (Node: stampPupIdentity + registerPupAumid +-//! updateWikiJumplist + applyAppOverlay, ~2,000 lines, now one file). Art (thread tiles, favicon-//! badges) is rendered by the browser pup drives; nothing here touches the registry or Win32.+//! updateWikiJumplist + applyAppOverlay, ~2,000 lines, now one file). The thread tile is rendered in+//! the binary (pup_identity::tile, 2.0.559); favicon badges come from the page. Nothing here touches+//! the registry or Win32 directly: every stamp goes through ab. //! //! Flow after a window opens (async, off the verb): resolve the mode (tiles | aumid | plain), //! render the tile if needed, register the AUMID (per thread in tiles mode), stamp the live //! window with it (+ the Relaunch trio so the jump-list header is branded), attach the jump //! list, then paint the favicon overlay. Navigation refreshes the overlay only. -use crate::{browser_for, Ctx};+use crate::Ctx; use pup_core::Caller; use pup_identity as id; use serde_json::{json, Value};@@ -58,20 +59,15 @@ pub fn mode(ctx: &Ctx) -> String { } /// Render the thread tile through the browser (canvas + the embedded font) and pack it as ICO.-fn ensure_tile(ctx: &Ctx, sid: &str, name: &str) -> Option<PathBuf> {+fn ensure_tile(ctx: &Ctx, _sid: &str, name: &str) -> Option<PathBuf> {+ // Rendered in the binary (pup_identity::tile) since 2.0.559: no page, no lane, no CSP. Cached per+ // thread name on disk, so a name is drawn once per tile generation. let dir = tiles_dir(ctx); let _ = std::fs::create_dir_all(&dir); let path = dir.join(format!("{}.ico", id::thread_slug(name))); if std::fs::metadata(&path).map(|m| m.len() > 100).unwrap_or(false) { return Some(path); }- let s = ctx.sessions.get(sid)?; let b = browser_for(ctx, &s.profile)?; let t = s.active()?.clone();- // The page renders the tile ONCE at 8x (TILE_RENDER); every ICO entry comes out of the brand raster- // pipeline in pup_identity (Lanczos3, ImageMagick unsharp 0.5x0.5+1.5+0.02, alpha clamp), no image library.- let v = b.cdp.evaluate(&t.cdp_session, &id::tile_js(name), Duration::from_secs(30)).ok()?;- let m: Value = serde_json::from_str(v.as_str()?).ok()?;- let rgba = pup_cdp::base64_decode(m["rgba"].as_str()?).ok()?;- if rgba.len() != (id::TILE_RENDER * id::TILE_RENDER * 4) as usize { pup_core::log(format!("[thread-tile] \"{name}\" render returned {} bytes, expected {}", rgba.len(), id::TILE_RENDER * id::TILE_RENDER * 4)); return None; }- std::fs::write(&path, id::ico_from_render(&rgba, id::TILE_RENDER)).ok()?;- pup_core::log(format!("[thread-tile] \"{name}\" lines {} at {}px, brand pipeline", m["lines"], m["size"]));- pup_core::log(format!("[thread-tile] rendered tile for \"{name}\" -> {}", path.display()));+ let Some(ico) = id::tile::render_ico(name) else { pup_core::log(format!("[thread-tile] \"{name}\": the font did not load, falling back to the per-window icon")); return None };+ std::fs::write(&path, ico).ok()?;+ pup_core::log(format!("[thread-tile] rendered tile for \"{name}\" in the binary -> {}", path.display())); Some(path) } @@ -100,6 +96,49 @@ fn exe_launch() -> impl Fn(&str, bool) -> (String, String) { move |tail: &str, quote_for_relaunch: bool| (if quote_for_relaunch { format!("\"{exe}\"") } else { exe.clone() }, format!("--jumplist {tail}")) } +pub fn plan(ctx: &Ctx, sid: &str, name: &str, url: &str) -> Option<(String, String, PathBuf)> {+ let m = mode(ctx);+ if m == "plain" { return None; }+ let cat = id::classify_url(url);+ Some(if m == "tiles" {+ match ensure_tile(ctx, sid, name) {+ Some(tile) => (id::thread_aumid(name, &id::tile_token(&tile)), name.to_string(), tile),+ None => (id::session_aumid(sid), name.to_string(), icons_dir(ctx).join(id::category_icon(cat, false))),+ }+ } else {+ let grouped = ctx.settings.get("taskbarGrouping", "split") == "grouped";+ let style_cat = ctx.settings.get("aumidIconStyle", "category") == "category";+ let icon = icons_dir(ctx).join(if style_cat { id::category_icon(cat, false) } else { "adom-pup.ico" });+ if grouped { (id::category_aumid(cat.unwrap_or("default")), format!("Adom Pup {}", cat.unwrap_or("window")), icon) } else { (id::session_aumid(sid), format!("{} (pup)", name), icon) }+ })+}++/// Register the AUMID (HKCU DisplayName + IconUri) through ab, once per pup process per id. A birth+/// needs it in place BEFORE the shell creates the button: the button's picture resolves through it.+pub fn preregister(ctx: &Ctx, caller: &Caller, app_id: &str, display: &str, icon: &std::path::Path) -> bool {+ static DONE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::OnceLock::new();+ let done = DONE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));+ if done.lock().unwrap().contains(app_id) { return true; }+ match ctx.ab.call("desktop_register_app_identity", register_args(ctx, app_id, display, &icon.to_string_lossy()), caller, AB_T) {+ Ok(_) => { done.lock().unwrap().insert(app_id.to_string()); true }+ Err(e) => { pup_core::log(format!("[identity] preregister {app_id} failed: {e}")); false }+ }+}++/// The relaunch command line for a session's jump-list header (the exe + --jumplist <sid>).+pub fn relaunch_for(sid: &str) -> String { let launch = exe_launch(); id::relaunch_command(&launch, sid) }++/// Stamp a bare hwnd that has no session row yet (the extension lane's window at birth).+pub fn stamp_hwnd(ctx: &Ctx, caller: &Caller, sid_like: &str, name: &str, hwnd: i64) {+ let Some((app_id, display, icon)) = plan(ctx, sid_like, name, "") else { return };+ if !preregister(ctx, caller, &app_id, &display, &icon) { return; }+ let launch = exe_launch();+ match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launch, sid_like), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), caller, AB_T) {+ Ok(_) => pup_core::log(format!("[identity] hwnd {hwnd} stamped {app_id} for \"{name}\"")),+ Err(e) => pup_core::log(format!("[identity] hwnd {hwnd} stamp failed: {e}")),+ }+}+ /// Stamp one window's identity (idempotent; safe to call again after a navigation). pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: bool) { stamp_opts(ctx, sid, caller, full, true) } pub fn stamp_opts(ctx: &Ctx, sid: &str, caller: &Caller, full: bool, with_overlay: bool) {@@ -109,21 +148,10 @@ pub fn stamp_opts(ctx: &Ctx, sid: &str, caller: &Caller, full: bool, with_overla if m == "plain" { return; } let url = s.active().map(|t| t.url.clone()).unwrap_or_default(); let name = s.owner.clone().unwrap_or_else(|| s.id.clone());- let cat = id::classify_url(&url);- let (app_id, display, icon): (String, String, PathBuf) = if m == "tiles" {- match ensure_tile(ctx, sid, &name) {- Some(tile) => (id::thread_aumid(&name, &id::tile_token(&tile)), name.clone(), tile),- None => (id::session_aumid(&s.id), name.clone(), icons_dir(ctx).join(id::category_icon(cat, false))),- }- } else {- let grouped = ctx.settings.get("taskbarGrouping", "split") == "grouped";- let style_cat = ctx.settings.get("aumidIconStyle", "category") == "category";- let icon = icons_dir(ctx).join(if style_cat { id::category_icon(cat, false) } else { "adom-pup.ico" });- if grouped { (id::category_aumid(cat.unwrap_or("default")), format!("Adom Pup {}", cat.unwrap_or("window")), icon) } else { (id::session_aumid(&s.id), format!("{} (pup)", name), icon) }- };+ let Some((app_id, display, icon)) = plan(ctx, sid, &name, &url) else { return }; if full || s.app_id.as_deref() != Some(app_id.as_str()) { if let Err(e) = ctx.ab.call("desktop_register_app_identity", register_args(ctx, &app_id, &display, &icon.to_string_lossy()), caller, AB_T) { pup_core::log(format!("[identity] \"{sid}\" register {app_id} failed: {e}")); return; }- let launch = exe_launch();+ let launch = exe_launch(); match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launch, sid), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), caller, AB_T) { Ok(_) => { pup_core::log(format!("[identity] \"{sid}\" stamped {app_id} ({m})")); ctx.sessions.with_mut(sid, |x| x.app_id = Some(app_id.clone())); crate::activity::mark_icon_applied(ctx, sid); if let Some(x) = ctx.sessions.get(sid) { ctx.sessions.save_file(&x); } } Err(e) => { pup_core::log(format!("[identity] \"{sid}\" stamp failed: {e}")); return; }@@ -140,11 +168,11 @@ pub fn stamp_opts(ctx: &Ctx, sid: &str, caller: &Caller, full: bool, with_overla static ORIGIN_BADGES: Mutex<Option<HashMap<String, String>>> = Mutex::new(None); // origin -> last REAL badge b64 (sibling reuse) static NEG_CACHE: Mutex<Option<HashMap<String, u64>>> = Mutex::new(None); // origin -> ms when both rails failed (30 min) -fn origin_of(url: &str) -> Option<String> { let u = url.trim(); if !(u.starts_with("http://") || u.starts_with("https://")) { return None; } let rest = &u[u.find("://")? + 3..]; let host = rest.split(['/', '?', '#']).next()?; Some(format!("{}://{}", &u[..u.find("://")?], host)) }+pub fn origin_of(url: &str) -> Option<String> { let u = url.trim(); if !(u.starts_with("http://") || u.starts_with("https://")) { return None; } let rest = &u[u.find("://")? + 3..]; let host = rest.split(['/', '?', '#']).next()?; Some(format!("{}://{}", &u[..u.find("://")?], host)) } /// Node's second rail: fetch <origin>/favicon.ico from the bridge with a browser UA; returns a data URL /// only when the bytes ARE an image (a bot-wall's HTML challenge answers 200 too).-fn fetch_favicon_oob(origin: &str) -> Option<String> {+pub fn fetch_favicon_oob(origin: &str) -> Option<String> { let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(6)).redirects(4).build(); let resp = agent.get(&format!("{origin}/favicon.ico")).set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36").set("Accept", "image/*,*/*;q=0.8").call().ok()?; let mut buf = Vec::new(); resp.into_reader().take(512 * 1024).read_to_end(&mut buf).ok()?;@@ -152,9 +180,40 @@ fn fetch_favicon_oob(origin: &str) -> Option<String> { Some(format!("data:{mime};base64,{}", id::base64_encode(&buf))) } -fn run_badge(cdp: &pup_cdp::Cdp, cdp_session: &str, pct: u32, fallback: Option<&str>) -> Option<Value> {- let v = cdp.evaluate(cdp_session, &id::badge_js(pct, fallback), Duration::from_secs(15)).ok()?;- serde_json::from_str::<Value>(v.as_str()?).ok()+/// The active tab's favicon bytes: (bytes, href, via, format). Extension lane: the worker reads+/// <link rel=icon> by injected function and fetches with the page's cookies. CDP lane: Runtime.evaluate+/// reads the hrefs (it ignores page CSP) and the bridge fetches. data: hrefs are decoded in place.+fn source_favicon(ctx: &Ctx, lane: &crate::transport::Lane, t: &pup_session::Tab) -> Option<(Vec<u8>, String, String, String)> {+ let _ = ctx;+ let hrefs: Vec<String> = match lane {+ crate::transport::Lane::Ext => {+ let r = crate::extdrive::run(json!({ "action": "favicon", "tabId": crate::transport::ext_id(t) }), Duration::from_secs(20)).ok()?;+ if let Some(f) = r.get("favicon").filter(|f| f.is_object()) {+ if let (Some(b64), Some(href)) = (f["b64"].as_str(), f["href"].as_str()) {+ if let Ok(bytes) = pup_cdp::base64_decode(b64) { if id::badge::looks_like_image(&bytes) { return Some((bytes, href.to_string(), "page <link rel=icon>, fetched by the extension".to_string(), f["type"].as_str().unwrap_or("image").to_string())); } }+ }+ }+ r["hrefs"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default()+ }+ crate::transport::Lane::Cdp(_) => {+ let v = crate::transport::evaluate(lane, t, "JSON.stringify([...document.querySelectorAll('link[rel~=\"icon\"], link[rel=\"shortcut icon\"], link[rel=\"apple-touch-icon\"]')].map(l => l.href).filter(h => h && !h.startsWith('blob:')))").ok()?;+ let txt = match v { Value::String(x) => x, other => other.to_string() };+ serde_json::from_str::<Vec<String>>(&txt).unwrap_or_default()+ }+ };+ for h in hrefs {+ if let Some(rest) = h.strip_prefix("data:") {+ let fmt = rest.split(';').next().unwrap_or("image").to_string();+ if let Some(b64) = rest.split("base64,").nth(1) { if let Ok(bytes) = pup_cdp::base64_decode(b64) { if id::badge::looks_like_image(&bytes) { return Some((bytes, h.chars().take(60).collect(), "page <link rel=icon> (data URL)".to_string(), fmt)); } } }+ continue;+ }+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(6)).redirects(4).build();+ if let Ok(resp) = agent.get(&h).set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36").set("Accept", "image/*,*/*;q=0.8").call() {+ let fmt = resp.header("content-type").unwrap_or("image").split(';').next().unwrap_or("image").to_string();+ let mut buf = Vec::new(); if resp.into_reader().take(512 * 1024).read_to_end(&mut buf).is_ok() && !buf.is_empty() && id::badge::looks_like_image(&buf) { return Some((buf, h, "page <link rel=icon>, fetched by the bridge".to_string(), fmt)); }+ }+ }+ None } /// The overlay badge (Node applyAppOverlay): the active tab's favicon on its contrast card, painted on@@ -169,20 +228,34 @@ pub fn overlay(ctx: &Ctx, sid: &str, caller: &Caller) { if s.last_paint_sig.as_deref() != Some("cleared") { let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": { "badge": "none" } }), caller, AB_T); ctx.sessions.with_mut(sid, |x| { x.last_paint_sig = Some("cleared".into()); x.overlay_led = Some("amber".into()); x.overlay_desc = Some("off (overlayBadges)".into()); }); } return; }- let Some(b) = browser_for(ctx, &s.profile) else { return };+ let Ok(lane) = crate::transport::lane_for(ctx, &s) else { return }; let pct: u32 = ctx.settings.get("overlaySize", "75").parse().unwrap_or(75); let origin = origin_of(&t.url);- let mut r = run_badge(&b.cdp, &t.cdp_session, pct, None).unwrap_or(Value::Null);- let is_real = |r: &Value| r["kind"].as_str() == Some("page-favicon") && r["dataUrl"].is_string();- if !is_real(&r) {+ // 2.0.568: the icon is SOURCED per lane and COMPOSED in the binary (pup_identity::badge). No page script.+ let is_pdf = t.url.to_lowercase().split(['?', '#']).next().map(|p| p.ends_with(".pdf")).unwrap_or(false);+ let mut src = source_favicon(ctx, &lane, &t);+ if src.is_none() { if let Some(o) = &origin { let neg = { let g = NEG_CACHE.lock().unwrap(); g.as_ref().and_then(|m| m.get(o).copied()).map(|ts| pup_core::now_ms().saturating_sub(ts) < 30 * 60_000).unwrap_or(false) }; if !neg {- if let Some(d) = fetch_favicon_oob(o) { if let Some(r2) = run_badge(&b.cdp, &t.cdp_session, pct, Some(&d)) { if is_real(&r2) { r = r2; } } }- if !is_real(&r) { NEG_CACHE.lock().unwrap().get_or_insert_with(HashMap::new).insert(o.clone(), pup_core::now_ms()); }+ src = fetch_favicon_oob(o).and_then(|d| d.split("base64,").nth(1).and_then(|b| pup_cdp::base64_decode(b).ok()).map(|bytes| (bytes, format!("{o}/favicon.ico"), "bridge fetch of /favicon.ico (no <link rel=icon> in the page)".to_string(), d.split(';').next().unwrap_or("image").trim_start_matches("data:").to_string())));+ if src.is_none() { NEG_CACHE.lock().unwrap().get_or_insert_with(HashMap::new).insert(o.clone(), pup_core::now_ms()); } } } }+ let composed = src.and_then(|(bytes, href, via, fmt)| id::badge::decode(&bytes).map(|im| (im, href, via, fmt)));+ let mut r: Value = match composed {+ Some((im, href, via, fmt)) => {+ let an = id::badge::analyse(&im);+ let png = id::badge::compose(&im, pct, an.dark_dominant && an.transparent);+ json!({ "dataUrl": format!("data:image/png;base64,{}", id::base64_encode(&png)), "kind": "page-favicon", "format": fmt, "transparent": an.transparent, "darkDominant": an.dark_dominant, "luminance": an.luminance, "href": href, "via": via })+ }+ None => {+ let png = id::badge::compose(&id::badge::generic(is_pdf), pct, false);+ json!({ "dataUrl": format!("data:image/png;base64,{}", id::base64_encode(&png)), "kind": if is_pdf { "generic-pdf" } else { "generic-web" }, "format": "png", "transparent": false, "darkDominant": false, "luminance": Value::Null, "href": if is_pdf { "generic:pdf" } else { "generic:page" }, "via": "generated (no favicon existed anywhere in this window)" })+ }+ };+ let is_real = |r: &Value| r["kind"].as_str() == Some("page-favicon") && r["dataUrl"].is_string(); let mut via = r["via"].as_str().unwrap_or("").to_string(); if !is_real(&r) { if let Some(o) = &origin {@@ -247,5 +320,22 @@ pub fn set_identity(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn audit(ctx: &Ctx) -> Value { let rows: Vec<Value> = ctx.sessions.all().iter().map(|s| json!({ "sessionId": s.id, "hwnd": s.hwnd, "appId": s.app_id, "owner": s.owner, "url": s.active().map(|t| t.url.clone()) })).collect();- pup_core::ok(json!({ "mode": mode(ctx), "windows": rows, "ok": true, "findings": [], "_hint": "What pup believes each window is stamped with. The read-back audit (ab#147) lands when ab returns appId on window rows." }))+ // Read back from WINDOWS, not from memory: app_id does not survive a bridge restart, and a stamp+ // attempted on a dead hwnd leaves the field empty while the real window is fine (or vice versa).+ let caller = internal_caller();+ let windows_appid = |hwnd: i64| -> Option<String> {+ let r = ctx.ab.call("desktop_get_window_identity", json!({ "hwnd": hwnd }), &caller, AB_T).ok()?;+ let o = r.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).unwrap_or(r);+ o["data"]["windows"][0]["appId"].as_str().map(|x| x.to_string())+ };+ let findings: Vec<Value> = ctx.sessions.all().iter().filter_map(|s| {+ let Some(h) = s.hwnd else { return Some(json!({ "sessionId": s.id, "problem": "no OS window bound: identity cannot be stamped, so the taskbar button wears Chrome's stock icon", "fix": "the extension-lane late rebind runs every health tick; pup_rescan forces it now" })) };+ if !ctx.host.is_alive(h) { return Some(json!({ "sessionId": s.id, "hwnd": h, "problem": "bound to an hwnd that is no longer a window (a transient launch window, most likely)", "fix": "pup_rescan rebinds it to the live lane window and re-stamps" })); }+ match windows_appid(h) {+ Some(a) if a.starts_with("Adom.Pup") => None,+ Some(a) => Some(json!({ "sessionId": s.id, "hwnd": h, "windowsAppId": a, "problem": "Windows carries Chrome's native AUMID on this window, not pup's: the stamp never landed", "fix": "pup_rescan re-stamps it" })),+ None => Some(json!({ "sessionId": s.id, "hwnd": h, "problem": "Windows reports no AUMID on this window", "fix": "pup_rescan re-stamps it" })),+ }+ }).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." } })) }
rust/verbs/src/input.rs+86−8@@ -18,8 +18,17 @@ fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\" /// Session + the tab addressed by args.tabId (default: active) + its live browser. pub fn resolve_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<(Session, Tab, Arc<crate::BrowserHandle>), Value> { let s = resolve(ctx, caller, args)?;- let t = args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()).ok_or_else(|| err("no_tab", "the window has no tabs", None))?;- let b = browser_for(ctx, &s.profile).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; recovery reconnects live Chromes at boot.")))?;+ // 2.0.535: every CDP-only verb comes through here. On an extension-lane window there is no CDP handle,+ // and before this guard such verbs silently did nothing (ab-smoke on the default lane: login and download+ // "succeeded" and changed nothing). Refuse loudly, and say what does work here.+ // 2.0.578: the extension lane answers these verbs too, through nbe's DevTools driver (chrome.debugger,+ // attached on demand, detached after idle). Recording and downloads still refuse below: they wait on+ // DevTools EVENTS, which the extension channel does not stream.+ let ext = crate::transport::is_ext(&s);+ let by_url = args.get("_tabUrl").and_then(|u| u.as_str()).and_then(|u| crate::session_for_tab_url(ctx, u)).filter(|(w, _)| w.id == s.id).map(|(_, t)| t);+ let t = args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| by_url.or_else(|| s.active().cloned())).ok_or_else(|| err("no_tab", "the window has no tabs", None))?;+ let b = if ext { crate::extdrive::ext_handle() } else { browser_for(ctx, &s.profile).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; recovery reconnects live Chromes at boot.")))? };+ crate::focus_emulate(ctx, &s.id, Some(&t.tab_id)); // 2.0.458: this tab is the one pup is driving Ok((s, t, b)) } @@ -56,6 +65,41 @@ fn mouse(cdp: &Cdp, sid: &str, kind: &str, x: f64, y: f64, button: &str, click_c cdp.send(Some(sid), "Input.dispatchMouseEvent", json!({ "type": kind, "x": x, "y": y, "button": button, "clickCount": click_count, "modifiers": modifiers }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)) } +/// A press whose release never landed leaves Chrome believing the button is STILL DOWN. Chrome then routes+/// every later mouse event to that widget as one ongoing drag: the user's touchpad scrolling and their clicks+/// on the tab strip stop working in that window, while dragging the scrollbar keeps working (because a drag is+/// exactly what Chrome thinks is happening). John hit this on 2026-09-10 after a release timed out mid-click:+/// "i still can't scroll with my touchpad on ANY of your pup windows... i also can't change tabs".+/// So: a click ALWAYS releases (retried once), and any press left outstanding by an earlier verb is released+/// before the next mouse event on that tab. Keyed by CDP session, cleared on a clean release.+fn press_down(ctx: &Ctx, sid: &str, x: f64, y: f64, button: &str) { ctx.pending_press.lock().unwrap().insert(sid.to_string(), (x, y, button.to_string())); }+fn press_up(ctx: &Ctx, sid: &str) { ctx.pending_press.lock().unwrap().remove(sid); }++/// Release a button an earlier verb left down on this tab, before doing anything else with the mouse.+pub fn clear_stuck_press(ctx: &Ctx, cdp: &Cdp, sid: &str) {+ let held = ctx.pending_press.lock().unwrap().get(sid).cloned();+ let Some((x, y, button)) = held else { return };+ if mouse(cdp, sid, "mouseReleased", x, y, &button, 1, 0).is_ok() {+ press_up(ctx, sid);+ pup_core::log(format!("[input] released a {button} button an earlier click left down (Chrome was treating every mouse event as one drag)"));+ }+}++/// press + release as one unit: the release runs even when it has to be retried, and the press is recorded+/// until a release lands, so the next verb on this tab can finish the job.+fn click_once(ctx: &Ctx, cdp: &Cdp, sid: &str, x: f64, y: f64, button: &str, clicks: u32, bits: u32) -> Result<(), Value> {+ clear_stuck_press(ctx, cdp, sid);+ mouse(cdp, sid, "mousePressed", x, y, button, clicks, bits)?;+ press_down(ctx, sid, x, y, button);+ let first = mouse(cdp, sid, "mouseReleased", x, y, button, clicks, bits);+ if first.is_ok() { press_up(ctx, sid); return Ok(()); }+ pup_core::log(format!("[input] mouseReleased failed after a press; retrying so the button is not left down: {}", first.as_ref().err().and_then(|e| e["error"].as_str()).unwrap_or("?")));+ match mouse(cdp, sid, "mouseReleased", x, y, button, clicks, bits) {+ Ok(()) => { press_up(ctx, sid); Ok(()) }+ Err(e) => { pup_core::log("[input] the retry failed too: the press stays recorded and the next mouse verb on this tab releases it".to_string()); Err(e) }+ }+}+ fn modifier_bits(mods: &[String]) -> Result<u32, Value> { let mut bits = 0u32; for m in mods {@@ -105,8 +149,7 @@ pub fn click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mods = args.get("modifiers").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect::<Vec<_>>()).unwrap_or_default(); let bits = match modifier_bits(&mods) { Ok(b) => b, Err(e) => return e }; if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, bits) { return e; }- if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mousePressed", x, y, button, clicks, bits) { return e; }- if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseReleased", x, y, button, clicks, bits) { return e; }+ if let Err(e) = click_once(ctx, &b.cdp, &t.cdp_session, x, y, button, clicks, bits) { return e; } touch(ctx, &s.id, None); out(&s, &t, json!({ "clicked": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "method": how, "x": x, "y": y, "_hint": "Click dispatched (isTrusted) at the element's center. Screenshot to confirm the effect; SPA routing may need pup_wait." })) }@@ -133,11 +176,16 @@ pub fn type_text(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { if let Err(e) = press(&b.cdp, &t.cdp_session, "a", 2) { return e; } if let Err(e) = press(&b.cdp, &t.cdp_session, "Delete", 0) { return e; } }- for ch in text.chars() {+ // 2.0.579: on the extension lane every DevTools call is a poll-channel round trip, and per-key typing is+ // two of them per character (a 20-char password overran the 30 s cap, measured). Insert the whole string+ // in ONE trusted Input.insertText, as nbe itself types. The CDP lane keeps per-key realism.+ if b.cdp.is_via() {+ if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": text }), T) { return err("type_failed", e.to_string(), None); }+ } else { for ch in text.chars() { let cs = ch.to_string(); let r = match keys::lookup(&cs) { Some(d) => { let shift = d.shift_key.is_none() && ch.is_uppercase(); press(&b.cdp, &t.cdp_session, &cs, if shift { 8 } else { 0 }) } None => b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": cs }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)) }; if let Err(e) = r { return e; }- }+ } } let read = b.cdp.evaluate(&t.cdp_session, "(() => { const a = document.activeElement; if (!a) return null; const v = a.value !== undefined ? a.value : (a.isContentEditable ? a.innerText : null); return v === null ? null : v.length; })()", T).ok().and_then(|v| v.as_u64()); let landed = read.map(|n| n >= text.chars().count().min(1) as u64); touch(ctx, &s.id, None);@@ -180,6 +228,33 @@ pub fn scroll(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } } +/// 2.0.592 (pup-bridge#116): choose a <select> option by value or by visible label, in the background, through+/// DevTools evaluate (so page CSP and Trusted Types cannot refuse it); fires input+change for framework selects.+pub fn select(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+ let Some(sel) = args.get("selector").and_then(|x| x.as_str()) else { return err("args_required", "pup_select needs {selector} plus {value} or {label}", Some("e.g. {\"selector\":\"select[name=tz]\",\"label\":\"Central Time\"}")) };+ let (value, label) = (args.get("value").and_then(|x| x.as_str()), args.get("label").and_then(|x| x.as_str()));+ if value.is_none() && label.is_none() { return err("args_required", "pup_select needs {value} or {label}", None); }+ let expr = format!(r#"(() => {{ const el = document.querySelector({sel}); if (!el) return {{ found: false }};+ if (el.tagName !== 'SELECT') return {{ found: true, notSelect: el.tagName }};+ const want = {val}, wantLabel = {lab}; let idx = -1;+ for (let i = 0; i < el.options.length; i++) {{ const o = el.options[i];+ if ((want !== null && o.value === want) || (wantLabel !== null && o.text.trim().toLowerCase() === wantLabel.toLowerCase())) {{ idx = i; break; }} }}+ if (idx < 0 && wantLabel !== null) {{ for (let i = 0; i < el.options.length; i++) {{ if (el.options[i].text.trim().toLowerCase().includes(wantLabel.toLowerCase())) {{ idx = i; break; }} }} }}+ if (idx < 0) return {{ found: true, optionFound: false, options: [...el.options].slice(0, 30).map(o => ({{ value: o.value, label: o.text.trim() }})) }};+ el.focus(); const d = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); if (d && d.set) d.set.call(el, el.options[idx].value); else el.value = el.options[idx].value;+ el.selectedIndex = idx; el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));+ return {{ found: true, optionFound: true, value: el.value, label: el.options[idx].text.trim(), index: idx }}; }})()"#,+ sel = js_str(sel), val = value.map(js_str).unwrap_or("null".into()), lab = label.map(js_str).unwrap_or("null".into()));+ match b.cdp.evaluate(&t.cdp_session, &expr, T) {+ Ok(v) if v["found"] == json!(false) => err("selector_not_found", format!("pup_select: selector \"{sel}\" not found"), None),+ Ok(v) if v["notSelect"].is_string() => err("not_a_select", format!("pup_select: \"{sel}\" is a {} not a <select>", v["notSelect"]), Some("For custom dropdowns (Material, React Select) click the control with pup_click, then pup_click the option by text.")),+ Ok(v) if v["optionFound"] == json!(false) => err("option_not_found", format!("pup_select: no option matched {}", value.or(label).unwrap_or("")), Some(&format!("Options: {}", v["options"]))),+ Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "selected": v["value"], "label": v["label"], "index": v["index"], "selector": sel })) }+ Err(e) => err("eval_failed", e.to_string(), None),+ }+}+ pub fn fill(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; let (Some(sel), Some(text)) = (args.get("selector").and_then(|x| x.as_str()), args.get("text").and_then(|x| x.as_str())) else { return err("args_required", "pup_fill needs {selector, text}", None) };@@ -283,9 +358,12 @@ pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let kind = args.get("type").and_then(|x| x.as_str()).unwrap_or(""); let (x, y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0)); let r = match kind {- "click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| mouse(&b.cdp, &t.cdp_session, "mousePressed", x, y, "left", 1, 0)).and_then(|_| mouse(&b.cdp, &t.cdp_session, "mouseReleased", x, y, "left", 1, 0)),+ "click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| click_once(ctx, &b.cdp, &t.cdp_session, x, y, "left", 1, 0)), "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {- let mut p = json!({ "type": kind, "x": x, "y": y, "button": args.get("button").and_then(|v| v.as_str()).unwrap_or("left"), "clickCount": 1 });+ let btn = args.get("button").and_then(|v| v.as_str()).unwrap_or("left").to_string();+ if kind != "mousePressed" { clear_stuck_press(ctx, &b.cdp, &t.cdp_session); } // never wheel or move through someone else's held button+ if kind == "mousePressed" { press_down(ctx, &t.cdp_session, x, y, &btn); } else if kind == "mouseReleased" { press_up(ctx, &t.cdp_session); }+ let mut p = json!({ "type": kind, "x": x, "y": y, "button": btn, "clickCount": 1 }); if kind == "mouseWheel" { p["deltaX"] = json!(args.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0)); p["deltaY"] = json!(args.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0)); } b.cdp.send(Some(&t.cdp_session), "Input.dispatchMouseEvent", p, T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)) }
rust/verbs/src/lib.rs+816−43@@ -41,7 +41,262 @@ pub mod import; pub fn assets_recorder_html() -> Option<&'static [u8]> { Some(include_bytes!("../../../src/recorder.html")) } pub const OWNER_CHIP_JS: &str = include_str!("../assets/owner-chip.js");+pub mod extdrive;+pub mod transport; pub const RENDER_PROBE_JS: &str = include_str!("../assets/render-probe.js");+pub const CONSUMER_EME_JS: &str = include_str!("../assets/consumer-eme.js");+/// pup-bridge#109: present as consumer Google Chrome, the browser the user actually runs. Chrome for Testing's+/// UA string already looks like Chrome, but its client-hint BRANDS report only [Not/A)Brand, Chromium] with no+/// "Google Chrome" — and Cloudflare/Akamai wall exactly that missing brand (analog.com, arrow.com). This adds+/// the Google Chrome brand to the UA header AND navigator.userAgentData together (a mismatch is itself a bot+/// tell), via Network.setUserAgentOverride. Not spoofing a different device: it is the same Windows/Mac Chrome+/// the user runs, correctly declaring itself. Ported from the Node build (computeConsumerUA), dropped in the+/// Rust port. Cached once per process off the running browser's real version.+static TARGET_CHROME_FULL: Mutex<Option<String>> = Mutex::new(None);+/// Read the installed consumer Chrome's version once (registry App Paths -> exe ProductVersion) so pup can+/// present EXACTLY the browser the user runs (John: "match nb's useragent, i am a human surfing with CfT").+/// Windows only for now; a box with no installed Chrome (CfT only) keeps CfT's own version.+pub fn set_target_chrome_from_host(host: &dyn pup_host::Host) {+ if TARGET_CHROME_FULL.lock().unwrap().is_some() { return; }+ match host.installed_chrome_version() {+ Some(v) => { pup_core::log(format!("[identity] installed consumer Chrome is {v}; pup will present that version")); *TARGET_CHROME_FULL.lock().unwrap() = Some(v); }+ None => { *TARGET_CHROME_FULL.lock().unwrap() = Some(String::new()); } // checked, none — do not re-run+ }+}+static CONSUMER_UA: Mutex<Option<(String, Value)>> = Mutex::new(None);+/// pup-bridge#109: the extension-driven browser (no CDP debug port). Tracked by pid only — there is+/// deliberately no CDP handle for it, so none of the session machinery applies.+/// 2.0.493: the pids that appeared when we launched. Chrome's launcher process exits at once and hands off+/// to a NEW pid, so the pid `spawn_detached` returned is dead within a second while the browser runs — which+/// made pup_close_window report success and kill nothing (John had to watch me close the windows by hand).+/// ONE pup birth at a time. `pup-window-targeting` rule 2 / `pup-bridge-dev` R4: the hwnd is a set-diff+/// taken across window creation, and "the lock guarantees exactly one pup birth at a time". The Rust port+/// dropped it, so two threads opening at once each diffed against their own `before` snapshot, both saw the+/// SAME new frame, and both claimed it: three sessions on one hwnd, each stamping its tile over the last+/// (John, 2026-09-14: "you fucking stick them in nsf's pup windows taskbar icon and vice versa"). Held+/// across [snapshot -> create window -> resolve hwnd] ONLY; navigation happens after it is released.+static BIRTH_LOCK: Mutex<()> = Mutex::new(());+/// Every hwnd another session already owns. A handle is NEVER handed to a second session: that is what+/// collapses distinct threads onto one taskbar button. (readopt_ext already did this; birth and the late+/// rebind did not.)+pub(crate) fn taken_hwnds(ctx: &Ctx, except: &str) -> std::collections::HashSet<i64> {+ ctx.sessions.all().iter().filter(|s| s.id != except).filter_map(|s| s.hwnd).collect()+}+static EXTDRIVE_PIDS: Mutex<Vec<u32>> = Mutex::new(Vec::new());+/// The token only the extension-driven browser carries (see stage_extensions_lane).+pub static EXTDRIVE_LANE: Mutex<String> = Mutex::new(String::new());+pub fn extdrive_pids() -> Vec<u32> { EXTDRIVE_PIDS.lock().unwrap().clone() }+pub(crate) fn set_extdrive_pids(p: Vec<u32>) { *EXTDRIVE_PIDS.lock().unwrap() = p; }+/// Write lane.json (token + pids) so the next pup can re-attach (restore_extdrive).+pub(crate) fn persist_lane(ctx: &Ctx) {+ let p = lane_file(ctx);+ if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }+ let _ = std::fs::write(&p, json!({ "lane": EXTDRIVE_LANE.lock().unwrap().clone(), "pids": EXTDRIVE_PIDS.lock().unwrap().clone() }).to_string());+}+pub fn extdrive_browser_alive(ctx: &Ctx) -> bool { EXTDRIVE_PIDS.lock().unwrap().iter().any(|p| ctx.host.alive(*p)) }+pub fn kill_extdrive(ctx: &Ctx) -> u32 {+ let pids: Vec<u32> = EXTDRIVE_PIDS.lock().unwrap().clone();+ let mut n = 0;+ for p in pids { if ctx.host.alive(p) { ctx.host.kill(p); n += 1; } }+ EXTDRIVE_PIDS.lock().unwrap().clear();+ EXTDRIVE_LANE.lock().unwrap().clear();+ extdrive::mark_dead();+ let _ = std::fs::remove_file(lane_file(ctx));+ if n > 0 { pup_core::log(format!("[extdrive] closed {n} browser process(es)")); }+ n+}++/// Where the lane's identity is parked so it survives a pup restart. 2.0.510: EXTDRIVE_LANE and+/// EXTDRIVE_PIDS were in-memory only, so every `bridge_install` (which restarts pup) orphaned a running+/// extension browser FOREVER: take_command_for rejects every poll while the token is empty, so the live+/// extension could never re-attach, `alive()` stayed false, the next pup_open_window launched a SECOND+/// Chrome that forwarded to the running one and exited at once ("1 process(es)" in the log), and+/// pup_close_window killed nothing because it knew no pids. Measured on AdomLapper 2026-09-13: pup said+/// browserRunning:false while two of its own Chrome for Testing windows sat on John's screen.+fn lane_file(ctx: &Ctx) -> std::path::PathBuf { ctx.host.state_dir().join("extlane").join("lane.json") }++fn save_lane(ctx: &Ctx) {+ let lane = EXTDRIVE_LANE.lock().unwrap().clone();+ let pids = EXTDRIVE_PIDS.lock().unwrap().clone();+ let p = lane_file(ctx);+ if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }+ let _ = std::fs::write(&p, json!({ "lane": lane, "pids": pids }).to_string());+}++/// Re-attach to an extension browser that outlived this pup process. Called at boot.+pub fn restore_extdrive(ctx: &Ctx) {+ let p = lane_file(ctx);+ let Ok(txt) = std::fs::read_to_string(&p) else { return };+ let Ok(v) = serde_json::from_str::<Value>(&txt) else { return };+ let lane = v["lane"].as_str().unwrap_or("").to_string();+ let pids: Vec<u32> = v["pids"].as_array().map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u32)).collect()).unwrap_or_default();+ let alive: Vec<u32> = pids.into_iter().filter(|p| ctx.host.alive(*p)).collect();+ if lane.is_empty() || alive.is_empty() {+ let _ = std::fs::remove_file(&p); // nothing survived; do not hold a token no browser carries+ return;+ }+ *EXTDRIVE_LANE.lock().unwrap() = lane.clone();+ *EXTDRIVE_PIDS.lock().unwrap() = alive.clone();+ pup_core::log(format!("[extdrive] re-attached to the extension browser that outlived this pup ({} process(es), lane {lane})", alive.len()));+}+/// 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+}++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 = "adom-you";+ 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);+ if stale > 0 {+ 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));+ }+ pup_core::log(format!("[extdrive] ended {stale} stale browser process(es) holding profile \"{profile}\" before relaunching"));+ }+ for f in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = std::fs::remove_file(dir.join(f)); }+ *EXTDRIVE_LANE.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()); }+ 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()));+ *EXTDRIVE_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");@@ -68,7 +323,7 @@ pub fn bug_report_invite(is_error: bool) -> String { 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; 6] = ["pup_window_info", "pup_my_windows", "pup_errors", "pup_switch_window", "pup_use", "pup_audit_icons"];+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.@@ -96,6 +351,15 @@ pub fn render_check(cdp: &Cdp, sid: &str, http_status: Option<u64>) -> Value { 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()),@@ -123,6 +387,15 @@ pub struct Ctx { 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,@@ -164,8 +437,8 @@ impl Ctx { } /// The walls only a human can pass after a login submit (Node: detectHumanChallenge).-pub fn detect_wall(cdp: &Cdp, sid: &str) -> Option<Value> {- let js = 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; };+/// 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] };@@ -173,6 +446,8 @@ pub fn detect_wall(cdp: &Cdp, sid: &str) -> Option<Value> { 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()) } @@ -196,9 +471,9 @@ pub fn install_event_hook(ctx: Arc<Ctx>) { } /// 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_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info",- "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "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_quit_idle_browsers", "pup_rescan", "pup_set_identity", "pup_audit_icons", "pup_dashboard",+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_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_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"];@@ -209,14 +484,17 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_describe" => describe(ctx), "pup_readiness" => readiness(ctx), "pup_prewarm" => prewarm(ctx, args),- "pup_status" => status(ctx),+ "pup_status" => pup_core::merge(status(ctx), json!({ "extensionLane": extdrive::lane_health(ctx) })), "pup_list_windows" => list_windows(ctx),- "pup_my_windows" => my_windows(ctx, caller),+ "pup_my_windows" | "pup_list_sessions" => my_windows(ctx, caller), "pup_open_window" => open_window(ctx, caller, args), "pup_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" => tabs::open_tab(ctx, caller, args), "pup_list_tabs" => tabs::list_tabs(ctx, caller, args),@@ -229,6 +507,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "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" => input::reload(ctx, caller, args), "pup_back" => input::history(ctx, caller, args, true),@@ -245,14 +524,20 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "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);- let mut a = json!({ "window": "pup-dashboard", "profile": "pup-dashboard", "url": url, "takeover": true });+ // 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) };@@ -298,7 +583,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "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. Until then, pup's own screen recorder works: pup_recorder_open {reason} then pup_screen_record_start {confirmDesktopNotTabRecording:true, reason}.")),+ 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.")), };@@ -352,7 +637,8 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { 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();- if !cur.contains("STILL BROKEN") && !cur.contains("IS BROKEN") { o.insert("_hint".into(), json!(format!("{lead}{cur}"))); }+ 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") {@@ -395,6 +681,26 @@ fn window_arg(args: &Value) -> Option<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))+}+ pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> { let explicit = window_arg(args); let id = match explicit.clone() {@@ -403,6 +709,10 @@ pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Valu 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.+ 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); } let d = thread_default_session_id(&caller.thread); if ctx.sessions.exists(&d) { d } else { let mine = ctx.sessions.owned_by(&caller.thread);@@ -416,8 +726,15 @@ pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Valu // 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 && 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 \"{}\"", id, owner), Some("Pass takeover:true to drive another thread's window deliberately, or open your own with pup_open_window.")), json!({ "owner": owner, "window": id })));+ 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) }@@ -453,12 +770,26 @@ fn close_orphan_blank_targets(ctx: &Ctx, cdp: &Cdp, profile: &str) { 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_LANE.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)) {@@ -467,7 +798,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) - 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 } });- grant_default_permissions(&h.cdp);+ 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.browsers.lock().unwrap().insert(profile.into(), h.clone());@@ -494,7 +825,10 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) - 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 });- grant_default_permissions(&h.cdp);+ // 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.browsers.lock().unwrap().insert(profile.into(), h.clone());@@ -509,13 +843,93 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) - 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))?;- let _ = cdp.page_enable(&sid);- for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = cdp.send(Some(&sid), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }- let capture = vault::capture_login_script();- let _ = cdp.send(Some(&sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));- let _ = cdp.evaluate(&sid, &capture, Duration::from_secs(5));+ 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;@@ -569,6 +983,7 @@ fn active_rows(ctx: &Ctx) -> Vec<Value> { } } rows.extend(ctx.consent.rows());+ rows.extend(extras::active_download_rows()); // pup-bridge#109: in-flight downloads are pollable here rows } @@ -599,7 +1014,7 @@ fn prewarm(ctx: &Ctx, args: &Value) -> Value { pub fn session_row(ctx: &Ctx, s: &Session) -> Value { let active_tab = s.active();- json!({ "sessionId": s.id, "window": s.id, "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>(),+ 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" } })@@ -613,6 +1028,7 @@ fn status(ctx: &Ctx) -> Value { "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")), })) } @@ -620,7 +1036,7 @@ 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() }))+ 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 {@@ -658,14 +1074,56 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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);+ if lane != "cdp" {+ let needs_cdp = if profile != "adom-you" { Some(format!("profile:\"{profile}\" (a separate profile runs on the CDP lane; the extension lane is the shared adom-you browser)")) }+ else if isolated { Some("isolated:true (a throwaway jar is a CDP-lane feature; see pup-bridge#112 for what it does today)".to_string()) }+ else 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()) });+ return open_window_ext(ctx, caller, args, &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) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };+ 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); }@@ -674,22 +1132,40 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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"] })); } } - let before: std::collections::HashSet<i64> = ctx.host.windows_for_pids(&[], 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- let target = match b.cdp.create_target("about:blank", true, Some((OFFSCREEN, OFFSCREEN, 1280, 800))) { 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.")) }; // 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 };@@ -699,12 +1175,15 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { // 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) { let _ = b.cdp.set_window_bounds(wid, json!({ "left": bl, "top": bt, "width": bw, "height": bh })); }- for _ in 0..200 {+ 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)) { hwnd = Some(w.hwnd); break; }+ 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@@ -720,7 +1199,8 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { // 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.- let untabbed = ctx.host.taskbar_tab(h, false);+ // 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.@@ -757,14 +1237,21 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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();- identity::stamp_opts(ctx, &sid, caller, true, false);- 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()));+ 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).@@ -784,6 +1271,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { "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.", }));@@ -800,6 +1288,7 @@ pub const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not a 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)) {@@ -819,10 +1308,28 @@ fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } } +/// The extension-lane arm of pup_navigate: same session bookkeeping, driven through the transport.+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); }+ if let Some(h) = s.hwnd { let _ = activity::clear_attention(ctx, h, caller); }+ identity::overlay(ctx, &s.id, caller);+ ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "loaded": loaded, "transport": "ext" }))+}+ 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.@@ -851,6 +1358,16 @@ fn eval(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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, "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);@@ -878,24 +1395,279 @@ fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { "_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 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();+ 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 { crate::extdrive_pids() } 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();+ }+ let mut t_vis = if w.visible { Some(t_found) } else { None };+ 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))+}++pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owner: Option<&str>, url: &str) -> Value {+ 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.+ 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();+ // 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);+ 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).+ for attempt in 1..=2u32 {+ if let Err(e) = launch_extdrive(ctx, "about:blank") { 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.")); }+ }+ // 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);+ match extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(10)) {+ Ok(r) => {+ let t = r["tabs"].as_array().and_then(|a| a.last().cloned()).unwrap_or(Value::Null);+ 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) => (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.+ 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");+ return open_window_ext(ctx, caller, &a, sid, owner, url);+ }+ }+ };+ let mut s = Session::new(sid, "adom-you", 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;+ if let Some((h, stamped)) = birth_report(watcher, sid) { s.hwnd = Some(h); early_stamped = stamped; }+ if s.hwnd.is_none() { if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { s.hwnd = Some(h); } }+ 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+ if cold && url != "about:blank" {+ let nav = extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)).unwrap_or(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);+ 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.+ let parked = ctx.host.send_to_bottom_no_activate(h);+ pup_core::log(format!("[window] \"{sid}\" parked at z-bottom at birth (ext lane): {parked}"));+ activity::on_open(ctx, sid, h, caller); let _ = activity::clear_attention(ctx, h, caller);+ }+ 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; }+ // 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);+ pup_core::log(format!("[ext] birth bound a transient window; rebound \"{sid}\" to the live window {h} and stamped it"));+ }+ }+ let t = s.active().cloned().unwrap_or(t);+ 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",+ "_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." }))+}++/// 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}.- let everyone = args.get("everyone").and_then(|b| b.as_bool()) == Some(true);- targets = if everyone || caller.thread.is_empty() { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) };+ // 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 closed = Vec::new(); let mut chrome_killed = 0; let mut failed: Vec<String> = Vec::new(); for s in targets {- if let Some(b) = browser_for(ctx, &s.profile) { for t in &s.tabs { let _ = b.cdp.close_target(&t.target_id); } }+ // 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.- if let Some(h) = s.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)); pup_core::log(format!("[close] \"{}\" window {h} survived the CDP close; closed through the OS", s.id)); } }- ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); closed.push(s.id.clone());+ // 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+ // 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)) {+ let n = kill_extdrive(ctx); if n > 0 { chrome_killed += 1; }+ } // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused) if ctx.sessions.for_profile(&s.profile).is_empty() { if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {@@ -905,7 +1677,8 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } } }- ok(json!({ "closed": closed, "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"] }))+ 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)]
rust/verbs/src/park.rs+25−3@@ -23,9 +23,31 @@ pub fn bottom_unless_foreground(ctx: &Ctx, sid: &str, force: bool) -> &'static s let fg = ctx.host.foreground(); if fg == h && fg != 0 { if !force { return "foreground"; }- if !in_birth_grace(&s) {- if user_is_using(&s) { ctx.sessions.with_mut(sid, |x| x.user_active_at_ms = pup_core::now_ms()); return "user-foreground"; }- if let Some(idle) = ctx.host.idle_ms() { if idle < 5_000 { ctx.sessions.with_mut(sid, |x| x.user_active_at_ms = pup_core::now_ms()); return "user-foreground"; } }+ // 2.0.584 (John: "within like 200ms a human might click the windows taskbar icon"): the birth grace may+ // override the session FLAGS (they lag), never INPUT. A programmatic self-raise happens with idle hands+ // (the 2.0.26 insight); keyboard or mouse activity in the last 5 s is a person, grace or not. Before+ // this, a taskbar click inside the first 8 s was undone by the +2.5 s re-assert.+ // 2.0.588 (John: "you cannot fuck over the user if they bring it to the foreground to look at it"): a pup+ // window that IS the foreground belongs to the user, full stop, and READING it involves no input, so the+ // 5 s idle rule must never expire that. The only self-raise pup may undo is a NEWBORN's (Chrome's first+ // paint or a PDF viewer) with idle hands inside the birth grace. Everything else: hands off, and remember+ // it for 10 minutes so the re-asserts that follow stay off too.+ // 2.0.589 (John: "a user could click the windows taskbar icon within 200ms"): no magic idle number.+ // Inside the birth grace pup may demote ONLY when nobody has touched the machine since the window+ // was born (so the raise was Chrome's), and only on the CDP lane, where Chrome self-activates a+ // newborn at all (an extension-lane window is created unfocused and never does). Any input since+ // birth, a click at 200 ms included, means a person: hands off, remembered for 10 minutes, and a+ // remembered person stays a person for the rest of the grace even while they read without input.+ let idle = ctx.host.idle_ms().unwrap_or(u64::MAX);+ let since_birth = pup_core::now_ms().saturating_sub(s.born_at_ms);+ let remembered = s.user_active_at_ms > 0 && pup_core::now_ms().saturating_sub(s.user_active_at_ms) < USER_MEMORY_MS;+ let chrome_self_raise = in_birth_grace(&s) && !crate::transport::is_ext(&s) && !remembered && idle >= since_birth;+ if chrome_self_raise {+ pup_core::log(format!("[park] \"{sid}\" is foreground {since_birth} ms after birth with no input since birth: Chrome's self-raise, bottoming"));+ } else {+ ctx.sessions.with_mut(sid, |x| x.user_active_at_ms = pup_core::now_ms());+ if in_birth_grace(&s) { pup_core::log(format!("[park] \"{sid}\" is foreground inside the birth grace with input {idle} ms ago: a person raised it, hands off")); }+ return "user-foreground"; } } ctx.host.send_to_bottom_no_activate(h);
rust/verbs/src/record.rs+67−1@@ -46,7 +46,46 @@ pub fn on_frame(recs: &TabRecs, cdp: &Cdp, cdp_session: &str, params: &Value) { if w > 0 && h > 0 { r.sizes.push((w, h)); } } +// ── 2.0.581: recording rides ab's native recorder (Windows Graphics Capture + hardware H.264, MP4) ─────────+// Measured 2026-09-15 on AdomLapper: a pup window buried under a maximized Edge recorded live at its native+// 2542x1520, 2.1 MB for 20 s, frames only when the content changed. The CDP screencast (10 fps JPEG over the+// protocol socket, WebM, CDP lane only) stays ONLY as the fallback where ab's recorder does not exist.+// Keying on the hwnd is also what makes recording work on the extension lane.+static AB_RECS: Mutex<Option<std::collections::HashMap<String, (String, String)>>> = Mutex::new(None); // session -> (recordingId, path)+fn ab_data(r: Value) -> Value {+ let o = r.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).unwrap_or(r);+ if o.get("data").map(|d| d.is_object()).unwrap_or(false) { o["data"].clone() } else { o }+}+fn ab_rec_take(sid: &str) -> Option<(String, String)> { AB_RECS.lock().unwrap().as_mut().and_then(|m| m.remove(sid)) }+fn ab_rec_get(sid: &str) -> Option<(String, String)> { AB_RECS.lock().unwrap().as_ref().and_then(|m| m.get(sid).cloned()) }+ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s0 = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if ab_rec_get(&s0.id).is_some() || ctx.tab_recs.lock().unwrap().contains_key(&s0.id) { return err("recording_in_progress", format!("window \"{}\" is already recording", s0.id), Some("pup_record_stop first.")); }+ let hwnd = s0.hwnd.or_else(|| { crate::extdrive::rebind_unbound(ctx); ctx.sessions.get(&s0.id).and_then(|x| x.hwnd) });+ if let Some(h) = hwnd {+ let fps = args["fps"].as_u64().unwrap_or(30).clamp(1, 60);+ let max_ms = args["maxDurationMs"].as_u64().unwrap_or(args["minutes"].as_u64().unwrap_or(20).clamp(1, 120) * 60_000);+ let reason = args["reason"].as_str().map(|r| r.to_string()).unwrap_or_else(|| format!("{} is recording the pup window \"{}\"", if caller.thread.is_empty() { "an AI thread" } else { &caller.thread }, s0.id));+ match ctx.ab.call("desktop_record_window_start", json!({ "hwnd": h, "fps": fps, "maxDurationMs": max_ms, "reason": reason }), caller, Duration::from_secs(30)) {+ Ok(r) => {+ let d = ab_data(r);+ if let Some(rid) = d["recordingId"].as_str() {+ let path = d["path"].as_str().unwrap_or("").replace('\\', "/");+ AB_RECS.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(s0.id.clone(), (rid.to_string(), path.clone()));+ pup_core::log(format!("[{rid}] ab native recorder on window {h} for \"{}\" @ {fps}fps -> {path}", s0.id));+ let body = json!({ "ok": true, "recordingId": rid, "sessionId": s0.id, "tabId": s0.active().map(|t| t.tab_id.clone()), "fps": fps, "filePath": path, "width": d["width"], "height": d["height"], "engine": "ab native recorder (Windows Graphics Capture, hardware H.264 MP4)" });+ return ok(json!({ "success": true, "output": body.to_string(), "recordingId": rid, "sessionId": s0.id, "fps": fps, "filePath": path, "width": d["width"], "height": d["height"], "engine": "ab",+ "_hint": "Recording the whole pup window through ab's native recorder (background is fine; a covered window still records). Frames are emitted only when the content changes, so a static page yields few frames and a small file. pup_record_stop {} returns the MP4 path; pull it with adom-bridge pull_file {filePaths:[path]}." }));+ }+ pup_core::log(format!("[record] ab recorder answered without a recordingId ({}); falling back to the CDP screencast", d.to_string().chars().take(160).collect::<String>()));+ }+ Err(e) => pup_core::log(format!("[record] ab recorder unavailable ({e}); falling back to the CDP screencast")),+ }+ }+ if crate::transport::is_ext(&s0) {+ return err("recorder_unavailable", "this window's recording needs ab's native recorder (desktop_record_window_start), which did not answer", Some("Update Adom Bridge to 2.1.35+ (the native recorder). The DevTools screencast fallback exists only on the CDP lane: pup_open_window {lane:\"cdp\"}."));+ } let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; if ctx.tab_recs.lock().unwrap().contains_key(&s.id) { return err("recording_in_progress", format!("window \"{}\" is already recording", s.id), Some("pup_record_stop first.")); } let fps = args["fps"].as_u64().unwrap_or(30).clamp(1, 60) as u32;@@ -73,6 +112,22 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if let Some((rid, path0)) = ab_rec_take(&s.id) {+ return match ctx.ab.call("desktop_record_window_stop", json!({ "recordingId": rid }), caller, Duration::from_secs(60)) {+ Ok(r) => {+ let d = ab_data(r);+ let path = d["path"].as_str().map(|p| p.replace('\\', "/")).unwrap_or(path0);+ let bytes = d["bytes"].as_u64().unwrap_or(0);+ pup_core::log(format!("[{rid}] ab recorder stopped: {} frames, {} ms, {bytes} bytes -> {path}", d["frames"], d["elapsedMs"]));+ let frames = d["frames"].as_u64().unwrap_or(0); let dur = d["elapsedMs"].as_u64().unwrap_or(0);+ let body = json!({ "ok": true, "recordingId": rid, "filePath": path, "bytes": bytes, "sizeKB": bytes / 1024, "frames": frames, "durationMs": dur, "engine": "ab native recorder (H.264 MP4)" });+ // the numbers ride at the TOP level too: the CLI drops a non-string `output`, and callers (ab-smoke) read frames there+ ok(json!({ "success": true, "output": body.to_string(), "recordingId": rid, "filePath": path, "bytes": bytes, "sizeKB": bytes / 1024, "frames": frames, "recordedMs": dur, "engine": "ab",+ "_hint": "Saved as H.264 MP4 by ab's native recorder. Pull it with adom-bridge pull_file {filePaths:[path]}. Frame count reflects content changes, not wall-clock: a static page records few frames by design." }))+ }+ Err(e) => err("record_stop_failed", format!("ab's recorder did not stop {rid}: {e}"), Some("desktop_record_window_status lists what ab still has running.")),+ };+ } let Some(rec) = ctx.tab_recs.lock().unwrap().remove(&s.id) else { return err("no_recording", format!("window \"{}\" is not recording", s.id), None) }; if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.send(Some(&rec.cdp_session), "Page.stopScreencast", json!({}), Duration::from_secs(5)); } let duration_ms = rec.started.elapsed().as_millis() as u64;@@ -137,6 +192,12 @@ fn pack_avi(rec: &TabRec, out: &Path, fps: f64) -> Result<(), String> { pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if let Some((rid, path)) = ab_rec_get(&s.id) {+ let live = ctx.ab.call("desktop_record_window_status", json!({}), caller, Duration::from_secs(15)).ok().map(ab_data)+ .and_then(|d| d["recordings"].as_array().cloned()).unwrap_or_default().into_iter().find(|r| r["recordingId"].as_str() == Some(rid.as_str()));+ let cur = json!({ "recordingId": rid, "filePath": path, "engine": "ab", "ab": live });+ return ok(json!({ "success": true, "active": [cur.clone()], "output": json!({ "ok": true, "active": [cur.clone()], "sessionId": s.id, "recording": true, "current": cur }).to_string(), "sessionId": s.id, "window": s.id }));+ } let m = ctx.tab_recs.lock().unwrap(); let cur = m.get(&s.id).map(|r| json!({ "recordingId": r.id, "tabId": r.tab_id, "fps": r.fps, "frames": r.frames, "durationMs": r.started.elapsed().as_millis() as u64, "filePath": r.file.to_string_lossy().replace('\\', "/") })); let active = cur.clone().map(|c| vec![c]).unwrap_or_default();@@ -146,6 +207,11 @@ pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn list(ctx: &Ctx) -> Value { let mut rows = Vec::new(); if let Ok(rd) = std::fs::read_dir(recordings_dir(ctx)) { for e in rd.flatten() { let p = e.path(); let ext = p.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default(); if ["webm", "avi", "mp4"].contains(&ext.as_str()) { if let Ok(md) = p.metadata() { rows.push(json!({ "filePath": p.to_string_lossy().replace('\\', "/"), "sizeKB": md.len() / 1024, "mtime": md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64) })); } } } }+ if let Ok(r) = ctx.ab.call("desktop_record_list", json!({}), &crate::identity::internal_caller(), Duration::from_secs(15)) {+ for f in ab_data(r)["recordings"].as_array().cloned().unwrap_or_default() {+ rows.push(json!({ "file": f["file"], "path": f["path"].as_str().unwrap_or("").replace('\\', "/"), "bytes": f["bytes"], "kind": f["kind"], "modified": f["modified"], "engine": "ab", "mtime": 0 }));+ }+ } rows.sort_by(|a, b| b["mtime"].as_u64().cmp(&a["mtime"].as_u64())); ok(json!({ "success": true, "output": json!({ "ok": true, "recordings": rows }).to_string(), "recordings": rows, "count": rows.len() })) }@@ -155,7 +221,7 @@ pub fn list(ctx: &Ctx) -> Value { pub struct ScreenRec { pub cdp: Cdp, pub cdp_session: String, pub pid: u32, pub reason: String, pub current: Option<(String, PathBuf, std::fs::File, Instant, u32)>, pub clips: Vec<Value>, pub counter: u32 } pub type ScreenRecState = Arc<Mutex<Option<ScreenRec>>>; -pub const SCREEN_HINT: &str = "This is pup's CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). For a whole-monitor or single-window recording with no browser use ab-core's native desktop_record_start (WGC + system audio to MP4, ab 2.1.36+).";+pub const SCREEN_HINT: &str = "Recording the DESKTOP: point the user to ab first. Adom Bridge's native recorder (desktop_record_start / desktop_record_stop, ab 2.1.36+) captures a whole monitor or one window with Windows Graphics Capture plus system audio to MP4, goes aggressive on GPU acceleration and offloads frames to the GPU when the machine has one, and needs no browser and no consent HUD. THIS verb is pup's fallback: a Google Meet style screen share (getDisplayMedia into WebM VP9, consent = the visible recorder HUD, system audio + mic mixing) that works on a very broad range of machines, including ones where the native path is unavailable. Use it when ab's recorder is missing or refuses; do not present desktop recording as pup's feature."; fn recorder_html_path(ctx: &Ctx) -> PathBuf { let p = ctx.host.temp_dir().join("pup-recorder").join("recorder.html"); if !p.is_file() { let _ = std::fs::create_dir_all(p.parent().unwrap()); if let Some(b) = crate::assets_recorder_html() { let _ = std::fs::write(&p, b); } } p }
rust/verbs/src/recovery.rs+382−12@@ -38,16 +38,148 @@ fn reconnect_profile_hint(ctx: &Ctx, profile: &str, hint: Option<u16>) -> Option /// last URL, in the background, signed in (same profile), at most twice per session id; the caller /// hears about it through `_cleanupReport` on its next verb. static RELAUNCHES: std::sync::Mutex<Vec<(String, u32)>> = std::sync::Mutex::new(Vec::new());-fn relaunch_from_file(ctx: &Ctx, f: &Value) {+/// 2.0.468: session ids a relaunch is bringing back RIGHT NOW. Between "dropped from memory" and "open_window+/// inserted the new session" the id has a file and no session, which is exactly what the health tick's+/// file re-attach looks for: on arav-rog it re-attached "drill-b" to the tab "drill-a"'s relaunch had just+/// opened (same URL), and the restore thread then relaunched drill-b on top of it (4 tabs, one shared).+static RESTORING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());+static IN_FLIGHT: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());+pub fn restoring(id: &str) -> bool { RESTORING.lock().unwrap().iter().any(|s| s == id) }+/// Reserve ids a caller is about to relaunch one by one (pup_restart_browser): the health tick leaves their+/// files alone until each relaunch has run. Released by `release`, or by the relaunch itself.+pub fn reserve(ids: &[String]) { let mut m = RESTORING.lock().unwrap(); for id in ids { if !m.contains(id) { m.push(id.clone()); } } }+pub fn release(id: &str) { RESTORING.lock().unwrap().retain(|s| s != id); }+struct RelaunchGuard(String);+static RELAUNCHED_AT: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());+/// 2.0.473: a window is "settling" for a grace period after a relaunch. During it the event pump must not adopt a+/// tab into the window as "opened outside pup": a killed Chrome auto-restores the old session's page as the new+/// window comes up, and pup has already reopened that page itself, so the restore lands as a phantom extra tab+/// (triple-restart drill on ConfRoomROG: "back with 4 of 3", 0 live).+pub fn note_relaunched(id: &str) { let mut m = RELAUNCHED_AT.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 120_000); m.push((id.to_string(), now)); }+pub fn recently_relaunched(id: &str, within_ms: u64) -> bool { let now = pup_core::now_ms(); RELAUNCHED_AT.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < within_ms) }+static CANCELLED_RESTORE: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());+/// 2.0.476: a bulk close CANCELS any in-flight restore of these windows instead of waiting for it. The restore+/// thread checks this after it brings a window back and, if the window was cancelled, closes it again and drops+/// the session — so "Close all" is instant and no window finishes relaunching behind the close and lingers+/// (close-during-restore cycle). The mark expires in 45 s, and a deliberate reopen of the id clears it at once.+pub fn cancel_restore(id: &str) { let mut m = CANCELLED_RESTORE.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 300_000); m.push((id.to_string(), now)); }+pub fn restore_cancelled(id: &str) -> bool { let now = pup_core::now_ms(); CANCELLED_RESTORE.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < 45_000) }+pub fn clear_cancel(id: &str) { CANCELLED_RESTORE.lock().unwrap().retain(|(s, _)| s != id); }+impl Drop for RelaunchGuard { fn drop(&mut self) { note_relaunched(&self.0); IN_FLIGHT.lock().unwrap().retain(|s| *s != self.0); release(&self.0); } }++/// 2.0.533: an EXT session is re-adopted, never relaunched, while its Chrome window is still in the lane+/// browser. The liveness check here is CDP (reconnect_profile), which an ext window never satisfies, so a+/// pup restart declared both ext sessions' "Chrome is gone" and relaunched them as NEW windows next to the+/// originals (AdomLapper, 2026-09-13: the 17-tab proof window orphaned, a duplicate opened beside it). The+/// session file carries windowId; the live `tabs` listing says whether that window still exists and which+/// tabs it holds, so the session is rebuilt from what Chrome actually has.+fn readopt_ext(ctx: &Ctx, f: &Value) -> bool {+ if f["transport"].as_str() != Some("ext") { return false; }+ let id = f["sessionId"].as_str().unwrap_or("").to_string();+ let Some(wid) = f["windowId"].as_i64() else { return false };+ if !crate::extdrive::alive(ctx) {+ // 2.0.536: at boot restore_extdrive has already re-attached the lane's live pids, but the extension's+ // first poll lands ~1-2 s later. Declaring the window gone in that gap KILLED the live browser and+ // rebuilt the 20-tab window from its file, twice (AdomLapper 20:34, 2026-09-13). Wait for the heartbeat.+ let t0 = std::time::Instant::now();+ while !crate::extdrive::alive(ctx) && !crate::extdrive_pids().is_empty() && t0.elapsed() < Duration::from_secs(8) { std::thread::sleep(Duration::from_millis(250)); }+ if !crate::extdrive::alive(ctx) { return false; }+ }+ let Ok(r) = crate::extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(20)) else { return false };+ let live: Vec<Value> = r["tabs"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| t["windowId"].as_i64() == Some(wid)).collect();+ if live.is_empty() { return false; }+ let mut s = Session::new(&id, f["profile"].as_str().unwrap_or("adom-you"), f["owner"].as_str(), None, 0, None);+ s.transport = "ext".into(); s.window_id = Some(wid);+ for t in &live { let tab = crate::transport::ext_tab(&mut s, t["tabId"].as_i64().unwrap_or(0), t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or("")); s.tabs.push(tab); }+ if let Some(i) = live.iter().position(|t| t["active"] == json!(true)) { s.active_tab = i; }+ s.hwnd = f["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h));+ if s.hwnd.is_none() {+ let taken: Vec<i64> = ctx.sessions.all().iter().filter_map(|x| x.hwnd).collect();+ s.hwnd = ctx.host.windows_for_pids(&crate::extdrive_pids(), Some("Chrome_WidgetWin_1")).iter().find(|w| w.visible && w.rect.2 - w.rect.0 > 200 && !taken.contains(&w.hwnd)).map(|w| w.hwnd);+ }+ s.app_id = f["appId"].as_str().map(|x| x.to_string());+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ pup_core::log(format!("[recovery] \"{id}\": re-adopted its extension-lane window (Chrome window {wid}, {} tab(s), hwnd {:?}); nothing relaunched", s.tabs.len(), s.hwnd));+ crate::extdrive::rebind_unbound(ctx); // stamp the re-adopted window NOW; the health tick is only a backstop+ true+}++pub fn relaunch_from_file(ctx: &Ctx, f: &Value) { let id = f["sessionId"].as_str().unwrap_or("").to_string();+ { let mut m = IN_FLIGHT.lock().unwrap(); if m.contains(&id) { pup_core::log(format!("[recovery] \"{id}\": a relaunch is already in flight; not starting a second")); return; } m.push(id.clone()); }+ reserve(std::slice::from_ref(&id));+ let _guard = RelaunchGuard(id.clone()); let url = f["url"].as_str().unwrap_or("about:blank").to_string(); let profile = f["profile"].as_str().unwrap_or("adom-you").to_string(); let owner = f["owner"].as_str().unwrap_or("").to_string(); let attempts = { let mut m = RELAUNCHES.lock().unwrap(); match m.iter_mut().find(|(s, _)| *s == id) { Some(e) => { e.1 += 1; e.1 } None => { m.push((id.clone(), 1)); 1 } } }; if attempts > 2 { pup_core::log(format!("[recovery] \"{id}\" failed to come back twice — giving up on it (the page itself may be crashing Chrome); removing its file")); ctx.sessions.delete_file(&id); crate::push_cleanup_report(format!("pup could NOT auto-recover the \"{id}\" window: it crashed again on relaunch ({url}). Open it again yourself with pup_open_window if you still need it.")); return; }+ if restore_cancelled(&id) { pup_core::log(format!("[recovery] \"{id}\": its restore was cancelled by a close; not relaunching")); ctx.sessions.delete_file(&id); return; }+ if readopt_ext(ctx, f) { return; }+ // 2.0.588: the window this file describes is still on glass: nothing is gone. Relaunching here replaced a+ // window the user had just raised. Re-adopt failed only because the worker was quiet; the next tick retries.+ if let Some(h) = f["_hwnd"].as_i64().filter(|&h| h > 0) {+ if ctx.host.is_alive(h) { pup_core::log(format!("[recovery] \"{id}\": its window {h} is alive on glass; NOT relaunching (worker quiet, will re-adopt)")); return; }+ } pup_core::log(format!("[recovery] \"{id}\": its Chrome is gone (profile {profile}) — relaunching at {url} in the background (attempt {attempts})")); let caller = pup_core::Caller { thread: owner.clone(), reason: "auto-recovery: the window's Chrome was gone".into(), ..Default::default() };- let r = crate::open_window(ctx, &caller, &json!({ "window": id, "url": url, "profile": profile, "takeover": true, "owner": if owner.is_empty() { Value::Null } else { json!(owner) } }));+ let open_args = json!({ "window": id, "url": url, "profile": profile, "takeover": true, "lane": f["transport"].as_str().map(|x| x.to_string()).unwrap_or_else(|| ctx.settings.get("defaultLane", "ext")), "owner": if owner.is_empty() { Value::Null } else { json!(owner) } });+ let mut r = crate::open_window(ctx, &caller, &open_args);+ // 2.0.580: a cold extension lane answers `starting:true` (no session yet) when its worker is not up within+ // the open's budget. Treating that as done left an orphan window and "0 session(s) active" (2026-09-14+ // 20:57). Wait for the worker, then open once more.+ if r["starting"] == json!(true) {+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(30) && !crate::extdrive::alive(ctx) { std::thread::sleep(Duration::from_millis(500)); }+ pup_core::log(format!("[recovery] \"{id}\": the lane was still starting; opening again now that the worker {}", if crate::extdrive::alive(ctx) { "is up" } else { "is STILL silent (best effort)" }));+ r = crate::open_window(ctx, &caller, &open_args);+ }+ // 2.0.476: a close that landed WHILE this relaunch was opening the window cancels it; undo the window we just+ // brought back so it does not linger behind the close.+ if r["ok"] == json!(true) && restore_cancelled(&id) {+ pup_core::log(format!("[recovery] \"{id}\": a close cancelled its restore mid-flight; closing the window it had just reopened"));+ let c = pup_core::Caller { thread: owner.clone(), reason: "undo a cancelled restore".into(), ..Default::default() };+ let _ = crate::close_window(ctx, &c, &json!({ "window": id, "takeover": true }));+ ctx.sessions.delete_file(&id);+ return;+ }+ // 2.0.461: reopen the window's OTHER tabs too, in their saved order.+ if r["ok"] == json!(true) {+ // 2.0.478: dedup the reopened tabs by base URL (and drop the active url) so a file that accumulated a+ // duplicate never reopens the same page twice.+ let base = |u: &str| u.split('?').next().unwrap_or("").trim_end_matches('/').to_string();+ let mut seen: Vec<String> = vec![base(&url)];+ let rest: Vec<String> = f["tabs"].as_array().map(|a| a.iter().filter_map(|t| t["url"].as_str()).map(|u| u.to_string()).filter(|u| {+ let b = base(u);+ if u.is_empty() || u == "about:blank" || seen.contains(&b) { false } else { seen.push(b); true }+ }).collect()).unwrap_or_default();+ if !rest.is_empty() {+ // 2.0.537: reopen ONLY into the window this relaunch itself just made. If a caller opened this id+ // meanwhile (its session is newer than the relaunch's own window), the saved list would land on top+ // of the caller's tabs: that produced ti.com twice and 33 tabs for 20 sites (John, 2026-09-13).+ let mine = r["hwnd"].as_i64().or_else(|| ctx.sessions.get(&id).and_then(|x| x.hwnd));+ let cur = ctx.sessions.get(&id);+ let Some(cur) = cur else {+ // 2.0.580: no session at all is a FAILED relaunch, not another caller's window. Keep the file so the+ // next tick tries again instead of dropping the user's tabs on the floor.+ pup_core::log(format!("[recovery] \"{id}\": the relaunch registered no session (open answered ok={} error={:?}); keeping its file for the next attempt", r["ok"], r["error"].as_str()));+ return;+ };+ // Chrome's own windowId is exact; the hwnd can legitimately change between the open and this check+ // (a late rebind), which is what mis-fired this guard on the extension lane.+ let still_mine = match (cur.window_id, r["windowId"].as_i64()) { (Some(a), Some(b)) => a == b, _ => cur.hwnd == mine || mine.is_none() };+ if !still_mine {+ pup_core::log(format!("[recovery] \"{id}\": another caller opened this window while the relaunch ran; not reopening the saved {} tab(s) on top of theirs", rest.len()));+ } else {+ pup_core::log(format!("[recovery] \"{id}\": reopening its other {} tab(s)", rest.len()));+ for u in &rest { let _ = crate::tabs::open_tab(ctx, &caller, &json!({ "window": id, "url": u, "takeover": true })); }+ }+ }+ }+ // 2.0.471: a relaunch that WORKED resets the counter. It counted every relaunch in the bridge's life, so a window+ // brought back twice (a wake, a crash) was refused the third time as "failed to come back twice" (winvm drill,+ // 2026-09-11: "drill-b" back with 0 of 3 after pup_restart_browser). Only consecutive failures count now.+ if r["ok"] == json!(true) { RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id); } if r["ok"] == json!(true) { crate::push_cleanup_report(format!("pup auto-recovered your \"{id}\" window after its Chrome was gone (a bridge update, a Chrome crash, or a sleep): it is back at {url}, in the background, on the same profile. Verify with pup_screenshot.")); } if r["ok"] == json!(true) { ctx.sessions.with_mut(&id, |x| x.disruption_notice = Some(format!("This window was auto-recovered after its Chrome was gone; it is back at {url} in the background. If something you set on the old page (an emulated viewport, an in-page draft) is missing, that is why."))); } else { pup_core::log(format!("[recovery] \"{id}\" relaunch failed: {}", r["error"].as_str().unwrap_or("?"))); }@@ -65,14 +197,24 @@ fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<Strin let want_url = file["url"].as_str().unwrap_or("").split('?').next().unwrap_or("").to_string(); let pick = targets.iter().find(|t| want_tid.as_deref().is_some_and(|w| t["targetId"] == json!(w))) .or_else(|| targets.iter().find(|t| { let u = t["url"].as_str().unwrap_or(""); !want_url.is_empty() && u != "about:blank" && u.starts_with(&want_url) && !taken.contains(&t["targetId"].as_str().unwrap_or("").to_string()) }))?;- let tid = pick["targetId"].as_str()?.to_string();+ // 2.0.454: a target another session already re-attached is not picked twice; an "adopted-*" leftover whose+ // page now belongs to its window's session is finished with, and its file goes.+ if let Some(t) = pick["targetId"].as_str() { if taken.contains(&t.to_string()) {+ if id.starts_with("adopted-") { pup_core::log(format!("Session \"{id}\": its page already belongs to a window session; dropping the leftover file")); ctx.sessions.delete_file(&id); }+ else { pup_core::log(format!("Session \"{id}\": its target {t} was already re-attached by another session; leaving the file")); }+ return None;+ } }+ let Some(tid) = pick["targetId"].as_str().map(|t| t.to_string()) else { pup_core::log(format!("Session \"{id}\": no live page target matches its file (wanted {:?} or url {:?}); leaving the file", want_tid, want_url)); return None; }; taken.push(tid.clone());- let sid = h.cdp.attach(&tid).ok()?;- let _ = h.cdp.page_enable(&sid);+ let sid = match h.cdp.attach(&tid) { Ok(x) => x, Err(e) => { pup_core::log(format!("Session \"{id}\": could not attach its target {tid}: {e}")); return None; } };+ // 2.0.456: Page.enable on a helper thread (a discarded tab answers only when its renderer comes back;+ // inline it cost the first recovery 40 s on AdomLapper).+ { let (c2, s2) = (h.cdp.clone(), sid.clone()); std::thread::Builder::new().name("recover-setup".into()).spawn(move || { let _ = c2.page_enable(&s2); }).ok(); } let mut s = Session::new(&id, &profile, file["owner"].as_str(), None, h.launched.cdp_port, file["pid"].as_u64().map(|p| p as u32)); s.created_iso = file["createdAt"].as_str().unwrap_or(&s.created_iso).to_string(); s.foreground = file["foreground"].as_bool().unwrap_or(false); s.app_id = file["appId"].as_str().map(|a| a.to_string());+ s.window_id = h.cdp.window_for_target(&tid).ok().map(|(w, _)| w).filter(|w| *w > 0).or_else(|| file["windowId"].as_i64()); let tab_id = s.next_tab_id(); s.tabs.push(Tab { tab_id, target_id: tid, cdp_session: sid, url: pick["url"].as_str().unwrap_or("").into(), title: pick["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None }); // the OS window: the persisted handle if it is still alive, else the one carrying our tag@@ -94,16 +236,38 @@ fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<Strin } /// Boot: every session file names a browser we may still own.+/// 2.0.456: after sessions re-attach (boot, reconnect), every page target in a recovered window that no+/// session tracks joins that window's session, keyed by the CDP Browser window id. Event-driven (runs at+/// the recovery moment, never on a timer). Skips about:blank and browser-internal pages.+pub fn adopt_window_tabs(ctx: &Ctx, h: &Arc<BrowserHandle>) -> usize {+ let mut adopted = 0;+ let tracked: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();+ for t in page_targets(&h.cdp) {+ let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("").to_string();+ if tid.is_empty() || tracked.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") || url.starts_with("chrome-extension://") { continue; }+ let Ok((wid, _)) = h.cdp.window_for_target(&tid) else { continue };+ let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) else { continue };+ let mut s = owner.clone();+ if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { adopted += 1; pup_core::log(format!("[recover] \"{}\": adopted the window's untracked tab {}", s.id, &url[..url.len().min(80)])); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }+ }+ adopted+}+ pub fn recover_at_boot(ctx: &Ctx) { let files = ctx.sessions.read_files(); if files.is_empty() { pup_core::log("Recovering 0 session(s) from disk..."); return; } pup_core::log(format!("Recovering {} session(s) from disk...", files.len())); let mut taken: Vec<String> = Vec::new();+ // 2.0.454: named window sessions first; hwnd-less "adopted-*" leftovers last, so they can never claim a+ // window's targets ahead of the session that owns the window.+ let mut files = files; files.sort_by_key(|f| f["sessionId"].as_str().unwrap_or("").starts_with("adopted-")); for f in &files { let id = f["sessionId"].as_str().unwrap_or("").to_string(); if id.is_empty() { continue; } let pid = f["pid"].as_u64().unwrap_or(0) as u32; let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();+ if readopt_ext(ctx, f) { continue; }+ if f["transport"].as_str() == Some("ext") { relaunch_from_file(ctx, f); continue; } let hint = f["cdpPort"].as_u64().map(|p| p as u16); let Some(h) = reconnect_profile_hint(ctx, &profile, hint) else { if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, f); } else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port but PID {pid} is alive (Chrome asleep or starting) — leaving the file for the health loop")); }@@ -114,12 +278,165 @@ pub fn recover_at_boot(ctx: &Ctx) { None => { pup_core::log(format!("Session \"{id}\" — no matching page in Chrome, removing")); ctx.sessions.delete_file(&id); } } }- pup_core::log(format!("Recovery complete. {} session(s) active.", ctx.sessions.count()));+ let handles: Vec<Arc<BrowserHandle>> = ctx.browsers.lock().unwrap().values().cloned().collect();+ let adopted: usize = handles.iter().map(|h| adopt_window_tabs(ctx, h)).sum();+ crate::focus_emulate_all(ctx); // 2.0.458: re-apply the focus-emulation policy to the recovered sessions+ pup_core::log(format!("Recovery complete. {} session(s) active{}.", ctx.sessions.count(), if adopted > 0 { format!(", {adopted} untracked tab(s) adopted into their windows") } else { String::new() })); } /// One health tick: dead browser connections are reconnected (never killed), sessions whose /// target vanished are dropped, stale files are swept.+/// 2.0.453. (a) A session whose last tab target was destroyed keeps its OS window for 15 s: if Chrome+/// recreated a page in that Browser window (sleep, Memory Saver, renderer restart) it is re-attached;+/// once the OS window is gone, or nothing replaced the tab, the session is removed. (b) A hwnd-less+/// session whose tab lives in another session's window (an "adopted-*" leftover) merges into it.+fn tabless_and_orphan_pass(ctx: &Ctx) {+ let now = pup_core::now_ms();+ for s in ctx.sessions.all() {+ // 2.0.536: both halves of this pass reason in CDP terms (window_for_target, browser_for). An ext session's+ // liveness is the lane and its Chrome window id, judged by readopt_ext; this pass removed a live ext+ // session 17 s after recovery rebuilt it, which the file pass then relaunched again.+ if crate::transport::is_ext(&s) { continue; }+ if s.tabs.is_empty() && s.tabless_since_ms > 0 {+ let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };+ let taken: Vec<String> = ctx.sessions.all().iter().flat_map(|x| x.tabs.iter().map(|t| t.target_id.clone())).collect();+ let replacement = page_targets(&h.cdp).into_iter().map(|t| t["targetId"].as_str().unwrap_or("").to_string())+ .filter(|tid| !tid.is_empty() && !taken.contains(tid))+ .find(|tid| h.cdp.window_for_target(tid).ok().map(|(w, _)| Some(w) == s.window_id).unwrap_or(false));+ if let Some(tid) = replacement {+ let mut ns = s.clone();+ if crate::attach_tab(&mut ns, &h.cdp, &tid).is_ok() { ns.tabless_since_ms = 0; pup_core::log(format!("[health] \"{}\": Chrome replaced its tab; re-attached the new page target", ns.id)); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), false); } continue; }+ }+ let window_alive = s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false);+ if !window_alive || now.saturating_sub(s.tabless_since_ms) > 15_000 {+ pup_core::log(format!("[health] session \"{}\" dead (its last tab was closed outside pup and {}) — removing", s.id, if window_alive { "nothing replaced it in 15 s" } else { "its window is gone" }));+ ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);+ }+ continue;+ }+ if s.hwnd.is_none() && !s.tabs.is_empty() && s.id.starts_with("adopted-") {+ let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };+ let Ok((wid, _)) = h.cdp.window_for_target(&s.tabs[0].target_id) else { continue };+ if let Some(owner) = ctx.sessions.all().into_iter().find(|x| x.id != s.id && x.window_id == Some(wid) && x.hwnd.is_some()) {+ let mut o = owner.clone();+ for t in &s.tabs { let mut t2 = t.clone(); t2.tab_id = o.next_tab_id(); o.tabs.push(t2); }+ pup_core::log(format!("[health] merged the hwnd-less session \"{}\" into \"{}\" (same Browser window)", s.id, o.id));+ ctx.sessions.insert(o.clone()); ctx.sessions.save_file(&o); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);+ }+ }+ }+}++/// A Chrome whose BROWSER process stops answering is invisible to a bridge that only ever times out: on+/// 2026-09-10 John's Chrome browser main thread spun at 98 % of a core for hours, so its taskbar tab strip and+/// his touchpad wheel stopped working, pup's calls timed out one by one, and pup said nothing. Every health+/// tick now pings the browser endpoint itself (Browser.getVersion, no page involved). Three misses in a row+/// (about 45 s) is not a slow page, it is a browser that is not coming back: pup says so in the log, toasts the+/// user with what to do, and carries it in pup_status / pup_readiness until it answers again.+fn browser_liveness(ctx: &Ctx) {+ let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();+ for (profile, h) in handles {+ let alive = h.cdp.send(None, "Browser.getVersion", json!({}), Duration::from_secs(5)).is_ok();+ let mut m = ctx.browser_stall.lock().unwrap();+ let misses = m.entry(profile.clone()).or_insert(0u32);+ if alive { if *misses >= 3 { pup_core::log(format!("[health] the browser for profile \"{profile}\" is answering again")); } *misses = 0; continue; }+ *misses += 1;+ let n = *misses;+ drop(m);+ if n == 3 {+ let tabs: usize = ctx.sessions.for_profile(&profile).iter().map(|s| s.tabs.len()).sum();+ pup_core::log(format!("[health] the browser for profile \"{profile}\" has not answered 3 checks in a row (~45 s). Its browser process is wedged, not busy: your windows will not scroll with the wheel and their tab strips will not respond, though dragging a scrollbar still works. pup cannot fix this from inside; that Chrome has to be restarted. {tabs} tab(s) are saved in the session files and come back with it."));+ let _ = ctx.ab.call("notify_user", json!({+ "title": "pup: this browser has stopped responding",+ "body": format!("The Chrome behind your pup windows (profile {profile}) is wedged: the wheel and the tab strip will not respond, though the scrollbar still drags. Restarting it fixes it, and your tabs are saved. Ask your AI thread to run pup_restart_browser."),+ "level": "warning", "focus": false }), &crate::identity::internal_caller(), Duration::from_secs(8));+ }+ }+}++/// A browser with no pup windows is holding the user's RAM for nothing. Measured on John's laptop,+/// 2026-09-11, with zero tabs open: 657 MB to 1.2 GB across 8 processes, most of it a GPU process still+/// sitting on allocations from 3D pages that had been closed. pup has always had pup_quit_idle_browsers and+/// never called it, so an empty Chrome lived until the machine rebooted. Now: a browser that has had no+/// windows for `idleBrowserQuitMin` minutes (default 10, 0 disables) is closed. The grace means a thread that+/// closes one window and opens another never pays a cold start, and nothing on disk is lost: the profile,+/// its cookies and its logins are files, so the next open comes back signed in.+fn idle_browser_reap(ctx: &Ctx) {+ let mins: u64 = ctx.settings.get("idleBrowserQuitMin", "10").parse().unwrap_or(10);+ if mins == 0 { return; }+ let now = pup_core::now_ms();+ let profiles: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();+ for p in profiles {+ if !ctx.sessions.for_profile(&p).is_empty() { ctx.browser_idle_since.lock().unwrap().remove(&p); continue; }+ let since = *ctx.browser_idle_since.lock().unwrap().entry(p.clone()).or_insert(now);+ if now.saturating_sub(since) < mins * 60_000 { continue; }+ let Some(b) = ctx.browsers.lock().unwrap().remove(&p) else { continue };+ let mb = ctx.host.process_tree_rss_mb(b.launched.pid).unwrap_or(0);+ 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); } }+ ctx.browser_idle_since.lock().unwrap().remove(&p);+ pup_core::log(format!("[idle] closed the browser for profile \"{p}\": no pup windows for {mins} min, and it was holding {mb} MB. The next open launches a fresh one, still signed in."));+ }+}++/// 2.0.580: the lane died overnight (worker silent 14 h, no window on glass) while pup kept listing its+/// session and calling the lane healthy, because the remembered pids still "looked" alive. The extension+/// lane has no CDP socket to notice a death on, so the worker's heartbeat IS its liveness. Silent 3 min+/// with no lane window on glass, or 10 min regardless: end what is left, and restore every extension+/// session from its file (the same path that heals a crash), tabs included.+fn silent_lane_restore(ctx: &Ctx) {+ static LAST: std::sync::Mutex<u64> = std::sync::Mutex::new(0);+ let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s)).collect();+ if ext.is_empty() || crate::extdrive::last_poll_ms() == 0 { return; }+ let ago = crate::extdrive::connected_ms_ago();+ if ago < 180_000 { return; }+ // 2.0.588 (John, 2026-09-15: "i just foregrounded the kicad-bridge pup window to look at it and you stole it+ // away from me"): this fired on INFERENCE (a quiet worker plus a window count that read zero with unknown+ // pids) and relaunched a window the user was looking at. Ground truth first: a session whose OS window is+ // alive on glass is NOT a dead lane, whatever the worker is doing. Never destroy on inference when the+ // truth is one IsWindow away (pup-user-foreground, the window-destruction incident).+ let alive_sessions = ext.iter().filter(|s| s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)).count();+ if alive_sessions > 0 {+ static SAID: std::sync::Mutex<u64> = std::sync::Mutex::new(0);+ let mut g = SAID.lock().unwrap();+ if pup_core::now_ms().saturating_sub(*g) > 300_000 { *g = pup_core::now_ms(); pup_core::log(format!("[health] the extension worker has been silent {} s but {alive_sessions} session window(s) are alive on glass: leaving them alone (a quiet worker is not a dead lane)", ago / 1000)); }+ return;+ }+ // an EMPTY pid list is "no filter" to the enumerator (it would count the user's own Chrome and Edge, measured: 7)+ let pids = crate::extdrive_pids();+ let wins = if pids.is_empty() { 0 } else { ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).count() };+ if wins > 0 && ago < 600_000 { return; }+ { let mut g = LAST.lock().unwrap(); if pup_core::now_ms().saturating_sub(*g) < 300_000 { return; } *g = pup_core::now_ms(); }+ pup_core::log(format!("[health] the extension lane has been silent for {} s with {wins} lane window(s) on glass: it is gone. Ending what is left and restoring {} session(s) from disk", ago / 1000, ext.len()));+ let ids: Vec<String> = ext.iter().map(|s| s.id.clone()).collect();+ for s in &ext { ctx.sessions.save_file(s); ctx.sessions.remove(&s.id); }+ crate::kill_extdrive(ctx);+ for f in ctx.sessions.read_files() {+ let id = f["sessionId"].as_str().unwrap_or("");+ if ids.iter().any(|x| x == id) { relaunch_from_file(ctx, &f); }+ }+}+ pub fn health_tick(ctx: &Ctx) {+ crate::extdrive::adopt_tokenless(ctx); // 2.0.580: a token-less extension browser after a restart+ silent_lane_restore(ctx); // 2.0.580: a lane whose worker went quiet is a dead lane+ crate::extdrive::rebind_unbound(ctx); // ext sessions whose birth outran the hwnd wait: bind + stamp now+ crate::extdrive::reload_stale_worker(ctx); // a bridge_install must not wait for every thread's window to close+ crate::extdrive::close_orphan_blank_windows(ctx, None); // and any about:blank window no session owns+ // 2.0.542: a lane browser that outlived a pup update keeps running the OLD extension until it cycles+ // (measured: worker.2-0-539 still serving under pup 2.0.541, so a brand rewrite shipped in .540 never+ // ran). When no extension-lane window is open, end it; the next open stages the current extension.+ if let Some((w, want)) = crate::extdrive::lane_worker_stale() {+ if crate::extdrive_browser_alive(ctx) && !ctx.sessions.all().iter().any(|s| crate::transport::is_ext(s)) {+ pup_core::log(format!("[lane] cycling the idle lane browser: it runs extension {} while pup is {}", w.replace('-', "."), want.replace('-', ".")));+ crate::kill_extdrive(ctx);+ *crate::extdrive::LANE_WORKER.lock().unwrap() = String::new();+ }+ }+ browser_liveness(ctx);+ idle_browser_reap(ctx);+ tabless_and_orphan_pass(ctx); let profiles: Vec<(String, u32)> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, b)| (p.clone(), b.launched.pid)).collect(); for (p, pid) in profiles { pup_core::log(format!("[health] browser for profile \"{p}\" disconnected — attempting reconnect"));@@ -131,6 +448,8 @@ pub fn health_tick(ctx: &Ctx) { let f = s.file_json(); match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => { pup_core::log(format!("[health] session \"{}\" not found after reconnect — removing", s.id)); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); } } }+ let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the reconnect")); }+ crate::focus_emulate_all(ctx); } None => { // Chrome is GONE (its pid is dead): every window on that profile comes back from its file@@ -139,7 +458,13 @@ pub fn health_tick(ctx: &Ctx) { if pid == 0 || !ctx.host.alive(pid) { let files: Vec<Value> = ctx.sessions.for_profile(&p).iter().map(|s| s.file_json()).collect(); pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone — relaunching its {} window(s) from their files", files.len()));- for f in files { if let Some(id) = f["sessionId"].as_str() { ctx.sessions.remove(id); } relaunch_from_file(ctx, &f); }+ // 2.0.469: drop and reserve EVERY session first, then relaunch. Dropping each only at its turn left+ // the second window listed with its three dead tabs for the ~30 s the first window's relaunch took+ // (CRR crash drill: "restored 2x3" but 3 of 6 live); a listed window must be a live one.+ let ids: Vec<String> = files.iter().filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).collect();+ reserve(&ids);+ for id in &ids { ctx.sessions.remove(id); }+ for f in files { relaunch_from_file(ctx, &f); } } else { pup_core::log(format!("[health] could not reconnect profile \"{p}\" but its Chrome (pid {pid}) is alive — its sessions wait for the next tick (Chrome may be asleep or restarting)")); }@@ -162,17 +487,53 @@ pub fn health_tick(ctx: &Ctx) { } for f in ctx.sessions.read_files() { let id = f["sessionId"].as_str().unwrap_or("").to_string(); let pid = f["pid"].as_u64().unwrap_or(0) as u32;- if id.is_empty() || ctx.sessions.exists(&id) { continue; }+ if id.is_empty() || ctx.sessions.exists(&id) || restoring(&id) { continue; }+ // 2.0.536: an ext file is re-adopted from the live lane first; it never enters the CDP reconnect, and it+ // is relaunched only when the lane really is dead (pid is 0 on ext sessions by design, which used to+ // read as "gone" every tick and tore the live window down).+ if f["transport"].as_str() == Some("ext") {+ if readopt_ext(ctx, &f) { continue; }+ if !crate::extdrive::alive(ctx) && crate::extdrive_pids().is_empty() { relaunch_from_file(ctx, &f); }+ continue;+ } let profile = f["profile"].as_str().unwrap_or("adom-you").to_string(); if let Some(h) = reconnect_profile_hint(ctx, &profile, f["cdpPort"].as_u64().map(|p| p as u16)) {- let mut taken = Vec::new();+ // 2.0.468: a tab a LIVE session already tracks is never re-attached from a file (the URL-prefix rule+ // matched another window's tab at the same page on arav-rog).+ let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect(); match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { pup_core::log(format!("[health] re-attached session \"{id}\" to its live Chrome")); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => relaunch_from_file(ctx, &f) }+ let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the re-attach")); }+ crate::focus_emulate_all(ctx); } else if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, &f); } }+ crate::focus_emulate_all(ctx); // 2.0.459: converge the focus-emulation policy (no CDP calls when it already matches) } +/// 2.0.470: the event pump kicks the health loop the moment a browser connection closes, so a crashed Chrome's+/// windows come back within a second or two instead of up to 15 s later (arav-rog crash drill: pup_list_windows+/// listed the dead windows as normal for the rest of the tick).+static HEALTH_KICK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);+pub fn kick_health() { HEALTH_KICK.store(true, std::sync::atomic::Ordering::SeqCst); }+/// 2.0.472: how many relaunches are still running. pup_restart_browser waits for this to reach zero before it+/// saves + kills again, so a second restart (or the wedge watchdog firing while a restore is mid-flight) can no+/// longer tear a window down while its tabs are still being reopened into it (ConfRoomROG/arav-rog triple-restart+/// drill: round 2 killed tri-b's window mid-restore — "HWND is not a valid window", "back with 6 of 3").+pub fn relaunches_in_flight() -> usize { IN_FLIGHT.lock().unwrap().len() }+/// 2.0.475: drain on the RESERVATION set, not IN_FLIGHT. A restart/crash batch reserves every window id up front+/// and releases each only as its relaunch runs, so RESTORING stays non-empty for the WHOLE batch; IN_FLIGHT goes+/// momentarily empty BETWEEN two sequential relaunches, and a close that drained on it fired in that gap and+/// missed the second window (close-during-restore cycle: a "cyc-b" lingered, then poisoned the next restart).+pub fn restores_pending() -> usize { RESTORING.lock().unwrap().len() }+pub fn wait_for_relaunches_to_drain(max: Duration) {+ let t0 = std::time::Instant::now();+ while (restores_pending() > 0 || relaunches_in_flight() > 0) && t0.elapsed() < max { std::thread::sleep(Duration::from_millis(100)); }+} pub fn start_health_loop(ctx: Arc<Ctx>) {- std::thread::Builder::new().name("health".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(15)); let t0 = std::time::Instant::now(); health_tick(&ctx); pup_core::loops::tick("health+user-activity", 15_000, t0.elapsed().as_millis() as u64); }).ok();+ std::thread::Builder::new().name("health".into()).spawn(move || loop {+ let slept = std::time::Instant::now();+ while slept.elapsed() < Duration::from_secs(15) { if HEALTH_KICK.swap(false, std::sync::atomic::Ordering::SeqCst) { pup_core::log("[health] kicked: a browser connection closed; running the tick now".to_string()); break; } std::thread::sleep(Duration::from_millis(500)); }+ let t0 = std::time::Instant::now(); health_tick(&ctx); pup_core::loops::tick("health+user-activity", 15_000, t0.elapsed().as_millis() as u64);+ }).ok(); } /// pup_rescan: reconnect dropped connections and rebuild sessions from files; adoptOrphans:true@@ -197,6 +558,14 @@ pub fn rescan(ctx: &Ctx, args: &Value) -> Value { for t in page_targets(&h.cdp) { let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or(""); if taken.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") { continue; }+ // 2.0.453: a page in a window pup already tracks joins THAT session as a tab (keyed by the CDP+ // Browser window id), instead of becoming a hwnd-less standalone "adopted-*" session.+ if let Ok((wid, _)) = h.cdp.window_for_target(&tid) {+ if let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) {+ let mut s = owner.clone();+ if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { s.tabless_since_ms = 0; taken.push(tid.clone()); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1; pup_core::log(format!("[rescan] \"{}\": adopted the orphan tab {} into its own window", s.id, &url[..url.len().min(80)])); continue; }+ }+ } if let Ok(sid) = h.cdp.attach(&tid) { let _ = h.cdp.page_enable(&sid); let id = format!("adopted-{}", &tid[..tid.len().min(8)].to_lowercase());@@ -209,9 +578,10 @@ pub fn rescan(ctx: &Ctx, args: &Value) -> Value { } } let live: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_some()).map(|s| s.id.clone()).collect();- let disconnected: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_none()).map(|s| s.id.clone()).collect();+ let rebound = crate::extdrive::rebind_unbound(ctx);+ let disconnected: Vec<String> = ctx.sessions.all().iter().filter(|s| if crate::transport::is_ext(s) { !crate::extdrive_browser_alive(ctx) } else { crate::browser_for(ctx, &s.profile).is_none() }).map(|s| s.id.clone()).collect(); let hint = if disconnected.is_empty() { format!("All known sessions are live: [{}].", live.join(", ")) } else { format!("{} session(s) still disconnected: [{}] — their Chrome is gone; pup_open_window recreates them.", disconnected.len(), disconnected.join(", ")) };- ok(json!({ "rescanned": reconnected.len() + unreachable.len(), "liveSessions": live, "disconnectedSessions": disconnected, "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected.len(), "profilesUnreachable": unreachable.len(), "_hint": hint }))+ ok(json!({ "rescanned": reconnected.len() + unreachable.len(), "liveSessions": live, "disconnectedSessions": disconnected, "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected.len(), "extWindowsRebound": rebound, "profilesUnreachable": unreachable.len(), "_hint": hint })) } #[allow(dead_code)]
rust/verbs/src/settings.rs+6−1@@ -14,7 +14,12 @@ pub const KEYS: &[(&str, &str, &str)] = &[ ("toolbarExtension", "on", "on|off"), ("tbSnipHotkey", "on", "on|off"), ("tbFloatyPencil", "off", "on|off"), ("floatyHitPx", "24", "int"), ("tbOwnerBadge", "on", "on|off"), ("annCaptionBar", "on", "on|off"), ("taskbarActivity", "progress", "progress|flash|off"),- ("fgWatch", "on", "on|off"), ("birthSettleMs", "400", "int"), ("aumidShortcut", "off", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"),+ ("defaultLane", "ext", "ext|cdp"),+ ("consumerBrands", "on", "on|off"),+ ("laneStockFlags", "on", "on|off"),+ ("laneFieldTrials", "on", "on|off"),+ ("consumerBrandsHeader", "", "str"),+ ("fgWatch", "on", "on|off"), ("birthSettleMs", "400", "int"), ("aumidShortcut", "off", "on|off"), ("idleBrowserQuitMin", "10", "int"), ("winAttentionFlash", "off", "on|off"), ("prePermissions", "off", "on|off"), ("cdpQuiet", "off", "on|off"), ("stockFlags", "off", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"), ("captureLogins", "on", "on|off"), ("autoLogin", "on", "on|off"), ("ownerChip", "on", "on|off"), ("titleTag", "off", "on|off"), ("titlePrefix", "off", "on|off"), ("sleepRecovery", "on", "on|off"), ("reportIssues", "on", "on|off"), ("minimalTouch", "on", "on|off"),
rust/verbs/src/tabs.rs+48−5@@ -8,16 +8,48 @@ use std::time::Duration; fn tab_row(ctx: &Ctx, s: &pup_session::Session, t: &pup_session::Tab, active: bool) -> Value { let errs = ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0);- json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": active, "errorCount": errs, "fav": t.fav, "opener": "agent", "openerTabId": Value::Null, "sessionId": s.id, "window": s.id, "aiThread": s.owner })+ json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": active, "errorCount": errs, "fav": t.fav, "opener": "agent", "openerTabId": Value::Null, "sessionId": s.id, "window": s.id, "aiThread": s.owner,+ // 2.0.458: true only for the tabs pup keeps focus-emulated (the active one, plus the one a verb is+ // driving). A tab WITHOUT it is throttled and discardable by Chrome, which is what keeps a many-tab+ // pup window off the user's CPU and out of their RAM.+ "focusEmulated": crate::focus_emulated(ctx, &s.id, &t.tab_id) }) } pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ if crate::transport::is_ext(&s) {+ let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();+ // 2.0.537: the same URL-dedup rule the CDP lane has had since 2.0.478. A recovery relaunch that raced a+ // caller's opens re-added a saved tab list on top of them (ti.com twice; 33 tabs for 20 sites, John,+ // 2026-09-13). A second open of a URL the window already holds activates that tab instead.+ if url != "about:blank" {+ if let Some(t) = s.tabs.iter().find(|t| t.url.trim_end_matches('/') == url.trim_end_matches('/')).cloned() {+ let _ = crate::transport::activate(&lane, &t);+ ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });+ return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tab": tab_row(ctx, &s, &t, true), "tabId": t.tab_id, "count": s.tabs.len(), "transport": "ext", "reused": true, "_hint": "This window already had a tab at that URL; it was activated instead of opening a duplicate." }));+ }+ }+ let mut sess = s.clone();+ let t = match crate::transport::open_tab(&lane, &mut sess, &url) { Ok(t) => t, Err(e) => return e };+ sess.tabs.push(t.clone()); sess.active_tab = sess.tabs.len() - 1;+ let _ = crate::transport::activate(&lane, &t);+ ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);+ crate::identity::overlay(ctx, &sess.id, caller);+ touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));+ return ok(json!({ "sessionId": sess.id, "window": sess.id, "aiThread": sess.owner, "tab": tab_row(ctx, &sess, &t, true), "tabId": t.tab_id, "count": sess.tabs.len(), "transport": "ext" }));+ } 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 url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();- let target = match b.cdp.create_target(&url, false, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };+ let target = 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 sess = s.clone(); let tab_id = match attach_tab(&mut sess, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };+ // 2.0.467: the new tab is tracked the moment it is attached, BEFORE the activate and the load wait (up to+ // 15 s). Until now the session was written back only at the end, so the events pump's tab adoption (which+ // looks 400 ms after Target.targetCreated) saw pup's own new target as "opened outside pup" and attached it+ // a second time: after a crash recovery or pup_restart_browser a 3-tab window came back with 6 entries,+ // half of them duplicates on the same targets (ConfRoomROG and arav-rog soaks, 2026-09-11).+ ctx.sessions.insert(sess.clone()); if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); } // owner chip + floaty ride every new document from here on let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": target }), Duration::from_secs(5)); if url != "about:blank" { if let Some(t) = sess.tabs.last_mut() { let rx = b.cdp.subscribe(Some(&t.cdp_session), Some("Page.loadEventFired")); let _ = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(15)); refresh_tab(&b.cdp, t); } }@@ -26,15 +58,16 @@ pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let count = sess.tabs.len(); sess.active_tab = sess.tabs.len().saturating_sub(1); // Target.activateTarget above put the new tab in front; the badge follows it ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);+ crate::focus_emulate(ctx, &sess.id, None); // 2.0.458: the new tab is the active one now touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str())); crate::identity::overlay(ctx, &sess.id, caller); if let Some(a) = ctx.arc() { crate::identity::overlay_retries(a, sess.id.clone(), caller.clone()); }- pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "renderCheck": sess.tabs.last().map(|t| crate::render_check(&b.cdp, &t.cdp_session, None)).unwrap_or(Value::Null), "_verifyRender": crate::VERIFY_RENDER, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] }))+ pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "memory": crate::memory_report(ctx, Some(&sess.profile)), "renderCheck": sess.tabs.last().map(|t| crate::render_check(&b.cdp, &t.cdp_session, None)).unwrap_or(Value::Null), "_verifyRender": crate::VERIFY_RENDER, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] })) } pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };- 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()); }+ if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::refresh_all(&lane, &mut s); ctx.sessions.with_mut(&s.id, |x| { x.tabs = s.tabs.clone(); x.active_tab = s.active_tab; }); } let active = s.active().map(|t| t.tab_id.clone()); let rows: Vec<Value> = s.tabs.iter().map(|t| tab_row(ctx, &s, t, Some(&t.tab_id) == active.as_ref())).collect(); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update() }))@@ -44,9 +77,18 @@ pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e }; let Some(tab_id) = args.get("tabId").and_then(|t| t.as_str()) else { return err("tab_id_required", "pup_switch_tab needs {tabId}", Some("pup_list_tabs shows the ids.")) }; let Some(t) = s.tab_by_id(tab_id).cloned() else { return err("tab_not_found", format!("no tab \"{tab_id}\" in window \"{}\"", s.id), Some("pup_list_tabs shows the ids.")) };+ if crate::transport::is_ext(&s) {+ let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ let activated = crate::transport::activate(&lane, &t).is_ok();+ ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });+ if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+ crate::identity::overlay(ctx, &s.id, caller);+ return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "activated": activated, "transport": "ext" }));+ } 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 activated = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)).is_ok(); ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });+ crate::focus_emulate(ctx, &s.id, None); touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str())); let mut t2 = t.clone(); refresh_tab(&b.cdp, &mut t2); crate::identity::overlay(ctx, &s.id, caller); // Node: the badge follows the ACTIVE tab@@ -57,11 +99,12 @@ pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e }; let t = match args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()) { Some(t) => t, None => return err("tab_not_found", "no such tab", None) }; if s.tabs.len() <= 1 { return crate::close_window(ctx, caller, &json!({ "window": s.id })); }- if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.close_target(&t.target_id); }+ if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::close_tab(&lane, &t); } let remaining = ctx.sessions.with_mut(&s.id, |x| { x.tabs.retain(|tt| tt.tab_id != t.tab_id); if x.active_tab >= x.tabs.len() { x.active_tab = x.tabs.len().saturating_sub(1); } x.tabs.len() }).unwrap_or(0); ctx.tabstate.lock().unwrap().remove(&t.cdp_session); let active = ctx.sessions.get(&s.id).and_then(|x| x.active().map(|tt| tt.tab_id.clone())); if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+ crate::focus_emulate(ctx, &s.id, None); // 2.0.458: a different tab is active now crate::identity::overlay(ctx, &s.id, caller); // the active tab changed ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "closedTab": t.tab_id, "remaining": remaining, "activeTabId": active })) }
rust/verbs/src/transport.rsadded+260@@ -0,0 +1,260 @@+//! ONE seam between a pup window and the thing that drives it. A window is a Session either way; only+//! the driving differs: CDP over a DevTools port, or the extension channel with no port at all (the lane+//! vendor sites cannot fingerprint). Every verb reaches the page through here, so identity, overlay,+//! progress, park, recovery and listing never need to know which transport a window uses.+//!+//! 2.0.525 (John, 2026-09-13): "i honestly still don't get why you made the non-cdp lane be like a whole new+//! set of code". It was outside the session model; this module is what puts it back inside.+use crate::{browser_for, BrowserHandle, Ctx};+use pup_core::err;+use pup_session::{Session, Tab};+use serde_json::{json, Value};+use std::sync::Arc;+use std::time::Duration;++pub enum Lane { Cdp(Arc<BrowserHandle>), Ext }++pub fn is_ext(s: &Session) -> bool { s.transport == "ext" }++pub fn lane_for(ctx: &Ctx, s: &Session) -> Result<Lane, Value> {+ if is_ext(s) {+ if crate::extdrive::alive(ctx) { Ok(Lane::Ext) } else { Err(err("browser_gone", "the extension-driven browser behind this window is not running", Some("pup_close_window then pup_open_window."))) }+ } else {+ browser_for(ctx, &s.profile).map(Lane::Cdp).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 reconnects automatically.")))+ }+}++/// An ext tab's `target_id` is Chrome's numeric tab id; its `cdp_session` is a synthetic unique key so+/// per-tab state maps (tabstate, focus emulation) keep working without a CDP session.+pub fn ext_tab(s: &mut Session, chrome_tab_id: i64, url: &str, title: &str) -> Tab {+ Tab { tab_id: s.next_tab_id(), target_id: chrome_tab_id.to_string(), cdp_session: format!("ext:{chrome_tab_id}"), url: url.into(), title: title.into(), error_count: 0, fav: None }+}+pub(crate) fn ext_id(t: &Tab) -> Value { t.target_id.parse::<i64>().map(Value::from).unwrap_or(Value::Null) }+fn run(cmd: Value, secs: u64) -> Result<Value, Value> { crate::extdrive::run(cmd, Duration::from_secs(secs)) }++pub fn navigate(lane: &Lane, t: &Tab, url: &str) -> Result<(String, String, bool), Value> {+ match lane {+ Lane::Cdp(b) => match b.cdp.navigate(&t.cdp_session, url, Duration::from_secs(25)) {+ Ok((u, loaded)) => { let mut tt = t.clone(); crate::refresh_tab(&b.cdp, &mut tt); Ok((if tt.url.is_empty() { u } else { tt.url }, tt.title, loaded)) }+ Err(e) => Err(err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL)."))),+ },+ Lane::Ext => {+ let r = run(json!({ "action": "navigate", "url": url, "tabId": ext_id(t) }), 60)?;+ Ok((r["url"].as_str().unwrap_or(url).to_string(), r["title"].as_str().unwrap_or("").to_string(), true))+ }+ }+}++pub fn evaluate(lane: &Lane, t: &Tab, expr: &str) -> Result<Value, Value> {+ match lane {+ Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None)),+ Lane::Ext => {+ let r = run(json!({ "action": "eval", "expr": expr, "tabId": ext_id(t) }), 25)?; // 25 s, not 45: identity's badge eval runs on every open and was the step that blew ab's 60 s when a worker restarted+ let res = &r["result"];+ if let Some(m) = res.as_str().and_then(|x| x.strip_prefix("ERR:")) {+ // 2.0.578: a strict Content-Security-Policy (wiki.adom.inc, claude.ai, Google Docs) refuses the+ // injected eval; Runtime.evaluate through the DevTools driver ignores page CSP, as the CDP lane does.+ // 2.0.591 (pup-bridge#116): Trusted Types refuse with "violates this document's Trusted Type assignment+ // requirements", which the CSP wording never matched, so Google Calendar and Admin got no fallback.+ if m.contains("Content Security Policy") || m.contains("unsafe-eval") || m.contains("Trusted Type") || m.contains("Evaluating a string as JavaScript") {+ return crate::extdrive::ext_handle().cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None));+ }+ return Err(err("eval_failed", format!("pup_eval could not run your code: {m}"), None));+ }+ // Same shape as the CDP lane's Runtime.evaluate: strings stay strings, objects come back as values.+ if let Some(sv) = res["s"].as_str() { return Ok(Value::String(sv.to_string())); }+ if res["u"] == json!(true) { return Ok(Value::Null); }+ if let Some(jv) = res["j"].as_str() { return Ok(serde_json::from_str::<Value>(jv).unwrap_or_else(|_| Value::String(jv.to_string()))); }+ // an older worker (untyped): keep the previous behaviour+ let raw = res.as_str().unwrap_or("");+ Ok(serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string())))+ }+ }+}++pub fn screenshot_png(lane: &Lane, t: &Tab, full: bool) -> Result<Vec<u8>, Value> {+ match lane {+ Lane::Cdp(b) => b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(20)).map_err(|e| err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState."))),+ Lane::Ext => {+ let r = run(json!({ "action": "screenshot", "tabId": ext_id(t) }), 60)?;+ if let Some(v) = r["via"].as_str() { if v.starts_with("debugger") { pup_core::log(format!("[screenshot] tab {} captured via {v}", t.tab_id)); } }+ let b64 = r["dataUrl"].as_str().and_then(|d| d.split(',').nth(1)).unwrap_or("");+ pup_cdp::base64_decode(b64).map_err(|e| err("screenshot_decode_failed", e.to_string(), None))+ }+ }+}++/// Open a tab in the window. Ext: in the SAME Chrome window (windowId) the session was born with.+pub fn open_tab(lane: &Lane, s: &mut Session, url: &str) -> Result<Tab, Value> {+ match lane {+ Lane::Cdp(b) => {+ let target = b.cdp.create_target(url, false, None, None).map_err(|e| err("cdp_create_target_failed", e.to_string(), None))?;+ let tab_id = crate::attach_tab(s, &b.cdp, &target)?;+ Ok(s.tab_by_id(&tab_id).cloned().expect("attached tab"))+ }+ Lane::Ext => {+ // 20 s: a slow site (nordicsemi behind Cloudflare) must return BEFORE ab's ~60 s relay gives up, or the+ // tab opens in Chrome while the caller sees "Request timed out" and pup never records it. The title+ // fills in on the next listing; refresh_all also adopts any tab pup missed.+ let r = run(json!({ "action": "newtab", "url": url, "windowId": s.window_id, "waitMs": 20000 }), 40)?;+ let id = r["tabId"].as_i64().ok_or_else(|| err("ext_no_tab", "the extension opened no tab", None))?;+ Ok(ext_tab(s, id, r["url"].as_str().unwrap_or(url), r["title"].as_str().unwrap_or("")))+ }+ }+}++pub fn activate(lane: &Lane, t: &Tab) -> Result<(), Value> {+ match lane {+ Lane::Cdp(b) => { let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)); Ok(()) }+ Lane::Ext => run(json!({ "action": "activate", "tabId": ext_id(t) }), 20).map(|_| ()),+ }+}++/// Bring a tab's url/title up to date. Ext: one `tabs` listing serves every tab of the window.+pub fn refresh_all(lane: &Lane, s: &mut Session) {+ match lane {+ Lane::Cdp(b) => { for t in s.tabs.iter_mut() { crate::refresh_tab(&b.cdp, t); } }+ Lane::Ext => {+ if let Ok(r) = run(json!({ "action": "tabs" }), 25) {+ let live = r["tabs"].as_array().cloned().unwrap_or_default();+ for t in s.tabs.iter_mut() {+ if let Some(l) = live.iter().find(|l| l["tabId"].as_i64().map(|i| i.to_string()) == Some(t.target_id.clone())) {+ if let Some(u) = l["url"].as_str() { t.url = u.into(); }+ if let Some(ti) = l["title"].as_str() { t.title = ti.into(); }+ }+ }+ // adopt tabs Chrome has in THIS session's window that pup does not track (a tab whose open+ // outlived the CLI relay, or one the user opened by hand), in Chrome's order+ if let Some(wid) = s.window_id {+ let known: Vec<String> = s.tabs.iter().map(|t| t.target_id.clone()).collect();+ let mut adopted = 0;+ for l in live.iter().filter(|l| l["windowId"].as_i64() == Some(wid)) {+ if let Some(id) = l["tabId"].as_i64() {+ if !known.contains(&id.to_string()) {+ let t = ext_tab(s, id, l["url"].as_str().unwrap_or(""), l["title"].as_str().unwrap_or(""));+ s.tabs.push(t); adopted += 1;+ }+ }+ }+ if adopted > 0 { pup_core::log(format!("[lane] \"{}\": adopted {adopted} tab(s) Chrome had in its window that pup did not track", s.id)); }+ }+ if let Some(i) = live.iter().find(|l| l["active"] == json!(true) && l["windowId"].as_i64() == s.window_id).and_then(|l| l["tabId"].as_i64()) {+ if let Some(pos) = s.tabs.iter().position(|t| t.target_id == i.to_string()) { s.active_tab = pos; }+ }+ }+ }+ }+}++pub fn close_tab(lane: &Lane, t: &Tab) {+ match lane {+ Lane::Cdp(b) => { let _ = b.cdp.close_target(&t.target_id); }+ Lane::Ext => { let _ = run(json!({ "action": "close", "tabId": ext_id(t) }), 20); }+ }+}++/// Close the OS window behind a session. Ext: only that Chrome window; the lane browser stays up for+/// other sessions and is reaped when it has none (the same rule the CDP browsers follow).+/// true when the lane confirmed the close; false when it could not even be asked (no window id) or did not answer.+pub fn close_window(lane: &Lane, s: &Session) -> bool {+ match lane {+ Lane::Cdp(b) => { let mut any = false; for t in &s.tabs { if b.cdp.close_target(&t.target_id).is_ok() { any = true; } } any || s.tabs.is_empty() }+ Lane::Ext => match s.window_id { Some(w) => run(json!({ "action": "close", "windowId": w }), 20).is_ok(), 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> {+ match lane {+ Lane::Cdp(_) => Err(err("internal", "transport::download is the extension arm; CDP downloads run in extras::download", None)),+ Lane::Ext => run(json!({ "action": "download", "url": url, "filename": save_as, "waitMs": wait_ms }), wait_ms / 1000 + 15),+ }+}++/// Delete cookies for a domain (and its subdomains), HttpOnly included, on either lane. `names` narrows it.+pub fn clear_cookies(lane: &Lane, domain: &str, names: &[String]) -> Result<u64, Value> {+ let dom = domain.trim_start_matches('.').to_lowercase();+ match lane {+ Lane::Ext => run(json!({ "action": "clearCookies", "domain": dom, "names": names }), 20).map(|r| r["removed"].as_u64().unwrap_or(0)),+ Lane::Cdp(b) => {+ let all = b.cdp.send(None, "Network.getAllCookies", json!({}), Duration::from_secs(10)).map_err(|e| err("cookies_failed", e.to_string(), None))?;+ let mut n = 0u64;+ for c in all["cookies"].as_array().cloned().unwrap_or_default() {+ let cd = c["domain"].as_str().unwrap_or("").trim_start_matches('.').to_lowercase();+ let name = c["name"].as_str().unwrap_or("").to_string();+ if (dom.is_empty() || cd == dom || cd.ends_with(&format!(".{dom}"))) && (names.is_empty() || names.contains(&name)) {+ if b.cdp.send(None, "Network.deleteCookies", json!({ "name": name, "domain": c["domain"], "path": c["path"] }), Duration::from_secs(5)).is_ok() { n += 1; }+ }+ }+ Ok(n)+ }+ }+}++/// Clear everything the jar keeps for these origins (cache, cookies, storage, service workers). Ext only; the CDP+/// lane keeps Network/Storage domain verbs.+pub fn clear_site_data(lane: &Lane, origins: &[String]) -> Result<(), Value> {+ match lane {+ Lane::Ext => run(json!({ "action": "clearSiteData", "origins": origins }), 20).map(|_| ()),+ Lane::Cdp(b) => { for o in origins { let _ = b.cdp.send(None, "Storage.clearDataForOrigin", json!({ "origin": o, "storageTypes": "all" }), Duration::from_secs(10)); } Ok(()) }+ }+}++/// Inject an extension-shipped script FILE into the page's MAIN world. Ext: chrome.scripting files+/// injection, which a page CSP cannot refuse. CDP: Runtime.evaluate of the same file's text (it ignores+/// CSP). `text` is that file's contents: the ONE copy lives in src/extension.+pub fn inject_script(lane: &Lane, t: &Tab, file: &str, text: &str, guard: &str) -> Result<(), Value> {+ match lane {+ Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ if (!{guard}) {{ {text} }} return !!{guard}; }})()"), Duration::from_secs(10)).map(|_| ()).map_err(|e| err("inject_failed", e.to_string(), None)),+ Lane::Ext => run(json!({ "action": "inject", "file": file, "tabId": ext_id(t), "world": "MAIN" }), 20).map(|_| ()),+ }+}+/// window[name](...args) in the page.+pub fn call_window_fn(lane: &Lane, t: &Tab, name: &str, args: &[Value]) -> Result<Value, Value> {+ match lane {+ Lane::Cdp(b) => {+ let a = args.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ");+ b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const f = window[{}]; return (typeof f === 'function') ? (f({a}) ?? null) : null; }})()", serde_json::to_string(name).unwrap()), Duration::from_secs(10)).map_err(|e| err("call_failed", e.to_string(), None))+ }+ Lane::Ext => {+ let r = run(json!({ "action": "call", "fn": name, "args": args, "tabId": ext_id(t) }), 20)?;+ if let Some(e) = r["error"].as_str() { return Err(err("call_failed", e.to_string(), None)); }+ if r["missing"] == json!(true) { return Err(err("call_missing", format!("window.{name} is not defined in the page"), None)); }+ Ok(r["value"].clone())+ }+ }+}+/// window[name] (cleared when asked) and the page's device pixel ratio.+pub fn read_window(lane: &Lane, t: &Tab, name: &str, clear: bool) -> Result<(Value, f64), Value> {+ match lane {+ Lane::Cdp(b) => {+ let n = serde_json::to_string(name).unwrap();+ let v = b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const v = window[{n}]; {} return JSON.stringify({{ value: v === undefined ? null : v, dpr: window.devicePixelRatio || 1 }}); }})()", if clear { format!("window[{n}] = null;") } else { String::new() }), Duration::from_secs(5)).map_err(|e| err("read_failed", e.to_string(), None))?;+ let o: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(v);+ Ok((o["value"].clone(), o["dpr"].as_f64().unwrap_or(1.0)))+ }+ Lane::Ext => { let r = run(json!({ "action": "read", "name": name, "clear": clear, "tabId": ext_id(t) }), 8)?; Ok((r["value"].clone(), r["dpr"].as_f64().unwrap_or(1.0))) }+ }+}+/// A PNG of the viewport, optionally clipped to a CSS-pixel rect. CDP clips in the capture; the+/// extension lane captures the visible tab (device pixels) and the clip is applied in the binary.+pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f64, f64)>, dpr: f64) -> Result<Vec<u8>, Value> {+ match lane {+ Lane::Cdp(b) => {+ let mut p = json!({ "format": "png", "captureBeyondViewport": false });+ if let Some((x, y, w, h)) = clip_css { p["clip"] = json!({ "x": x.max(0.0), "y": y.max(0.0), "width": w.max(1.0), "height": h.max(1.0), "scale": 1 }); }+ let r = b.cdp.send(Some(&t.cdp_session), "Page.captureScreenshot", p, Duration::from_secs(15)).map_err(|e| err("capture_failed", e.to_string(), None))?;+ pup_cdp::base64_decode(r["data"].as_str().unwrap_or("")).map_err(|e| err("capture_failed", e.to_string(), None))+ }+ Lane::Ext => {+ let r = run(json!({ "action": "captureVisible", "tabId": ext_id(t) }), 20)?;+ let b64 = r["dataUrl"].as_str().and_then(|d| d.split("base64,").nth(1)).unwrap_or("");+ let bytes = pup_cdp::base64_decode(b64).map_err(|e| err("capture_failed", e.to_string(), None))?;+ match clip_css {+ None => Ok(bytes),+ Some((x, y, w, h)) => pup_identity::badge::crop_png(&bytes, (x.max(0.0) * dpr).round() as u32, (y.max(0.0) * dpr).round() as u32, ((w.max(1.0) * dpr).round() as u32).max(1), ((h.max(1.0) * dpr).round() as u32).max(1)).ok_or_else(|| err("capture_failed", "could not decode the captured viewport".to_string(), None)),+ }+ }+ }+}
rust/verbs/src/vault.rs+43@@ -53,6 +53,7 @@ pub fn credential_delete(ctx: &Ctx, args: &Value) -> Value { /// pup_login: fill (and optionally submit, reason-gated) the login form in the window's active tab. pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ if let Ok(s0) = crate::resolve(ctx, caller, args) { if crate::transport::is_ext(&s0) { return login_ext(ctx, caller, args, s0); } } let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; let cur_url = t.url.clone(); let live_host = pup_vault::host_of(&cur_url).unwrap_or_else(|| "<unknown>".into());@@ -254,3 +255,45 @@ pub fn vault_sync(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { "unionHere": total, "_hint": format!("Both desktops now hold the union of their adom-you logins ({total} here). Newest-wins settled any conflicts. Run pup_vault_sync again anytime a box has learned new logins.") }).to_string() })) }+++/// pup_login on the extension lane: the same locate / fill / submit scripts, evaluated through the transport.+/// realKeystrokes needs CDP trusted input and is refused here with the lane:"cdp" hint.+fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value {+ let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+ let t = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id).cloned()).or_else(|| s.active().cloned()) { Some(t) => t, None => return err("no_tab", "the window has no tabs", None) };+ let cur_url = crate::transport::evaluate(&lane, &t, "location.href").ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_else(|| t.url.clone());+ let live_host = pup_vault::host_of(&cur_url).unwrap_or_else(|| "<unknown>".into());+ let (username, password, source): (String, String, String) = match (args["username"].as_str(), args["password"].as_str()) {+ (Some(u), Some(p)) if !p.is_empty() => { if args["save"].as_bool() == Some(true) { let _ = ctx.vault.set(&*ctx.host, &live_host, u, p); } (u.into(), p.into(), "args".into()) }+ _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {+ Some(c) => (c.username, c.password, format!("vault:{}", c.host)),+ None => return err("no_credential", format!("no saved login matches {live_host}"), Some("Pass {username, password} (add save:true to remember it for this host).")),+ },+ };+ let submit = args["submit"].as_bool() == Some(true);+ if submit && args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {+ return pup_core::merge(err("submit_reason_required", "CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS", Some(&format!("You asked to submit a stored login: the page is on {live_host}. Re-call with submitReason:\"<why {live_host} is the intended site>\"."))), json!({ "liveHost": live_host, "url": cur_url }));+ }+ if args["realKeystrokes"].as_bool() == Some(true) || args["keystrokes"].as_bool() == Some(true) {+ return err("lane_needs_cdp", "realKeystrokes needs CDP trusted input", Some("Reopen with pup_open_window {lane:\"cdp\"} for a React-masked login form."));+ }+ if submit { pup_core::log(format!("[cred] pup_login submit to {live_host} by \"{}\" (ext lane) — reason: {}", caller.thread, args["submitReason"].as_str().unwrap_or("").chars().take(120).collect::<String>())); }+ let located = match crate::transport::evaluate(&lane, &t, pup_vault::locate_login_js()) { Ok(v) => v, Err(e) => return e };+ let filled = crate::transport::evaluate(&lane, &t, &pup_vault::fill_login_js(&username, &password)).unwrap_or(Value::Null);+ let (filled_user, filled_pass) = (located["hasUser"] == json!(true) && filled["fu"] == json!(true), filled["fp"] == json!(true));+ let submitted = crate::transport::evaluate(&lane, &t, &pup_vault::submit_login_js(submit)).ok() == Some(json!(true));+ if source.starts_with("vault:") { ctx.vault.touch_used(source.trim_start_matches("vault:")); }+ touch(ctx, &s.id, None);+ if submitted {+ std::thread::sleep(Duration::from_millis(1800));+ if let Some(w) = crate::transport::evaluate(&lane, &t, crate::WALL_JS).ok().filter(|v| v.is_object()) {+ pup_core::log(format!("[login] {live_host}: submitted, now on a {} wall — blocked_on_user", w["kind"].as_str().unwrap_or("?")));+ let url_now = crate::transport::evaluate(&lane, &t, "location.href").ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_else(|| cur_url.clone());+ return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" },+ &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")),+ json!({ "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"], "transport": "ext" }).to_string() }));+ }+ }+ ok(json!({ "success": true, "output": json!({ "ok": true, "url": cur_url, "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "submitted": submitted, "transport": "ext" }) }))+}
rust/verbs/src/window.rs+19@@ -41,6 +41,25 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "_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"] })) } +/// A HUMAN caller (jump-list task, toolbar button) just clicked something that needs the window on+/// their screen: raise it with a short caption, no toast (they initiated it), and mark the session+/// user-active so the park doctrine leaves it alone. Node v2.0.293 osRaiseSessionWindow for USER_CALLERS.+pub fn raise_for_user(ctx: &Ctx, s: &pup_session::Session, caller: &Caller, caption: &str) -> bool {+ let Some(h) = hwnd_of(ctx, s) else { return false };+ if ctx.settings.get("fgCaption", "on") != "off" {+ 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 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(); });+ if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+ applied+}+ pub fn lower(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e }; let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };
tools/gen-assets.py+43−4@@ -7,14 +7,22 @@ import os, sys root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def rel(p, crate): return os.path.relpath(p, os.path.join(root, 'rust', crate, 'src')).replace('\\', '/') # extensions-dirs = [('extension', 'pup-extension'), ('extension-identity', 'pup-identity-extension'), ('extension-identity-anon', 'pup-identity-anon-extension'), ('extension-annotate', 'pup-annotate-extension')]+dirs = [('extension', 'pup-extension'), ('extension-identity', 'pup-identity-extension'), ('extension-identity-anon', 'pup-identity-anon-extension'), ('extension-annotate', 'pup-annotate-extension'), ('extension-nbe', 'pup-nbe-extension')] out = ["//! GENERATED by tools/gen-assets.py: the MV3 extension folders, embedded. Do not edit.", "", "/// (source folder, staged folder name under ~/.adom, files)", "pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &["] for src, dst in dirs: d = os.path.join(root, 'src', src) if not os.path.isdir(d): continue- files = sorted(f for f in os.listdir(d) if os.path.isfile(os.path.join(d, f)))+ # RECURSIVE: nbe keeps its code in src/ and its art in icons/, and a top-level-only listdir+ # silently embedded just its manifest -> Chrome refused the whole extension with "Could not load+ # background script ''" (John caught the dialog, 2026-09-13). Paths stay forward-slashed.+ files = []+ for base, _dirs, names in os.walk(d):+ for f in sorted(names):+ full = os.path.join(base, f)+ files.append((os.path.relpath(full, d).replace(os.sep, '/'), full))+ files.sort() out.append(f' ("{src}", "{dst}", &[')- for f in files: out.append(f' ("{f}", include_bytes!("{rel(os.path.join(d, f), "browser")}")),')+ for name, full in files: out.append(f' ("{name}", include_bytes!("{rel(full, "browser")}")),') out.append(' ]),') out.append('];') open(os.path.join(root, 'rust', 'browser', 'src', 'ext_assets.rs'), 'w').write("\n".join(out) + "\n")@@ -42,7 +50,38 @@ _g = json.load(open(os.path.join(root, 'docs', 'contract', 'pup_describe.json')) _verbs = [v for v in _o['verbs'] if not v['name'].startswith('desktop_')] # The Rust build adds verbs the Node describe golden lacks (2 real, 2 doc-only). Keep them across regen. _have = {v['name'] for v in _verbs}-CATALOG_EXTRA = ['pup_assisted_click', 'pup_import_scan', 'pup_import_browser_logins', 'pup_NATIVE_INPUT_WARNING', 'pup_eval_frame_note']+# pup_list_sessions is a Rust-side ALIAS onto the same caller-scoped implementation as pup_my_windows+# (adom/pup-bridge#108: agents called the legacy name and got not_in_rust_yet with no equivalent named).+# The Node describe golden predates the alias, so synthesise its entry here or it regenerates away.+# 2.0.592 (pup-bridge#116): pup_select, a <select> chooser by value or label, both lanes; synthesised from pup_fill.+if 'pup_select' not in _have:+ _f = next((v for v in _verbs if v['name'] == 'pup_fill'), None)+ if _f:+ _sv = json.loads(json.dumps(_f)); _sv['name'] = 'pup_select'+ _sv['summary'] = 'Choose a <select> option by value or visible label, in the background (fires input+change).'+ _sv['input'] = { 'selector': 'required str', 'value': 'optional str (option value)', 'label': 'optional str (visible text, case-insensitive, substring ok)', 'sessionId': _f['input'].get('sessionId', 'optional str') }+ _sv['example'] = { 'selector': 'select[name=timezone]', 'label': 'Central Time' }+ _sv['hint'] = 'Sets the option through the native setter and fires input+change so framework-bound selects update; works on both lanes (DevTools evaluate, so page CSP and Trusted Types cannot refuse it). For custom dropdowns (Material, React Select) use pup_click on the control, then pup_click the option by text. option_not_found lists the first 30 options.'+ _sv['related'] = ['pup_fill', 'pup_click', 'pup_type']+ _verbs.append(_sv)+if 'pup_list_sessions' not in _have:+ _mw = next((v for v in _verbs if v['name'] == 'pup_my_windows'), None)+ if _mw:+ _al = dict(_mw)+ _al['name'] = 'pup_list_sessions'+ _al['summary'] = 'Alias of pup_my_windows: list the sessions owned by the calling ai-thread. Passive, never launches a browser.'+ _verbs.append(_al)+ _have.add('pup_list_sessions')+# pup_close_all_windows: same #108 class, aliased onto close_window's {all:true} path in 2.0.553.+if 'pup_close_all_windows' not in _have:+ _cw = next((v for v in _verbs if v['name'] == 'pup_close_window'), None)+ if _cw:+ _al2 = dict(_cw)+ _al2['name'] = 'pup_close_all_windows'+ _al2['summary'] = "Close every pup window owned by the calling ai-thread (pup_close_window with all:true). Other threads' windows are never touched."+ _verbs.append(_al2)+ _have.add('pup_close_all_windows')+CATALOG_EXTRA = ['pup_assisted_click', 'pup_import_scan', 'pup_import_browser_logins', 'pup_NATIVE_INPUT_WARNING', 'pup_eval_frame_note', 'pup_vault_sync', 'pup_vault_export', 'pup_vault_import'] _existing = {} try: _cur = json.load(open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json')))
tools/ship.sh+5@@ -190,9 +190,14 @@ PY } echo "== 5. push the source text files to the product page (source is open; public manifest untouched) =="+# 2.0.595 (pup-bridge#117, Kyle): the Rust tree the exe is built from ships with it, every release, so a+# port (host-mac) is written against code that compiles. Everything under rust/ except target/, plus the+# two build tools. 62 files, ~1.5 MB. Cargo.lock included so a checkout builds the same dependency set.+RUST_FILES=$(find rust -path rust/target -prune -o -type f \( -name '*.rs' -o -name 'Cargo.toml' -o -name 'Cargo.lock' -o -name '*.ttf' -o -name '*.png' -o -name '*.svg' -o -name '*.json' -o -name '*.js' -o -name '*.html' -o -name '*.md' \) -print | tr '\n' ' ') adom-wiki repo push "$PAGE" \ --files src/bridge.json src/BRIDGE_VERSION src/*.js src/extension/*.js src/extension/*.json SKILL.md package.json \ skills/*/SKILL.md dev-skills/*/SKILL.md publish-skills/*/SKILL.md \+ $RUST_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py \ -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || { [ "${FORCE_PUSH:-0}" = "1" ] || { echo " PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; } }