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
//! 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)),
        }
    }
}