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
Update 21 file(s)
14 files changed
+436−105
CHANGELOG.md+54docs/PLAN-rust-port.md+11−1rust/Cargo.toml+1−1rust/dashboard/src/lib.rs+4−4rust/host-win/src/lib.rs+22rust/host/src/lib.rs+7rust/identity/src/lib.rs+160−62rust/session/src/lib.rs+8−2rust/verbs/src/extras.rs+6−3rust/verbs/src/identity.rs+98−15rust/verbs/src/lib.rs+48−11rust/verbs/src/recovery.rs+4−4rust/verbs/src/settings.rs+7−1rust/verbs/src/tabs.rs+6−1CHANGELOG.md+54@@ -1,5 +1,59 @@ # Changelog — Puppeteer Bridge +## 2.0.437 — 2026-09-06 — badges that load under CSP, tiles rendered at every size, one button born with the tile++Three of John's findings in one release:++- **Favicon badges were generic on every site with a strict CSP.** The in-page loader used a blob: URL for+ the fetched favicon; sites like wiki.adom.inc allow data: images but not blob:, so every paint fell+ through to the generic glyph. Measured on the wiki's SVG favicon: blob: fails, data: loads. The loader+ now goes data: first (giving a size-less SVG an explicit size), blob: as the fallback.+- **"Gross and crunchy" thread tiles.** The Rust port packed ONE 256 px PNG into the tile ICO and let+ Windows scale it to the 24 to 48 px the taskbar draws; Node v2.0.160 had found the taskbar does not even+ render PNG entries at small sizes. The tile is now rendered natively at 16, 20, 24, 32, 40, 48 and 64 px+ (the font engine anti-aliases each size; no scaling) and packed as 32-bit DIB entries, plus the 256 px+ PNG. Layout is solved once at 256 and drawn at each size, so every size wraps the same way.+- **"The icon should come up the first time with the ai-thread name and NEVER swap."** Windows bakes a+ button's icon when it creates the button, and creates it when the window is shown. The newborn is now+ hidden the instant its handle appears (15 ms poll), the thread tile is rendered and the AUMID, identity+ and jump list are stamped on the hidden window, then it is shown once, no-activate, at z-bottom. One+ button, born with the tile; no CfT icon, no category icon, no rebuild. The identity pass that used to+ run 350 ms after birth is gone for a window with a handle.+ Measured on the way (2.0.429 to 2.0.436, candidates only): the window is never hidden (hiding makes+ Chrome queue its first-show activation); the shell's own ITaskbarList::DeleteTab takes the button away+ at birth, the window is parked and navigated, then the identity is stamped and AddTab creates the one+ button, wearing the tile. The attention tint on a background-born button is Windows marking Chrome's+ denied activation of its own new window: it appears whenever the foreground belongs to another app+ (KiCad in front: tinted; a pup window in front: clean, activation allowed and focus handed back). Node's+ births were the same, so Node had it too. It is not a flash pup paints and no pup setting is involved.+- Focus handback (Node v1.9.253): when the user is in a pup window, Windows lets Chrome activate the+ newborn; pup now measures the foreground before creation and hands it straight back (measured:+ foreground unchanged in both cases).+- The 5-minute identity audit no longer re-stamps anything: it logs a mismatch and marks the dashboard red+ (John: "i hate sweeps"). The fix for a mismatch is the birth stamp, not a sweep.+- The favicon badge follows the active tab (pup_open_tab activates and badges the new tab).++## 2.0.428 — 2026-09-06 — overlay badges as Node painted them (candidate only; superseded by 2.0.429)++John: "audit what you're doing with your overlay icons vs what we had pup-bridge node version doing cuz+you don't seem to be consistent with it." Audited side by side and ported:++- Compositor: 48 px canvas downscaled once (Node v1.9.203), 2 px favicon inset, card #202124, the light+ card #f1f3f4 only for a dark AND transparent favicon (v2.0.191), the finished badge scaled to+ `overlaySize` and tucked north-east so the tile text stays clear (v2.0.262). The Rust build had a+ 32 px canvas, a 12% inset, other colours and a light card for any dark favicon.+- No favicon: the Chrome-style grey globe, or the PDF sheet for a .pdf tab. Never the Adom mark+ (v1.9.331 / 2.0.192). The Rust build fell back to ab's built-in Adom badge.+- Rails: in-page fetch; the bridge's own /favicon.ico fetch with a browser UA, accepted only when the+ bytes are an image (bot-wall HTML is not); the last REAL favicon of the origin, from this window or a+ sibling, instead of a downgrade to generic; a 30-minute negative cache per origin; repaints at 1.5 s+ and 4.5 s after a navigation; one re-resolve 30 s after a generic paint.+- Triggers: tab switch and tab close repaint (the badge follows the active tab); any taskbar-family+ setting change re-identifies every window, a badge setting change repaints every badge; a recovered+ window is re-stamped. Identical paints are skipped by a paint signature.+- The dashboard's "how this icon was chosen" is filled in again (kind, format, via, href, painted by),+ per window and per tab.+ ## 2.0.426 — 2026-09-06 — the Node z-order doctrine, restored (no activation at birth, never yank a window the user is using) John: "can u explain to me how you open new pup windows with regards to dealing with background/foreground
docs/PLAN-rust-port.md+11−1@@ -121,7 +121,17 @@ John's words. Written 2026-09-05 against pup 2.0.396; John approved the plan the 2.0.426: the z-order doctrine (Node v1.9.181 / v2.0.26 / v2.0.261) ported: `park.rs` with birth grace, the user-in-use latch (granted raise, observed foreground, recent input; 10 min memory) and bottom-unless-foreground at every re-park; foreground:true opens honoured; the 2.0.425 birth-time- attention clear removed (ab flickers the window to the front for it; Node never activated at birth). Jump lists: the two-right-clicks flash is ab#155+ attention clear removed (ab flickers the window to the front for it; Node never activated at birth).+ 2.0.427: the overlay badge pipeline ported from Node after John's audit (48 px compositor, globe/PDF+ fallback instead of the Adom mark, the favicon rails and retries, tab and settings triggers, paint+ signature, dashboard provenance).+ 2.0.429: the CSP-safe favicon loader (data: before blob:), tiles rendered natively at every ICO size as+ 32-bit DIBs (Node v2.0.160), and the no-flip birth: hide the newborn, stamp thread tile + AUMID + jump+ list while hidden, show once. Identity audit demoted to a detector.+ 2.0.437 (candidate): the final birth order after eight measured candidates: DeleteTab at the handle, park,+ navigate, stamp, AddTab; never hide a newborn (Chrome queues its activation); focus handback when the+ user is in a pup window. The attention tint on a background-born button is Windows marking Chrome's+ denied activation (KiCad in front: tinted; a pup window in front: clean); Node had it too. Jump lists: the two-right-clicks flash is ab#155 (registry-only AUMIDs, no anchoring shortcut); `aumidShortcut` (opt-in) asks ab for the anchor; human verification pending. Filed: ab#152 (kind:exe seed npm), ab#155 (jump-list anchor), ab#157 (forward bridge consent prompts into ah for boxes that block toasts).
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.424"+version = "2.0.437" edition = "2021" license = "MIT"
rust/dashboard/src/lib.rs+4−4@@ -78,25 +78,25 @@ pub fn snapshot(ctx: &Ctx) -> Value { let mut buttons: Vec<Value> = Vec::new(); for s in ctx.sessions.all() { let states = ctx.tabstate.lock().unwrap();- let tabs: Vec<Value> = s.tabs.iter().map(|t| { let st = states.get(&t.cdp_session); json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": s.active().map(|a| a.tab_id == t.tab_id).unwrap_or(false), "crashed": false, "crashReason": Value::Null, "opener": "agent", "errorCount": st.map(|x| x.errors.len()).unwrap_or(0), "fav": Value::Null }) }).collect();+ let tabs: Vec<Value> = s.tabs.iter().map(|t| { let st = states.get(&t.cdp_session); json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "fav": t.fav, "active": s.active().map(|a| a.tab_id == t.tab_id).unwrap_or(false), "crashed": false, "crashReason": Value::Null, "opener": "agent", "errorCount": st.map(|x| x.errors.len()).unwrap_or(0), "fav": Value::Null }) }).collect(); let errors: Vec<Value> = s.tabs.iter().flat_map(|t| states.get(&t.cdp_session).map(|x| x.errors.clone()).unwrap_or_default()).collect(); drop(states); let badge_file = ctx.host.temp_dir().join("pup-badge-debug").join(format!("{}.png", pup_session::sanitize_sid(&s.id))); let painted = badge_file.exists();- let overlay_led = if s.app_id.is_none() { "red" } else if painted { "green" } else { "amber" };+ let overlay_led = s.overlay_led.clone().unwrap_or_else(|| if s.app_id.is_none() { "red".into() } else if painted { "green".into() } else { "amber".into() }); let url = s.active().map(|t| t.url.clone()).unwrap_or_default(); wins.push(json!({ "sessionId": s.id, "profile": s.profile, "owner": s.owner, "ownerSource": if s.owner.is_some() { "caller-identity" } else { Value::Null.as_str().unwrap_or("none") }, "ownerThread": s.owner, "ownerContainer": s.owner_container, "lastThread": s.owner, "foreground": s.foreground, "foregroundReason": s.last_note, "foregroundAt": Value::Null, "foregroundVerb": Value::Null, "background": if s.foreground { "foreground" } else { "backgrounded" }, "highFps": false, "highFpsMinutesLeft": 0, "openedAt": s.created_ms, "uptimeMs": now.saturating_sub(s.created_ms), "tabCount": s.tabs.len(), "tabs": tabs,- "overlay": if painted { "page-favicon" } else { "none" }, "overlayWhy": { "at": if painted { now } else { 0 }, "why": if painted { "painted through ab desktop_taskbar" } else { "not painted yet" } },+ "overlay": s.overlay_desc.clone().unwrap_or_else(|| if painted { "page-favicon".into() } else { "none".into() }), "overlayWhy": s.overlay_why.clone().unwrap_or_else(|| json!({ "at": if painted { now } else { 0 }, "why": if painted { "painted through ab desktop_taskbar" } else { "not painted yet" } })), "_curAppId": s.app_id, "wiki": { "authed": Value::Null, "view": Value::Null, "isWiki": pup_identity::is_adom_url(&url) }, "identityPending": false, "identityPendingMs": 0, "overlayLed": overlay_led, "hwnd": s.hwnd, "emulatedViewport": s.emulated, "lostBrowser": pup_verbs::browser_for(ctx, &s.profile).is_none(), "lastAgentUpdate": s.last_agent_update_ms, "errorCount": errors.len(), "recentErrors": errors.iter().rev().take(5).map(|e| json!({ "type": e["type"], "text": e["text"].as_str().unwrap_or("").chars().take(200).collect::<String>() })).collect::<Vec<_>>(), "flashCount": 0, "url": url, })); let (key, label, base_icon) = match &s.app_id { Some(a) => (format!("aumid:{a}"), s.id.clone(), format!("/dashboard/stampicon?session={}", s.id)), None => (format!("chrome-process:{}", s.profile), format!("Chrome ({})", s.profile), format!("/dashboard/baseicon?profile={}", s.profile)) }; if let Some(b) = buttons.iter_mut().find(|b| b["key"] == json!(key)) { b["windows"].as_array_mut().unwrap().push(json!(s.id)); if b["led"] == json!("green") && overlay_led != "green" { b["led"] = json!(overlay_led); } }- else { buttons.push(json!({ "key": key, "label": label, "kind": "process", "baseIcon": base_icon, "grouping": ctx.settings.get("taskbarGrouping", "split"), "windows": [s.id], "badgeSession": s.id, "badgeAt": now, "badgeVia": "newest-painted", "badgeLed": overlay_led, "led": overlay_led, "identityPending": 0, "ledWhy": match overlay_led { "green" => "every window wears its real page-favicon overlay — dashboard matches the taskbar", "amber" => "stamped, overlay not painted yet (or the page had no favicon)", _ => "a window under this button is not identity-stamped yet" } })); }+ else { buttons.push(json!({ "key": key, "label": label, "kind": "process", "baseIcon": base_icon, "grouping": ctx.settings.get("taskbarGrouping", "split"), "windows": [s.id], "badgeSession": s.id, "badgeAt": now, "badgeVia": "newest-painted", "badgeLed": overlay_led, "led": overlay_led, "identityPending": 0, "ledWhy": match overlay_led.as_str() { "green" => "every window wears its real page-favicon overlay — dashboard matches the taskbar", "amber" => "stamped, overlay not painted yet (or the page had no favicon)", _ => "a window under this button is not identity-stamped yet" } })); } } wins.sort_by(|a, b| b["openedAt"].as_u64().cmp(&a["openedAt"].as_u64())); let mut threads: Vec<String> = wins.iter().filter_map(|w| w["owner"].as_str().map(|s| s.to_string())).collect(); threads.sort(); threads.dedup();
rust/host-win/src/lib.rs+22@@ -61,6 +61,28 @@ mod imp { // Node: win32.hasProp fn has_tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { !GetPropW(hwnd as HWND, wide(tag).as_ptr()).is_null() } } // Node: osBottomUnlessForeground's in-process leg (SetWindowPos HWND_BOTTOM, NOACTIVATE)+ fn taskbar_tab(&self, hwnd: i64, present: bool) -> bool {+ // ITaskbarList {56FDF342-FD6D-11d0-958A-006097C9A090}, CLSID TaskbarList {56FDF344-FD6D-11d0-958A-006097C9A090}+ #[repr(C)] struct Guid { d1: u32, d2: u16, d3: u16, d4: [u8; 8] }+ #[repr(C)] struct VTable { qi: usize, add_ref: usize, release: unsafe extern "system" fn(*mut c_void) -> u32, hr_init: unsafe extern "system" fn(*mut c_void) -> i32, add_tab: unsafe extern "system" fn(*mut c_void, HWND) -> i32, delete_tab: unsafe extern "system" fn(*mut c_void, HWND) -> i32 }+ #[repr(C)] struct Obj { vt: *const VTable }+ extern "system" { fn CoInitializeEx(reserved: *mut c_void, coinit: u32) -> i32; fn CoCreateInstance(clsid: *const Guid, outer: *mut c_void, ctx: u32, iid: *const Guid, out: *mut *mut c_void) -> i32; }+ const CLSID: Guid = Guid { d1: 0x56FDF344, d2: 0xFD6D, d3: 0x11d0, d4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90] };+ const IID: Guid = Guid { d1: 0x56FDF342, d2: 0xFD6D, d3: 0x11d0, d4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90] };+ unsafe {+ let _ = CoInitializeEx(std::ptr::null_mut(), 0x2 /* APARTMENTTHREADED */);+ let mut p: *mut c_void = std::ptr::null_mut();+ if CoCreateInstance(&CLSID, std::ptr::null_mut(), 0x1 /* INPROC_SERVER */, &IID, &mut p) < 0 || p.is_null() { return false; }+ let obj = p as *mut Obj; let vt = &*(*obj).vt;+ let ok = (vt.hr_init)(p) >= 0 && (if present { (vt.add_tab)(p, hwnd as HWND) } else { (vt.delete_tab)(p, hwnd as HWND) }) >= 0;+ (vt.release)(p);+ ok+ }+ }+ fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool {+ extern "system" { fn ShowWindow(hwnd: HWND, ncmd: i32) -> i32; }+ unsafe { ShowWindow(hwnd as HWND, if visible { 4 /* SW_SHOWNOACTIVATE */ } else { 0 /* SW_HIDE */ }); true }+ } fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool { unsafe { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0 } }
rust/host/src/lib.rs+7@@ -41,6 +41,13 @@ pub trait HostWindows: Send + Sync { /// ab exposes the same as desktop_set_window_state {state:"bottom"}; this is the in-process /// fallback for the birth moment, when a round-trip would let the window flash on top. fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool;+ /// ShowWindow(SW_HIDE) / ShowWindow(SW_SHOWNOACTIVATE): a hidden window has no taskbar button; showing it+ /// creates the button ONCE with whatever identity the window carries by then.+ fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool { let _ = (hwnd, visible); false }+ /// ITaskbarList::DeleteTab / AddTab: remove or (re)create the taskbar button WITHOUT touching the+ /// window (no hide, so Chrome queues no activation). AddTab creates the button fresh with whatever+ /// AUMID/icon the window carries at that moment.+ fn taskbar_tab(&self, hwnd: i64, present: bool) -> bool { let _ = (hwnd, present); false } /// Move/resize without activation or z-order change. fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool; fn foreground(&self) -> i64;
rust/identity/src/lib.rs+160−62@@ -80,81 +80,179 @@ pub fn category_icon(cat: Option<&str>, wiki_authed: bool) -> &'static str { /// The thread-tile canvas script (verbatim port of the Node build's, v2.0.172 sizing rules): /// resolves to a PNG data URL of a 256x256 teal tile with the name bottom-left.+pub const TILE_SIZES: [u32; 8] = [16, 20, 24, 32, 40, 48, 64, 256];+/// The thread-tile canvas script (Node v2.0.172 sizing rules). Renders the tile at EVERY ICO size+/// natively (the font engine anti-aliases each size; no downscale), returning JSON+/// {"256": <png data url>, "16": <base64 raw RGBA>, ...}. Node v2.0.160: the taskbar does not render+/// PNG-compressed entries at small sizes, so those are packed as 32-bit DIBs by `ico_from_tiles`. 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 sizes = serde_json::to_string(&TILE_SIZES).unwrap(); 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, cv = document.createElement('canvas'); cv.width = box; cv.height = box;- const ctx = cv.getContext('2d');- ctx.fillStyle = '#00b8b1'; ctx.fillRect(0, 0, box, box);- const FLOOR = box * 0.36;- const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.80 : box * 0.92;- 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 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; }};- 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)]); }}- let 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(/^[ -]+/, '');+ const nm = {nm}; const SIZES = {sizes};+ // layout is solved ONCE at 256 (lines + font size as a fraction of the box), then drawn at each size+ const solve = (box) => {{+ const cv = document.createElement('canvas'); cv.width = box; cv.height = box; const ctx = cv.getContext('2d');+ const FLOOR = box * 0.36;+ const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.80 : box * 0.92;+ 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 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) return s; s *= 0.96; }} return s; }};+ 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)]); }}+ let 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); }}- best = out.filter(Boolean);+ return {{ lines: best, frac: bf / box }};+ }};+ const L = solve(256);+ const draw = (box) => {{+ const cv = document.createElement('canvas'); cv.width = box; cv.height = box; const ctx = cv.getContext('2d');+ ctx.fillStyle = '#00b8b1'; ctx.fillRect(0, 0, box, box);+ const bf = L.frac * box;+ ctx.font = '700 ' + bf + "px FGtile,'Segoe UI',sans-serif"; ctx.fillStyle = '#05221f'; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';+ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';+ const lh = bf * 0.95, mx = box * 0.055, baseY = box - box * 0.05 - bf * 0.14;+ L.lines.forEach((ln, i) => ctx.fillText(ln, mx, baseY - (L.lines.length - 1 - i) * lh));+ return cv;+ }};+ const out = {{}};+ for (const s of SIZES) {{+ const cv = draw(s);+ if (s >= 256) {{ out[String(s)] = cv.toDataURL('image/png'); continue; }}+ const d = cv.getContext('2d').getImageData(0, 0, s, s).data; let bin = ''; for (let i = 0; i < d.length; i++) bin += String.fromCharCode(d[i]);+ out[String(s)] = btoa(bin); }}- setF(bf); ctx.fillStyle = '#05221f'; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';- const lh = bf * 0.95, mx = box * 0.055, baseY = box - box * 0.05 - bf * 0.14;- best.forEach((ln, i) => ctx.fillText(ln, mx, baseY - (best.length - 1 - i) * lh));- return cv.toDataURL('image/png');+ return JSON.stringify(out); }} catch (e) {{ return null; }} }})()"#) } -/// The overlay badge: the page's favicon (fetched in-page, so cookies and CORS behave like the-/// page) on a rounded card, composited at `pct` percent of a 32x32 canvas in the top-right corner-/// (Node: applyAppOverlay + overlayScalePct). Resolves to a PNG data URL, or null when the page has-/// no reachable favicon (the caller then paints the generic glyph).-pub fn badge_js(pct: u32) -> String {- format!(r#"(async () => {{- const pct = {pct};+/// 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.+pub fn ico_from_tiles(raw: &[(u32, Vec<u8>)], png256: &[u8]) -> Vec<u8> {+ let mut entries: Vec<(u32, Vec<u8>)> = Vec::new();+ for (s, rgba) in raw {+ let s = *s; let n = (s * s) as usize;+ if rgba.len() < n * 4 { continue; }+ let mut d = Vec::with_capacity(40 + n * 4 + (((s + 31) / 32) * 4 * s) as usize);+ d.extend_from_slice(&40u32.to_le_bytes()); d.extend_from_slice(&(s as i32).to_le_bytes()); d.extend_from_slice(&((s * 2) as i32).to_le_bytes());+ d.extend_from_slice(&1u16.to_le_bytes()); d.extend_from_slice(&32u16.to_le_bytes()); d.extend_from_slice(&[0u8; 24]);+ for y in 0..s { let sy = s - 1 - y; for x in 0..s { let i = ((sy * s + x) * 4) as usize; d.extend_from_slice(&[rgba[i + 2], rgba[i + 1], rgba[i], rgba[i + 3]]); } }+ let and_row = ((s + 31) / 32) * 4; d.extend(std::iter::repeat(0u8).take((and_row * s) as usize));+ entries.push((s, d));+ }+ entries.push((256, png256.to_vec()));+ let n = entries.len() as u16;+ let mut out = Vec::new();+ out.extend_from_slice(&[0, 0, 1, 0]); out.extend_from_slice(&n.to_le_bytes());+ let mut offset = 6 + 16 * entries.len() as u32;+ for (s, d) in &entries {+ out.push(if *s >= 256 { 0 } else { *s as u8 }); out.push(if *s >= 256 { 0 } else { *s as u8 });+ out.extend_from_slice(&[0, 0, 1, 0, 32, 0]);+ out.extend_from_slice(&(d.len() as u32).to_le_bytes()); out.extend_from_slice(&offset.to_le_bytes());+ offset += d.len() as u32;+ }+ for (_, d) in &entries { out.extend_from_slice(d); }+ 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(l.href); }}- cands.push(location.origin + '/favicon.ico');- let img = null;- for (const href of cands) {{- try {{ const r = await fetch(href, {{ credentials: 'include' }}); if (!r.ok) continue; const b = await r.blob(); if (!b.size || !/image|icon/.test(b.type || 'image')) continue;- const url = URL.createObjectURL(b); const im = new Image();- await new Promise((res, rej) => {{ im.onload = res; im.onerror = rej; im.src = url; }});- if (im.naturalWidth > 0 || /svg/.test(b.type)) {{ if (!im.naturalWidth) {{ im.width = 64; im.height = 64; }} img = im; break; }} // SVG favicons report no intrinsic size- }} catch (e) {{}}- }}- if (!img) return null;- const S = 32, cv = document.createElement('canvas'); cv.width = S; cv.height = S; const c = cv.getContext('2d');- const side = Math.max(12, Math.round(S * pct / 100)); const x0 = S - side, y0 = 0;- // dark-dominant favicons get a light card (the GitHub-cat case), else the dark card- const probe = document.createElement('canvas'); probe.width = 8; probe.height = 8; const pc = probe.getContext('2d'); pc.drawImage(img, 0, 0, 8, 8);- const d = pc.getImageData(0, 0, 8, 8).data; let lum = 0, n = 0; for (let i = 0; i < d.length; i += 4) {{ if (d[i + 3] > 40) {{ lum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; n++; }} }}- const dark = n > 0 && (lum / n) < 70;- const r = Math.round(side * 0.22);- c.beginPath(); c.moveTo(x0 + r, y0); c.arcTo(x0 + side, y0, x0 + side, y0 + side, r); c.arcTo(x0 + side, y0 + side, x0, y0 + side, r); c.arcTo(x0, y0 + side, x0, y0, r); c.arcTo(x0, y0, x0 + side, y0, r); c.closePath();- c.fillStyle = dark ? '#e6edf3' : '#161b22'; c.fill();- const pad = Math.round(side * 0.12); c.drawImage(img, x0 + pad, y0 + pad, side - pad * 2, side - pad * 2);- return cv.toDataURL('image/png');-}})()"#)+ 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()) } /// A single-image ICO wrapping PNG bytes (Vista+ format; Windows AUMID registry icons accept it).
rust/session/src/lib.rs+8−2@@ -18,6 +18,7 @@ pub struct Tab { pub url: String, pub title: String, pub error_count: u32,+ pub fav: Option<Value>, } #[derive(Debug, Clone)]@@ -54,6 +55,11 @@ pub struct Session { pub born_at_ms: u64, pub user_active_at_ms: u64, pub raise_granted: bool,+ pub overlay_why: Option<Value>,+ pub overlay_led: Option<String>,+ pub last_paint_sig: Option<String>,+ pub last_real_fav: Option<(String, String)>,+ pub generic_retried_url: Option<String>, pub owner_from_caller: bool, pub disruption_notice: Option<String>, pub fg_watch_until_ms: u64,@@ -66,7 +72,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, 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(), 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 } } 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) }@@ -132,7 +138,7 @@ mod tests { #[test] fn files_roundtrip() { let dir = std::env::temp_dir().join(format!("pup-sess-{}", std::process::id())); let ss = Sessions::new(&dir); let mut s = Session::new("t1", "adom-you", Some("thread"), None, 9222, Some(42));- let tid = s.next_tab_id(); s.tabs.push(Tab { tab_id: tid, target_id: "T".into(), cdp_session: "S".into(), url: "https://x".into(), title: "X".into(), error_count: 0 });+ let tid = s.next_tab_id(); s.tabs.push(Tab { tab_id: tid, target_id: "T".into(), cdp_session: "S".into(), url: "https://x".into(), fav: None, title: "X".into(), error_count: 0 }); ss.insert(s.clone()); ss.save_file(&s); let files = ss.read_files(); assert_eq!(files.len(), 1); assert_eq!(files[0]["sessionId"], json!("t1")); assert_eq!(files[0]["cdpPort"], json!(9222)); assert_eq!(files[0]["url"], json!("https://x")); assert_eq!(ss.active_id().as_deref(), Some("t1")); ss.remove("t1"); assert_eq!(ss.count(), 0); assert!(ss.active_id().is_none());
rust/verbs/src/extras.rs+6−3@@ -99,6 +99,9 @@ pub fn start_profile_janitor(ctx: Arc<Ctx>) { } pub fn start_identity_audit(ctx: Arc<Ctx>) {+ // DETECTOR ONLY (John, 2026-09-06: "i hate sweeps"): every 5 minutes, ask the OS what identity each+ // window carries and compare with what pup stamped. A mismatch is LOGGED and shown red on the+ // dashboard (last_audit.ok=false) so the birth stamp gets fixed; nothing is silently re-stamped. std::thread::Builder::new().name("identity-audit".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(300)); let _t0 = std::time::Instant::now();@@ -108,12 +111,12 @@ pub fn start_identity_audit(ctx: Arc<Ctx>) { let caller = crate::identity::internal_caller(); let Ok(r) = ctx.ab.call("desktop_get_window_identity", json!({ "hwnds": wins.iter().map(|w| w.1).collect::<Vec<_>>() }), &caller, Duration::from_secs(10)) else { continue }; let rows = r["rows"].as_array().or(r["windows"].as_array()).cloned().unwrap_or_default();- let mut repaired = 0;+ let mut mismatched = Vec::new(); for (sid, hwnd, want) in wins { let actual = rows.iter().find(|x| x["hwnd"].as_i64() == Some(hwnd)).and_then(|x| x["appId"].as_str().map(|s| s.to_string()));- if want.is_some() && actual != want { pup_core::log(format!("[identity-audit] \"{sid}\" stamped {:?} but the OS says {:?} — re-stamping", want, actual)); crate::identity::stamp(&ctx, &sid, &caller, true); repaired += 1; }+ if want.is_some() && actual != want { pup_core::log(format!("[identity-audit] \"{sid}\" stamped {:?} but the OS says {:?} — NOT re-stamped (detector only); the birth stamp is the thing to fix", want, actual)); mismatched.push(json!({ "sessionId": sid, "stamped": want, "os": actual })); } }- ctx.last_audit.lock().unwrap().replace(json!({ "at": pup_core::now_ms(), "checked": rows.len(), "repaired": repaired, "ok": repaired == 0 }));+ ctx.last_audit.lock().unwrap().replace(json!({ "at": pup_core::now_ms(), "checked": rows.len(), "repaired": 0, "mismatched": mismatched, "ok": mismatched.is_empty() })); }).ok(); }
rust/verbs/src/identity.rs+98−15@@ -11,6 +11,9 @@ use crate::{browser_for, Ctx}; use pup_core::Caller; use pup_identity as id; use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::Mutex;+use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration;@@ -60,11 +63,12 @@ fn ensure_tile(ctx: &Ctx, sid: &str, name: &str) -> Option<PathBuf> { 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();- let v = b.cdp.evaluate(&t.cdp_session, &id::tile_js(name), Duration::from_secs(12)).ok()?;- let data = v.as_str()?; let b64 = data.split("base64,").nth(1)?;- let png = pup_cdp::base64_decode(b64).ok()?;- let (w, h) = id::png_size(&png).unwrap_or((256, 256));- std::fs::write(&path, id::png_to_ico(&png, w, h)).ok()?;+ let v = b.cdp.evaluate(&t.cdp_session, &id::tile_js(name), Duration::from_secs(15)).ok()?;+ let m: serde_json::Map<String, Value> = serde_json::from_str(v.as_str()?).ok()?;+ let png = pup_cdp::base64_decode(m.get("256")?.as_str()?.split("base64,").nth(1)?).ok()?;+ let mut raw = Vec::new();+ for sz in id::TILE_SIZES { if sz >= 256 { continue; } if let Some(b) = m.get(&sz.to_string()).and_then(|x| x.as_str()) { if let Ok(bytes) = pup_cdp::base64_decode(b) { raw.push((sz, bytes)); } } }+ std::fs::write(&path, id::ico_from_tiles(&raw, &png)).ok()?; pup_core::log(format!("[thread-tile] rendered tile for \"{name}\" -> {}", path.display())); Some(path) }@@ -95,7 +99,8 @@ fn exe_launch() -> impl Fn(&str, bool) -> (String, String) { } /// Stamp one window's identity (idempotent; safe to call again after a navigation).-pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: bool) {+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) { let Some(s) = ctx.sessions.get(sid) else { return }; let Some(hwnd) = s.hwnd else { return }; let m = mode(ctx);@@ -126,27 +131,105 @@ pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: bool) { if let Err(e) = ctx.ab.call("desktop_set_window_jumplist", json!({ "appId": app_id, "hwnd": hwnd, "tasks": tasks, "headerIcon": icon.to_string_lossy(), "headerName": display }), caller, AB_T) { pup_core::log(format!("[jumplist] \"{sid}\": {e}")); } } }- overlay(ctx, sid, caller);+ if with_overlay { overlay(ctx, sid, caller); } } /// The favicon overlay badge on the taskbar button (Node: applyAppOverlay).+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)) }++/// 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> {+ 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()?;+ let mime = if buf.starts_with(b"\x89PNG") { "image/png" } else if buf.starts_with(&[0, 0, 1, 0]) { "image/x-icon" } else if buf.starts_with(b"GIF8") { "image/gif" } else if buf.starts_with(&[0xFF, 0xD8]) { "image/jpeg" } else if buf.len() > 5 && (buf.starts_with(b"<svg") || buf.starts_with(b"<?xml")) { "image/svg+xml" } else { return None };+ 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 overlay badge (Node applyAppOverlay): the active tab's favicon on its contrast card, painted on+/// the window's taskbar button. Rails in order: in-page fetch; the bridge's own /favicon.ico fetch;+/// the last REAL favicon of this origin (this window or a sibling), never downgraded to generic; then+/// the generic globe / PDF sheet. Identical paints are skipped (paint signature); a generic paint gets+/// one re-resolve after 30 s if the URL is unchanged. pub fn overlay(ctx: &Ctx, sid: &str, caller: &Caller) {- if ctx.settings.get("overlayBadges", "on") == "off" { return; } let Some(s) = ctx.sessions.get(sid) else { return }; let (Some(hwnd), Some(t)) = (s.hwnd, s.active().cloned()) else { return };+ if ctx.settings.get("overlayBadges", "on") == "off" {+ 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 pct: u32 = ctx.settings.get("overlaySize", "75").parse().unwrap_or(75);- let badge = b.cdp.evaluate(&t.cdp_session, &id::badge_js(pct), Duration::from_secs(12)).ok().and_then(|v| v.as_str().map(|s| s.to_string())).and_then(|d| d.split("base64,").nth(1).map(|s| s.to_string()));- if let Some(b64) = &badge { if let Ok(png) = pup_cdp::base64_decode(b64) { let dir = ctx.host.temp_dir().join("pup-badge-debug"); let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(format!("{}.png", pup_session::sanitize_sid(sid))), png); } }+ 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) {+ 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()); }+ }+ }+ }+ let mut via = r["via"].as_str().unwrap_or("").to_string();+ if !is_real(&r) {+ if let Some(o) = &origin {+ let reuse = s.last_real_fav.as_ref().filter(|(oo, _)| oo == o).map(|(_, b)| b.clone()).or_else(|| ORIGIN_BADGES.lock().unwrap().as_ref().and_then(|m| m.get(o).cloned()));+ if let Some(b64) = reuse { r["dataUrl"] = json!(format!("data:image/png;base64,{b64}")); r["kind"] = json!("page-favicon"); via = "reused last real favicon (re-fetch missed this cycle — no downgrade to generic)".into(); pup_core::log(format!("[overlay] \"{sid}\" reused last real favicon for {o} instead of the generic globe")); }+ }+ }+ let Some(b64) = r["dataUrl"].as_str().and_then(|d| d.split("base64,").nth(1)).map(|x| x.to_string()) else { pup_core::log(format!("[overlay] \"{sid}\" no badge could be composed")); return };+ let kind = r["kind"].as_str().unwrap_or("generic-web").to_string();+ if kind == "page-favicon" { if let Some(o) = &origin { ORIGIN_BADGES.lock().unwrap().get_or_insert_with(HashMap::new).insert(o.clone(), b64.clone()); ctx.sessions.with_mut(sid, |x| x.last_real_fav = Some((o.clone(), b64.clone()))); } } let name = s.owner.clone().unwrap_or_else(|| s.id.clone());- let tooltip = format!("Adom Pup — {name} · {} tab{}", s.tabs.len(), if s.tabs.len() == 1 { "" } else { "s" });- let overlay = match badge { Some(b64) => json!({ "icon": b64, "tooltip": tooltip }), None => json!({ "badge": "adom", "tooltip": tooltip }) };- match ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": overlay }), caller, AB_T) {- Ok(_) => pup_core::log(format!("[overlay] \"{sid}\" badge painted ({})", if overlay.get("icon").is_some() { "favicon" } else { "generic" })),- Err(e) => pup_core::log(format!("[overlay] \"{sid}\" failed: {e}")),+ let n = s.tabs.len();+ let tooltip = format!("Adom Pup — {name} · {}{n} tab{}{}", if s.wiki_authed == Some(true) { "Adom wiki: SIGNED IN · " } else { "" }, if n == 1 { "" } else { "s" }, if n > 1 { " (icon = active tab)" } else { "" });+ let mut h: u64 = 1469598103934665603; for byte in b64.as_bytes() { h ^= *byte as u64; h = h.wrapping_mul(1099511628211); }+ let sig = format!("{hwnd}|{}|{tooltip}|{h:x}", s.app_id.clone().unwrap_or_default());+ if s.last_paint_sig.as_deref() == Some(sig.as_str()) { return; }+ if let Ok(png) = pup_cdp::base64_decode(&b64) { let dir = ctx.host.temp_dir().join("pup-badge-debug"); let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(format!("{}.png", pup_session::sanitize_sid(sid))), png); }+ let fav = json!({ "kind": kind, "format": r["format"], "mode": "default", "transparent": r["transparent"], "darkDominant": r["darkDominant"], "luminance": r["luminance"], "via": via, "href": r["href"] });+ match ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "overlay": { "icon": b64, "tooltip": tooltip } }), caller, AB_T) {+ Ok(_) => {+ pup_core::log(format!("[overlay] \"{sid}\" badge painted ({kind}, {via})"));+ let led = if kind == "page-favicon" { "green" } else { "amber" };+ ctx.sessions.with_mut(sid, |x| {+ x.last_paint_sig = Some(sig.clone()); x.overlay_led = Some(led.into());+ x.overlay_desc = Some(format!("{kind} via {via}{}", r["href"].as_str().map(|h| format!(" ({h})")).unwrap_or_default()));+ x.overlay_why = Some(json!({ "at": pup_core::now_ms(), "source": kind, "tabId": t.tab_id, "tabPickedBy": "active tab", "fetchedVia": via, "paintedBy": "ab desktop_taskbar (overlay.icon)" }));+ if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { tt.fav = Some(fav.clone()); }+ });+ // Node v2.0.199: generic is not a terminal state — one re-resolve 30 s out if the URL is unchanged+ if kind != "page-favicon" && s.generic_retried_url.as_deref() != Some(t.url.as_str()) {+ ctx.sessions.with_mut(sid, |x| x.generic_retried_url = Some(t.url.clone()));+ if let Some(a) = ctx.arc() { let sid2 = sid.to_string(); let url = t.url.clone(); let c2 = caller.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(30)); if a.sessions.get(&sid2).and_then(|x| x.active().map(|tt| tt.url == url)).unwrap_or(false) { overlay(&a, &sid2, &c2); } }); }+ }+ }+ Err(e) => { pup_core::log(format!("[overlay] \"{sid}\" failed: {e}")); ctx.sessions.with_mut(sid, |x| { x.overlay_led = Some("red".into()); x.overlay_desc = Some(format!("not painted: {e}")); }); } } } +/// Node v1.8.92: after a navigation the favicon often lands late; repaint at 1.5 s and 4.5 s.+pub fn overlay_retries(ctx: Arc<Ctx>, sid: String, caller: Caller) {+ std::thread::spawn(move || { for wait in [1500u64, 3000] { std::thread::sleep(Duration::from_millis(wait)); if ctx.sessions.get(&sid).is_none() { return; } overlay(&ctx, &sid, &caller); } });+}++/// Every window, re-stamped (a mode / icon-style / grouping / jump-list change) or just re-badged+/// (overlaySize / overlayBadges), off the caller's thread. Node re-identifies all windows on a switch.+pub fn reapply_all(ctx: Arc<Ctx>, full: bool) {+ std::thread::spawn(move || { let c = internal_caller(); for s in ctx.sessions.all() { if full { stamp(&ctx, &s.id, &c, true); } else { ctx.sessions.with_mut(&s.id, |x| x.last_paint_sig = None); overlay(&ctx, &s.id, &c); } } });+}+ /// Fire-and-forget identity work after an open or a navigation. pub fn schedule(ctx: Arc<Ctx>, sid: String, caller: Caller, full: bool) { let caller = if caller.thread.is_empty() { internal_caller() } else { caller };
rust/verbs/src/lib.rs+48−11@@ -510,7 +510,7 @@ pub fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, let _ = cdp.send(Some(&sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5)); let _ = cdp.evaluate(&sid, &capture, Duration::from_secs(5)); 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 });+ s.tabs.push(Tab { tab_id: tab_id.clone(), target_id: target_id.into(), cdp_session: sid, url: "about:blank".into(), title: String::new(), error_count: 0, fav: None }); s.active_tab = s.tabs.len() - 1; Ok(tab_id) }@@ -594,7 +594,7 @@ 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>(), "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()), "flash": "none", "flashCount": s.flash_count, "progressBarCount": s.progress_count, "progressBarMs": s.progress_ms,+ "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" } }) } @@ -655,7 +655,8 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let title = s.active().map(|t| t.title.clone()).unwrap_or_default(); let tab_count = s.tabs.len(); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- if let Some(a) = ctx.arc() { identity::schedule(a, sid.clone(), caller.clone(), false); }+ 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+ 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" },@@ -665,38 +666,74 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let (bl, bt, bw, bh) = birth_rect(ctx); 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, bw, bh))) { 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, if caller.thread.is_empty() { None } else { Some(&caller.thread) }, 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 }; // park: on-screen rect via CDP (moves without z-order change), then bottom + tag via the host let mut hwnd: Option<i64> = None;+ // Node v2.0.276: born off-screen so Chrome never activates it, moved on-screen within the 50 ms budget by+ // one local CDP call that changes no z-order. Leaving it off-screen longer (2.0.429 to 2.0.433 candidates)+ // made Chrome fire its activation on the first real paint, which Windows denies and tints. Move NOW. if let Ok((wid, _)) = b.cdp.window_for_target(&target) { let _ = b.cdp.set_window_bounds(wid, json!({ "left": bl, "top": bt, "width": bw, "height": bh })); }- for _ in 0..25 {+ 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; }- std::thread::sleep(Duration::from_millis(80));+ std::thread::sleep(Duration::from_millis(15)); } let mut parked = false; if let Some(h) = hwnd {+ // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name+ // and then it NEVER has to swap icons again." Windows bakes a button's icon when the button is+ // created, and creates it when the window is shown. So: HIDE the newborn at once (its button+ // goes away), render the thread tile + register the AUMID + stamp + jump list on the hidden+ // window, then show it once, no-activate, at z-bottom. One button, born with the tile.+ // John (2026-09-06): "i would expect the icon to come up the first time with the ai-thread name+ // and then it NEVER has to swap icons again." Windows bakes a button's icon when it creates the+ // button. So: take the newborn's button away through the shell's own ITaskbarList::DeleteTab+ // (the window stays visible, so Chrome queues no activation and no attention tint can follow),+ // render the thread tile + register the AUMID + stamp + jump list, then AddTab: the shell creates+ // the button ONCE, already wearing the tile. Hiding the window instead (2.0.429 to 2.0.432+ // candidates) made Chrome fire its first-show activation on the re-show, which Windows denies+ // and tints; measured, so never hide a newborn.+ let untabbed = ctx.host.taskbar_tab(h, false);+ // Node v1.9.253: when the user's foreground window belongs to pup's own Chrome (they are using a+ // pup window), Windows lets Chrome activate the newborn, so creation steals their focus. Measured+ // 2026-09-06 (foreground 1901802 -> the newborn). Hand it straight back before anything else.+ if prev_fg != 0 && prev_fg != h && ctx.host.foreground() == h {+ let _ = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": prev_fg }), caller, Duration::from_secs(4));+ pup_core::log(format!("[window] \"{sid}\" creation took the foreground from {prev_fg}; handed it back ({})", if ctx.host.foreground() == prev_fg { "ok" } else { "still ours" }));+ } s.hwnd = Some(h); ctx.host.tag(h, &s.tag_string());- ctx.host.set_bounds_no_activate(h, bl, bt, bw, bh);+ ctx.sessions.insert(s.clone());+ // Park FIRST (Node: the unconditional launch park), so the newborn never sits on top while we work. parked = ctx.host.send_to_bottom_no_activate(h); let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));- // Node: a background birth leaves the Windows 11 attention tint on the button; clear it now- // (ab's off-screen activate-and-restore, net-zero focus), then the activity signal for this mode.+ activity::mark_icon_applied(ctx, &sid); activity::on_open(ctx, &sid, h, caller);- if let Some(a) = ctx.arc() { activity::start_icon_apply(a, sid.clone()); } } if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); } let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false }; let (auto_login_note, wiki_extra) = match s.tabs.last() { Some(t) => (vault::autofill_on_load(ctx, &s, &t.cdp_session, &url), wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &url, args["wikiView"].as_str(), args["isolated"].as_bool().unwrap_or(false))), None => (None, json!({})) }; ctx.sessions.insert(s.clone()); let _ = park::bottom_unless_foreground(ctx, &sid, true); // Node reassertBottomAfterNav: the first cross-origin paint can raise it; inside the birth grace it is bottomed+ if let Some(h) = hwnd {+ // Measured 2026-09-06: Windows puts the attention flag on the BUTTON that exists when Chrome's+ // first real paint (this navigation) tries to activate the window and is denied. No button existed+ // (DeleteTab at birth), so nothing was flagged. Now stamp the thread identity and let the shell+ // create the one button this window will ever have, wearing the tile.+ 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()));+ } let title = s.active().map(|t| t.title.clone()).unwrap_or_default(); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);- if let Some(a) = ctx.arc() { identity::schedule(a, sid.clone(), caller.clone(), true); }+ if let Some(a) = ctx.arc() { if hwnd.is_none() { identity::schedule(a.clone(), sid.clone(), caller.clone(), true); } identity::overlay_retries(a, sid.clone(), caller.clone()); } // identity was stamped while hidden; the favicon lands late, repaint at 1.5 s and 4.5 s if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) { // Node: a foreground open is a background birth followed by a GRANTED raise (caption + toast). let _ = window::raise(ctx, caller, &json!({ "window": sid, "foregroundReason": args["foregroundReason"] }));@@ -739,7 +776,7 @@ fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { *tt = t.clone(); } x.last_agent_update_ms = pup_core::now_ms(); }); let _ = park::bottom_unless_foreground(ctx, &s.id, true); // never yank a window the user is using (Node v2.0.26) if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }- if let Some(a) = ctx.arc() { identity::schedule(a, s.id.clone(), caller.clone(), false); }+ if let Some(a) = ctx.arc() { identity::schedule(a.clone(), s.id.clone(), caller.clone(), false); identity::overlay_retries(a, s.id.clone(), caller.clone()); } let note = vault::autofill_on_load(ctx, &s, &t.cdp_session, &t.url); let wiki_extra = wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &t.url, args["wikiView"].as_str(), false); let mut r = ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "renderCheck": if loaded { render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }));
rust/verbs/src/recovery.rs+4−4@@ -74,7 +74,7 @@ fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<Strin s.foreground = file["foreground"].as_bool().unwrap_or(false); s.app_id = file["appId"].as_str().map(|a| a.to_string()); 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 });+ 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 let tag = s.tag_string(); let hwnd = file["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h))@@ -129,7 +129,7 @@ pub fn health_tick(ctx: &Ctx) { let mut taken = Vec::new(); for s in ctx.sessions.for_profile(&p) { 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); } 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); } }+ 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); } } } } None => {@@ -166,7 +166,7 @@ pub fn health_tick(ctx: &Ctx) { 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();- 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); } None => relaunch_from_file(ctx, &f) }+ 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) } } else if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, &f); } } }@@ -202,7 +202,7 @@ pub fn rescan(ctx: &Ctx, args: &Value) -> Value { let id = format!("adopted-{}", &tid[..tid.len().min(8)].to_lowercase()); let mut s = Session::new(&id, &profile, None, None, h.launched.cdp_port, None); let tab_id = s.next_tab_id();- s.tabs.push(Tab { tab_id, target_id: tid.clone(), cdp_session: sid, url: url.into(), title: t["title"].as_str().unwrap_or("").into(), error_count: 0 });+ s.tabs.push(Tab { tab_id, target_id: tid.clone(), cdp_session: sid, url: url.into(), title: t["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None }); taken.push(tid); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1; } }
rust/verbs/src/settings.rs+7−1@@ -14,7 +14,7 @@ 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"), ("aumidShortcut", "off", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"),+ ("fgWatch", "on", "on|off"), ("birthSettleMs", "400", "int"), ("aumidShortcut", "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"),@@ -45,6 +45,12 @@ pub fn configure(ctx: &Ctx, args: &Value) -> Value { if k == "taskbarMode" { let (a, t) = match sv.as_str() { "plain" => ("off", "off"), "aumid" => ("on", "off"), _ => ("on", "on") }; let _ = ctx.settings.set("aumidIcons", json!(a)); let _ = ctx.settings.set("threadTiles", json!(t)); } } }+ // live apply (Node: a mode switch re-identifies every window; a badge setting repaints every badge)+ const RESTAMP: [&str; 11] = ["taskbarMode", "aumidIconStyle", "taskbarGrouping", "threadTiles", "aumidIcons", "jumpLists", "jlViewTasks", "jlCloseAll", "jlDashboard", "jlAnnotate", "aumidShortcut"];+ if let Some(a) = ctx.arc() {+ if changed.iter().any(|k| RESTAMP.contains(&k.as_str())) { crate::identity::reapply_all(a, true); }+ else if changed.iter().any(|k| k == "overlayBadges" || k == "overlaySize") { crate::identity::reapply_all(a, false); }+ } let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "platform": if cfg!(windows) { "win32" } else if cfg!(target_os = "macos") { "darwin" } else { "linux" }, "taskbarSupported": cfg!(windows), "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } }); ok(json!({ "success": true, "output": out.to_string(), "settings": out["settings"], "changed": out["changed"], "rejected": out["rejected"] })) }
rust/verbs/src/tabs.rs+6−1@@ -8,7 +8,7 @@ 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, "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 }) } pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {@@ -24,8 +24,11 @@ pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); } let row = sess.tabs.last().map(|t| tab_row(ctx, &sess, t, true)).unwrap_or(Value::Null); 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); 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"] })) } @@ -46,6 +49,7 @@ pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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; } }); 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 pup_core::merge(ok(tab_row(ctx, &s, &t2, true)), json!({ "activated": activated, "_hint": "Tab activated within its window (the window itself was NOT raised)." })) } @@ -58,6 +62,7 @@ pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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::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 })) }