app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
v2.0.739 (insiders): Never a bare Chrome taskbar button during recovery (pup-bridge#143): an OS-level health-tick safet…
f222eda
9d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
//! Taskbar identity, applied through ab's verbs (Node: stampPupIdentity + registerPupAumid +
//! 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::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;
const AB_T: Duration = Duration::from_secs(8);
/// ab refuses anonymous calls; pup's own housekeeping identifies itself as the bridge.
pub fn internal_caller() -> Caller { Caller { thread: "pup-bridge (housekeeping)".into(), reason: "pup taskbar identity housekeeping".into(), ..Default::default() } }
fn local_app_data(ctx: &Ctx) -> PathBuf {
// Windows: %LOCALAPPDATA% (the jump-list relaunch reads the port file there). Elsewhere there is no
// LocalAppData; the same files live in the state dir (~/.adom) instead of a fake AppData tree.
if cfg!(windows) { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) } else { ctx.host.state_dir() }
}
fn icons_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-icons").join(id::ICON_GEN) }
fn tiles_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-thread-tiles").join(id::TILE_GEN) }
/// The Start Menu anchor for the jump list (ab#155, measured by ab on 2026-09-05): a registry-only
/// AUMID makes the first right-click flyout flash; only a VISIBLE Start Menu shortcut carrying the AUMID
/// keeps it up, and there is no invisible anchor (hidden .lnk, hidden folder and SHAddToRecentDocs all
/// measured not to work). ab 2.1.49 makes that anchor opt-in (`anchor:true`, one collapsed Start Menu
/// folder). pup maps its `aumidShortcut` setting onto it: off (default) = clean Start Menu, first
/// right-click flashes; on = first-click flyout plus one "Adom Pup" folder entry per identity.
pub const ANCHOR_FOLDER: &str = "Adom Pup";
fn register_args(ctx: &Ctx, app_id: &str, display: &str, icon: &str) -> Value {
let anchor = ctx.settings.get("aumidShortcut", "off") == "on";
json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": false, "anchor": anchor, "anchorFolder": ANCHOR_FOLDER })
}
pub fn boot(ctx: &Ctx) {
if let Err(e) = id::write_icons(&icons_dir(ctx)) { pup_core::log(format!("[identity] icons not written: {e}")); }
let lad = local_app_data(ctx);
// 2.0.423: jump-list tasks call pup's OWN exe (`--jumplist <verb> <b64>`), no PowerShell script;
// remove the Node-era launcher so nothing on disk still points at powershell.exe.
let _ = std::fs::remove_file(id::launcher_path(&lad));
let _ = std::fs::create_dir_all(id::port_file_path(&lad).parent().unwrap());
let _ = std::fs::write(id::port_file_path(&lad), json!({ "port": ctx.port, "pid": std::process::id(), "version": pup_core::version(), "host": "127.0.0.1", "assignedPort": ctx.port, "shell": "rust" }).to_string());
match ctx.ab.call("desktop_register_app_identity", register_args(ctx, id::APP_ID, id::DISPLAY_NAME, &icons_dir(ctx).join("adom-pup.ico").to_string_lossy()), &internal_caller(), AB_T) {
Ok(_) => pup_core::log("[identity] base AUMID registered (Adom.Pup)"),
Err(e) => pup_core::log(format!("[identity] base AUMID register skipped: {e}")),
}
}
/// 2.0.666: unregister every pup identity no live window holds, and drop tile/icon generations nothing
/// references. Until now only pup_uninstall ever called desktop_unregister_app_identity, so every thread
/// that ever opened a window left its taskbar identity registry key (and, with aumidShortcut on, a Start Menu
/// entry Windows lists as an App): 116 keys on arav-rog by 2026-09-20. Runs a minute after boot (after
/// recovery has re-listed the windows it brings back) and on every window close for that window's identity.
pub fn sweep_dead_identities(ctx: &Ctx, why: &str) -> Value {
let live: std::collections::HashSet<String> = ctx.sessions.all().iter().filter_map(|s| s.app_id.clone()).collect();
let mine = [format!("{}.thread.", id::APP_ID), format!("{}.cat.", id::APP_ID), format!("{}.w.", id::APP_ID)];
let mut removed = Vec::new(); let mut failed = 0usize;
for k in ctx.host.list_aumids(&format!("{}.", id::APP_ID)) {
if live.contains(&k) || !mine.iter().any(|p| k.starts_with(p)) { continue; }
match ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": k, "anchorFolder": ANCHOR_FOLDER }), &internal_caller(), AB_T) { Ok(_) => removed.push(k), Err(_) => failed += 1 }
}
// tile / icon generations: keep the current one and any an active identity still points at
let mut dirs = Vec::new();
for (root, cur) in [("pup-thread-tiles", id::TILE_GEN), ("pup-icons", id::ICON_GEN)] {
let Ok(rd) = std::fs::read_dir(ctx.host.temp_dir().join(root)) else { continue };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name == cur { continue; }
let in_use = live.iter().any(|a| a.contains(&format!(".{name}")) || a.ends_with(&name));
if in_use { continue; }
if std::fs::remove_dir_all(e.path()).is_ok() { dirs.push(format!("{root}/{name}")); }
}
}
// the anchor folder is pup's own: every .lnk in it that no live window's thread or id matches is dead
// (ab's unregister only removes the anchor of the key it is given; keys removed by older builds left theirs)
let anchor_on = ctx.settings.get("aumidShortcut", "off") == "on";
let mut lnks = Vec::new();
if let Ok(appdata) = std::env::var("APPDATA") {
let folder = PathBuf::from(appdata).join("Microsoft").join("Windows").join("Start Menu").join("Programs").join(ANCHOR_FOLDER);
let keep: std::collections::HashSet<String> = ctx.sessions.all().iter().flat_map(|s| vec![s.owner.clone().unwrap_or_default(), s.id.clone()]).collect();
if let Ok(rd) = std::fs::read_dir(&folder) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some(stem) = name.strip_suffix(".lnk") else { continue };
if keep.contains(stem) || (anchor_on && stem == id::DISPLAY_NAME) { continue; }
if std::fs::remove_file(e.path()).is_ok() { lnks.push(stem.to_string()); }
}
if std::fs::read_dir(&folder).map(|mut r| r.next().is_none()).unwrap_or(false) { let _ = std::fs::remove_dir(&folder); }
}
}
if !removed.is_empty() || !dirs.is_empty() || !lnks.is_empty() || failed > 0 { pup_core::log(format!("[identity] sweep ({why}): unregistered {} dead identit{} ({failed} failed), removed {} dead Start Menu shortcut(s), {} stale icon generation(s){}", removed.len(), if removed.len() == 1 { "y" } else { "ies" }, lnks.len(), dirs.len(), if dirs.is_empty() { String::new() } else { format!(": {}", dirs.join(", ")) })); }
json!({ "unregistered": removed.len(), "failed": failed, "shortcutsRemoved": lnks, "iconDirsRemoved": dirs, "live": live.len(), "sample": removed.iter().take(10).collect::<Vec<_>>() })
}
/// Called after a window closed: its identity goes unless another live window shares it (category
/// AUMIDs are shared; thread AUMIDs are per thread and other windows of the thread hold the same one).
pub fn release_identity(ctx: &Ctx, app_id: &str) {
if app_id == id::APP_ID || ctx.sessions.all().iter().any(|s| s.app_id.as_deref() == Some(app_id)) { return; }
let _ = ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": app_id, "anchorFolder": ANCHOR_FOLDER }), &internal_caller(), AB_T);
}
pub fn mode(ctx: &Ctx) -> String {
ctx.settings.all().get("taskbarMode").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| if ctx.settings.get("aumidIcons", "on") != "on" { "plain".into() } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles".into() } else { "aumid".into() })
}
/// 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> {
// 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 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)
}
fn jumplist_tasks(ctx: &Ctx, sid: &str, url: &str) -> Vec<Value> {
let launch = exe_launch();
let st = &ctx.settings;
let mut tasks = Vec::new();
if st.get("jlViewTasks", "on") != "off" && id::is_adom_url(url) {
tasks.push(id::task(&launch, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "authed" }), "Switch to logged-in view", "Reload this Adom wiki page SIGNED IN", Some(&icons_dir(ctx).join("pup-cat-wiki-in9.ico"))));
tasks.push(id::task(&launch, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "public" }), "Switch to public view", "Reload this Adom wiki page logged OUT, exactly what the public sees", Some(&icons_dir(ctx).join("pup-cat-wiki9.ico"))));
}
if st.get("jlAnnotate", "on") != "off" { tasks.push(id::task(&launch, "pup_annotate", json!({ "sessionId": sid, "tool": "pen" }), "Annotate this page", "Draw on the page; the capture goes to the owning AI thread", Some(&icons_dir(ctx).join("adom-pup.ico")))); }
if st.get("jlDashboard", "on") != "off" { tasks.push(id::task(&launch, "pup_dashboard", json!({}), "Open pup dashboard", "Every window pup is driving, live", Some(&icons_dir(ctx).join("adom-pup.ico")))); }
if st.get("jlCloseAll", "on") != "off" { tasks.push(id::task(&launch, "pup_close", json!({ "all": true, "everyone": true }), "Close ALL Adom Pup windows", "Closes every pup window on this machine", Some(&icons_dir(ctx).join("adom-pup.ico")))); }
tasks
}
/// The jump-list / relaunch launcher: pup's own exe in its `--jumplist` mode (`bridge/src/main.rs`),
/// which POSTs the verb to the running bridge over loopback as caller user-taskbar-menu and exits.
/// Until 2.0.422 this was powershell.exe running a .ps1 (Node had no exe to call back into), which
/// put the PowerShell icon on icon-less tasks and cost a PowerShell start per click (John, 2026-09-06).
/// ab's ab-taskrun shim still interposes the target so a console-subsystem exe never paints a window.
fn exe_launch() -> impl Fn(&str, bool) -> (String, String) {
let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| "adom-bridge-puppeteer.exe".into());
// tasks: bare target path + args (ab builds the shell link); relaunch: one command line, path quoted.
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) {
let Some(s) = ctx.sessions.get(sid) else { return };
let Some(hwnd) = s.hwnd else { return };
let m = mode(ctx);
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 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();
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; }
}
if ctx.settings.get("jumpLists", "on") != "off" {
let tasks = jumplist_tasks(ctx, sid, &url);
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}")); }
}
}
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)
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).
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()?;
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)))
}
/// 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
/// 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) {
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 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);
// 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 {
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 {
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 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 };
std::thread::Builder::new().name("identity".into()).spawn(move || { std::thread::sleep(Duration::from_millis(350)); pup_core::log(format!("[identity] \"{sid}\" pass starting (full={full})")); stamp(&ctx, &sid, &caller, full); }).ok();
}
pub fn set_identity(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
stamp(ctx, &s.id, caller, true);
let after = ctx.sessions.get(&s.id);
pup_core::ok(json!({ "sessionId": s.id, "window": s.id, "appId": after.and_then(|x| x.app_id), "mode": mode(ctx), "hwnd": s.hwnd, "_hint": "Identity re-stamped through ab (register + stamp + jump list + overlay)." }))
}
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();
// 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." } }))
}
/// 2.0.739 (pup-bridge#143): the "never a bare Chrome button" safety net.
///
/// After a bridge restart or a worker-reload cycle there is a gap where a pup-profile Chrome-for-Testing
/// window exists but has no session yet: the extension control channel is still re-handshaking, so
/// `adopt_stray_windows` (which drives the extension) cannot run, and the window sits on the taskbar wearing
/// Chrome's stock button with no thread tile. This recurs on John's laptop on every insiders ship.
///
/// This runs on every health tick and works at the OS level, independent of the extension channel, so it
/// closes the gap even while the channel is down. It enumerates pup CfT windows by PID (never the user's own
/// Chrome, never a CDP browser pup drives), and on any that a session does not already own and that is not
/// already carrying a pup AUMID, it stamps a grouped `Adom Pup` identity. That identity upgrades to the real
/// per-thread tile the moment `adopt_stray_windows` re-adopts the window and its own stamp runs. Read + stamp
/// only; it never touches the lane or kill paths that caused the 2.0.723 / 2.0.646 regressions.
pub fn stamp_stray_cft_windows(ctx: &Ctx) {
if !cfg!(windows) { return; }
if mode(ctx) == "plain" { return; } // the user asked for stock icons; respect it
let pup_pids: std::collections::HashSet<u32> = crate::pup_browser_pids_by_profile(ctx).into_values().flatten().collect();
if pup_pids.is_empty() { return; }
let cdp: std::collections::HashSet<u32> = ctx.browsers.lock().unwrap().values().map(|b| b.launched.pid).filter(|p| *p > 0).collect();
let owned: std::collections::HashSet<i64> = ctx.sessions.all().iter().filter_map(|s| s.hwnd).collect();
let caller = internal_caller();
static DONE: Mutex<Option<std::collections::HashSet<i64>>> = Mutex::new(None);
let mut done = DONE.lock().unwrap();
let done = done.get_or_insert_with(Default::default);
let wins: Vec<pup_host::HostWindow> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter()
.filter(|w| pup_pids.contains(&w.pid) && !cdp.contains(&w.pid) && w.title.ends_with("Google Chrome for Testing"))
.collect();
let live: std::collections::HashSet<i64> = wins.iter().map(|w| w.hwnd).collect();
done.retain(|h| live.contains(h)); // forget closed windows
// the generic grouped pup identity (one button for every not-yet-adopted pup window)
let app_id = id::category_aumid("default");
let display = "Adom Pup".to_string();
let icon = icons_dir(ctx).join("adom-pup.ico");
for w in wins {
if owned.contains(&w.hwnd) { continue; } // a session owns it: its own stamp carries the real tile
if done.contains(&w.hwnd) { continue; } // stamped once already; the session upgrade re-stamps later
if w.title.starts_with("PUPBIRTH-") { continue; } // a window being born: its birth stamps it
// already a pup button? read the live AUMID back from Windows before stamping
let cur = ctx.ab.call("desktop_get_window_identity", json!({ "hwnd": w.hwnd }), &caller, AB_T).ok()
.and_then(|r| { 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(|s| s.to_string()) });
if cur.as_deref().map(|a| a.starts_with(id::APP_ID)).unwrap_or(false) { done.insert(w.hwnd); continue; }
if !preregister(ctx, &caller, &app_id, &display, &icon) { continue; }
let launch = exe_launch();
match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": w.hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launch, &format!("stray-{}", w.hwnd)), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), &caller, AB_T) {
Ok(_) => { done.insert(w.hwnd); pup_core::log(format!("[identity] safety net: stamped a pup identity on an unadopted CfT window (hwnd {}, \"{}\") so it never shows a bare Chrome button while recovery catches up", w.hwnd, w.title)); }
Err(e) => pup_core::log(format!("[identity] safety net: could not stamp hwnd {}: {e}", w.hwnd)),
}
}
}