← Commit history

rust: taskbar identity through ab (tiles rendered by the browser, AUMID register+stamp, jump list, favicon overlay), reconnect learns the browser pid, close waits for Chrome to exit

John Lauer ·fc4af03e2d ·1mo ago ·parent 6da4234
7 files changed +427−9
rust/bridge/src/main.rs+3−1
@@ -41,8 +41,9 @@ fn main() {     let ctx = Arc::new(Ctx {         host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),         sessions: Arc::new(pup_session::Sessions::new(&state_dir.join("pup-sessions"))), browsers: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),-        bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None),+        bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()),     });+    *ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx);      let server = match Server::http(format!("{bind}:{port}")) { Ok(s) => s, Err(e) => { eprintln!("[pup] cannot bind {bind}:{port}: {e}"); std::process::exit(2); } };     pup_core::log(format!("Puppeteer Bridge v{} (rust) running on http://{bind}:{port} (bind={bind}; no firewall prompt)", pup_core::version()));@@ -62,6 +63,7 @@ fn main() {                 else if cands.is_empty() { pup_core::log("[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser"); }                 if c.prefer_cft() || cands.is_empty() { c.installer.ensure_background(c.host.clone()); }             }+            pup_verbs::identity::boot(&c);             pup_verbs::recovery::recover_at_boot(&c);             pup_verbs::recovery::start_health_loop(c.clone());             if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); }
rust/identity/Cargo.toml−1
@@ -7,4 +7,3 @@ description = "Thread tiles, AUMID naming, badge composition and the taskbar-mod  [dependencies] serde_json.workspace = true-png.workspace = true
rust/identity/src/lib.rs+264−3
@@ -1,3 +1,264 @@-//! pup-identity: Thread tiles, AUMID naming, badge composition and the taskbar-mode policy. Pure image code plus AbClient calls; no OS calls.-//!-//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).+//! pup-identity: taskbar identity POLICY and ART, no OS calls. AUMID naming (identical to the Node+//! build so pinned buttons survive the port), URL categories, the thread-tile and overlay-badge+//! canvas scripts (rendered by the browser pup already drives: no font rasterizer, no image+//! library), a PNG-in-ICO packer, the embedded icon set and font, and the jump-list launcher.+//! The verbs crate applies all of it through ab's identity verbs.++use serde_json::json;+use std::path::{Path, PathBuf};++pub const APP_ID: &str = "Adom.Pup";+pub const DISPLAY_NAME: &str = "Adom Pup";+pub const TILE_GEN: &str = "t7";+pub const ICON_GEN: &str = "9";+pub const TITLE_LABEL: &str = "ai-thread";++pub static FONT_WOFF2: &[u8] = include_bytes!("../../../src/extension/fg-700.woff2");+pub static ICONS: &[(&str, &[u8])] = &[+    ("adom-pup.ico", include_bytes!("../../../src/icons/adom-pup.ico")),+    ("pup-cat-default9.ico", include_bytes!("../../../src/icons/pup-cat-default9.ico")),+    ("pup-cat-wiki9.ico", include_bytes!("../../../src/icons/pup-cat-wiki9.ico")),+    ("pup-cat-wiki-in9.ico", include_bytes!("../../../src/icons/pup-cat-wiki-in9.ico")),+    ("pup-cat-web9.ico", include_bytes!("../../../src/icons/pup-cat-web9.ico")),+    ("pup-cat-app9.ico", include_bytes!("../../../src/icons/pup-cat-app9.ico")),+    ("pup-cat-mixed9.ico", include_bytes!("../../../src/icons/pup-cat-mixed9.ico")),+];++/// Write the embedded icons to a folder (skipped when the bytes already match).+pub fn write_icons(dir: &Path) -> std::io::Result<()> {+    std::fs::create_dir_all(dir)?;+    for (name, bytes) in ICONS {+        let p = dir.join(name);+        let same = std::fs::metadata(&p).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);+        if !same { std::fs::write(&p, bytes)?; }+    }+    Ok(())+}++// ── naming (Node: threadSlug / threadAumid / classifyUrl) ──────────────────────────────++pub fn thread_slug(name: &str) -> String {+    let base = name.to_lowercase();+    let mut s = String::new(); let mut last_dash = false;+    for ch in base.chars() { if ch.is_ascii_alphanumeric() { s.push(ch); last_dash = false; } else if !last_dash { s.push('-'); last_dash = true; } }+    let s: String = s.trim_matches('-').chars().take(24).collect();+    let s = if s.is_empty() { "thread".to_string() } else { s };+    format!("{s}-{}", &sha1_hex(name.as_bytes())[..6])+}++/// The tile token folds the tile file's content signature into the AUMID (Windows caches a+/// button's icon per AUMID, so only a virgin AUMID can change art). Same scheme as Node.+pub fn tile_token(ico_path: &Path) -> String {+    match std::fs::metadata(ico_path) {+        Ok(m) => { let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis()).unwrap_or(0); format!("i{}", &sha1_hex(format!("{}-{}", mtime, m.len()).as_bytes())[..6]) }+        Err(_) => String::new(),+    }+}++pub fn thread_aumid(name: &str, token: &str) -> String { format!("{APP_ID}.thread.{}.{TILE_GEN}{token}", thread_slug(name)) }+pub fn session_aumid(session_id: &str) -> String { format!("{APP_ID}.w.{}", session_id.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect::<String>()) }+pub fn category_aumid(cat: &str) -> String { format!("{APP_ID}.cat.{cat}{ICON_GEN}") }++pub fn is_adom_url(u: &str) -> bool { u.contains("wiki.adom.inc") || u.contains("adom.inc/") }++/// wiki | app | web | None (browser-internal pages)+pub fn classify_url(u: &str) -> Option<&'static str> {+    if u.is_empty() { return None; }+    if is_adom_url(u) { return Some("wiki"); }+    let lower = u.to_ascii_lowercase();+    if lower.starts_with("about:") || lower.starts_with("chrome:") || lower.starts_with("edge:") || lower.starts_with("devtools:") || lower.starts_with("file:") { return None; }+    let host = lower.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");+    if host == "localhost" || host == "127.0.0.1" || host == "[::1]" || host.ends_with(".adom.cloud") { return Some("app"); }+    Some("web")+}++pub fn category_icon(cat: Option<&str>, wiki_authed: bool) -> &'static str {+    match cat { Some("wiki") => if wiki_authed { "pup-cat-wiki-in9.ico" } else { "pup-cat-wiki9.ico" }, Some("app") => "pup-cat-app9.ico", Some("web") => "pup-cat-web9.ico", Some("mixed") => "pup-cat-mixed9.ico", _ => "pup-cat-default9.ico" }+}++// ── art, rendered by the browser (Node: renderThreadTileIco / dualBadgeB64 / applyAppOverlay) ──++/// 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 fn tile_js(name: &str) -> String {+    let nm = serde_json::to_string(name).unwrap_or_else(|_| "\"thread\"".into());+    let font = base64_encode(FONT_WOFF2);+    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(/^[ -]+/, '');+      }}+      best = out.filter(Boolean);+    }}+    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');+  }} 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};+  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');+}})()"#)+}++/// A single-image ICO wrapping PNG bytes (Vista+ format; Windows AUMID registry icons accept it).+pub fn png_to_ico(png: &[u8], width: u32, height: u32) -> Vec<u8> {+    let mut out = Vec::with_capacity(png.len() + 22);+    out.extend_from_slice(&[0, 0, 1, 0, 1, 0]);+    out.push(if width >= 256 { 0 } else { width as u8 }); out.push(if height >= 256 { 0 } else { height as u8 });+    out.extend_from_slice(&[0, 0, 1, 0, 32, 0]);+    out.extend_from_slice(&(png.len() as u32).to_le_bytes());+    out.extend_from_slice(&22u32.to_le_bytes());+    out.extend_from_slice(png);+    out+}++pub fn png_size(png: &[u8]) -> Option<(u32, u32)> {+    if png.len() < 24 || &png[..8] != b"\x89PNG\r\n\x1a\n" { return None; }+    Some((u32::from_be_bytes([png[16], png[17], png[18], png[19]]), u32::from_be_bytes([png[20], png[21], png[22], png[23]])))+}++// ── the jump-list launcher (Node: ensurePupJumplistLauncher / pupTask / pupRelaunchCommand) ──++pub const LAUNCHER_VERSION: &str = "rs1";++pub fn launcher_script() -> String {+    format!(r#"# Adom Pup jump-list callback (v{LAUNCHER_VERSION}) — pup talks to itself; the CLI wakes it if asleep.+param([Parameter(Mandatory=$true)][string]$Verb, [string]$B64='')+$ErrorActionPreference='Stop'+$json = if ($B64) {{ [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($B64)) }} else {{ '{{}}' }}+try {{ $a = $json | ConvertFrom-Json }} catch {{ $a = @{{}} }}+try {{ $a | Add-Member -NotePropertyName caller -NotePropertyValue (@{{aiThread='user-taskbar-menu'}}) -Force }} catch {{}}+$body = (@{{ command=$Verb; args=$a }} | ConvertTo-Json -Depth 12 -Compress)+$portFile = Join-Path $env:LOCALAPPDATA 'Adom Bridge\pup-bridge-port.json'+$ok=$false+try {{+  $port = (Get-Content $portFile -Raw -ErrorAction Stop | ConvertFrom-Json).port+  Invoke-RestMethod -Uri ("http://127.0.0.1:{{0}}/command" -f $port) -Method Post -ContentType 'application/json' -Headers @{{'x-adom-caller-thread'='user-taskbar-menu';'x-adom-caller-reason'='jump-list click'}} -Body $body -TimeoutSec 60 | Out-Null+  $ok=$true+}} catch {{ $ok=$false }}+if (-not $ok) {{+  $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\adom-bridge.exe'+  if (-not (Test-Path $cli)) {{ $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\adom-bridge-cli.exe' }}+  if (Test-Path $cli) {{ & $cli $Verb $json | Out-Null }}+}}+"#)+}++pub fn launcher_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-jumplist-call.ps1") }+pub fn port_file_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-bridge-port.json") }++pub fn task(launcher: &Path, verb: &str, args: serde_json::Value, title: &str, description: &str, icon: Option<&Path>) -> serde_json::Value {+    let b64 = base64_encode(args.to_string().as_bytes());+    json!({ "title": title, "description": description, "iconPath": icon.map(|p| p.to_string_lossy().to_string()), "target": "powershell.exe", "args": format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"{}\" {verb} {b64}", launcher.display()) })+}++pub fn relaunch_command(launcher: &Path, session_id: &str) -> String {+    let b64 = base64_encode(json!({ "sessionId": session_id, "foregroundReason": "the user clicked this window's taskbar entry" }).to_string().as_bytes());+    format!("powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \\\"{}\\\" pup_focus_window {b64}", launcher.display())+}++// ── tiny hashing + base64 (no dependencies) ────────────────────────────────────────────++pub fn base64_encode(bytes: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+    for chunk in bytes.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char); out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++pub fn sha1_hex(data: &[u8]) -> String {+    let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];+    let mut msg = data.to_vec(); let bit_len = (data.len() as u64) * 8;+    msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bit_len.to_be_bytes());+    for block in msg.chunks(64) {+        let mut w = [0u32; 80];+        for i in 0..16 { w[i] = u32::from_be_bytes([block[i * 4], block[i * 4 + 1], block[i * 4 + 2], block[i * 4 + 3]]); }+        for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); }+        let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);+        for (i, wi) in w.iter().enumerate() {+            let (f, k) = match i { 0..=19 => ((b & c) | (!b & d), 0x5A827999), 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), _ => (b ^ c ^ d, 0xCA62C1D6) };+            let t = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(*wi);+            e = d; d = c; c = b.rotate_left(30); b = a; a = t;+        }+        h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); h[4] = h[4].wrapping_add(e);+    }+    h.iter().map(|x| format!("{x:08x}")).collect()+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test] fn sha1_known() { assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); }+    #[test] fn b64() { assert_eq!(base64_encode(b"hello"), "aGVsbG8="); assert_eq!(base64_encode(b"hi"), "aGk="); assert_eq!(base64_encode(b""), ""); }+    #[test] fn slugs_match_node() {+        // Node: threadSlug('pup-bridge') = 'pup-bridge-' + sha1('pup-bridge')[..6]+        assert_eq!(thread_slug("pup-bridge"), format!("pup-bridge-{}", &sha1_hex(b"pup-bridge")[..6]));+        assert!(thread_slug("My Thread / x").starts_with("my-thread-x-"));+        assert_eq!(thread_aumid("t", "iabc123"), format!("Adom.Pup.thread.t-{}.t7iabc123", &sha1_hex(b"t")[..6]));+    }+    #[test] fn classify() { assert_eq!(classify_url("https://wiki.adom.inc/adom/x"), Some("wiki")); assert_eq!(classify_url("http://localhost:3000/"), Some("app")); assert_eq!(classify_url("https://x.adom.cloud/proxy/1/"), Some("app")); assert_eq!(classify_url("https://example.com"), Some("web")); assert_eq!(classify_url("about:blank"), None); }+    #[test] fn ico_wraps_png() { let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0,0,0,13, b'I',b'H',b'D',b'R', 0,0,1,0, 0,0,1,0, 8,6,0,0,0]; assert_eq!(png_size(&png), Some((256, 256))); let ico = png_to_ico(&png, 256, 256); assert_eq!(&ico[..4], &[0,0,1,0]); assert_eq!(ico.len(), png.len() + 22); }+}
rust/verbs/Cargo.toml+1
@@ -14,3 +14,4 @@ pup-host = { path = "../host" } pup-cdp = { path = "../cdp" } pup-browser = { path = "../browser" } pup-session = { path = "../session" }+pup-identity = { path = "../identity" }
rust/verbs/src/identity.rsadded+143
@@ -0,0 +1,143 @@+//! Taskbar identity, applied through ab's verbs (Node: stampPupIdentity + registerPupAumid ++//! updateWikiJumplist + applyAppOverlay, ~2,000 lines, now one file). Art (thread tiles, favicon+//! badges) is rendered by the browser pup drives; nothing here touches the registry or Win32.+//!+//! Flow after a window opens (async, off the verb): resolve the mode (tiles | aumid | plain),+//! render the tile if needed, register the AUMID (per thread in tiles mode), stamp the live+//! window with it (+ the Relaunch trio so the jump-list header is branded), attach the jump+//! list, then paint the favicon overlay. Navigation refreshes the overlay only.++use crate::{browser_for, Ctx};+use pup_core::Caller;+use pup_identity as id;+use serde_json::{json, Value};+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 { std::env::var("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|_| ctx.host.home().join("AppData").join("Local")) }+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) }++/// Boot: icons on disk, the jump-list launcher, the port-discovery file, the base AUMID.+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);+    let launcher = id::launcher_path(&lad);+    let _ = std::fs::create_dir_all(launcher.parent().unwrap());+    let script = id::launcher_script();+    if std::fs::read_to_string(&launcher).ok().as_deref() != Some(script.as_str()) { let _ = std::fs::write(&launcher, script); }+    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", json!({ "appId": id::APP_ID, "displayName": id::DISPLAY_NAME, "iconPath": icons_dir(ctx).join("adom-pup.ico").to_string_lossy(), "shortcut": false }), &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}")),+    }+}++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> {+    let dir = tiles_dir(ctx); let _ = std::fs::create_dir_all(&dir);+    let path = dir.join(format!("{}.ico", id::thread_slug(name)));+    if std::fs::metadata(&path).map(|m| m.len() > 100).unwrap_or(false) { return Some(path); }+    let s = ctx.sessions.get(sid)?; let b = browser_for(ctx, &s.profile)?; let t = s.active()?.clone();+    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()?;+    pup_core::log(format!("[thread-tile] rendered tile for \"{name}\" -> {}", path.display()));+    Some(path)+}++fn jumplist_tasks(ctx: &Ctx, sid: &str, url: &str) -> Vec<Value> {+    let launcher = id::launcher_path(&local_app_data(ctx));+    let st = &ctx.settings;+    let mut tasks = Vec::new();+    if st.get("jlViewTasks", "on") != "off" && id::is_adom_url(url) {+        tasks.push(id::task(&launcher, "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(&launcher, "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(&launcher, "pup_annotate", json!({ "sessionId": sid, "tool": "pen" }), "Annotate this page", "Draw on the page; the capture goes to the owning AI thread", None)); }+    if st.get("jlDashboard", "on") != "off" { tasks.push(id::task(&launcher, "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(&launcher, "pup_close", json!({ "all": true, "everyone": true }), "Close ALL Adom Pup windows", "Closes every pup window on this machine", None)); }+    tasks+}++/// Stamp one window's identity (idempotent; safe to call again after a navigation).+pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: 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 cat = id::classify_url(&url);+    let (app_id, display, icon): (String, String, PathBuf) = if m == "tiles" {+        match ensure_tile(ctx, sid, &name) {+            Some(tile) => (id::thread_aumid(&name, &id::tile_token(&tile)), name.clone(), tile),+            None => (id::session_aumid(&s.id), name.clone(), icons_dir(ctx).join(id::category_icon(cat, false))),+        }+    } else {+        let grouped = ctx.settings.get("taskbarGrouping", "split") == "grouped";+        let style_cat = ctx.settings.get("aumidIconStyle", "category") == "category";+        let icon = icons_dir(ctx).join(if style_cat { id::category_icon(cat, false) } else { "adom-pup.ico" });+        if grouped { (id::category_aumid(cat.unwrap_or("default")), format!("Adom Pup {}", cat.unwrap_or("window")), icon) } else { (id::session_aumid(&s.id), format!("{} (pup)", name), icon) }+    };+    if full || s.app_id.as_deref() != Some(app_id.as_str()) {+        if let Err(e) = ctx.ab.call("desktop_register_app_identity", json!({ "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "shortcut": false }), caller, AB_T) { pup_core::log(format!("[identity] \"{sid}\" register {app_id} failed: {e}")); return; }+        let launcher = id::launcher_path(&local_app_data(ctx));+        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(&launcher, 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())); 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}")); }+        }+    }+    overlay(ctx, sid, caller);+}++/// The favicon overlay badge on the taskbar button (Node: applyAppOverlay).+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 };+    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()));+    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}")),+    }+}++/// 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();+    pup_core::ok(json!({ "mode": mode(ctx), "windows": rows, "ok": true, "findings": [], "_hint": "What pup believes each window is stamped with. The read-back audit (ab#147) lands when ab returns appId on window rows." }))+}
rust/verbs/src/lib.rs+13−3
@@ -22,6 +22,7 @@ pub mod keys; pub mod recovery; pub mod window; pub mod settings;+pub mod identity;  /// Per-tab state the event pump maintains (keyed by CDP session id). #[derive(Debug, Clone, Default)]@@ -45,9 +46,11 @@ pub struct Ctx {     pub port: u16,     pub started: Instant,     pub ad_version: Mutex<Option<String>>,+    pub self_ref: Mutex<std::sync::Weak<Ctx>>, }  impl Ctx {+    pub fn arc(&self) -> Option<Arc<Ctx>> { self.self_ref.lock().unwrap().upgrade() }     pub fn new_tabstate() -> events::TabStates { Arc::new(Mutex::new(HashMap::new())) }     pub fn prefer_cft(&self) -> bool { self.settings.get("toolbarExtension", "on") != "off" }     pub fn shots_dir(&self) -> PathBuf { let d = self.bridge_dir.join("screenshots"); let _ = std::fs::create_dir_all(&d); d }@@ -56,7 +59,7 @@ impl Ctx { /// The verbs this build serves natively. pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info",     "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize",-    "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan"];+    "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan", "pup_set_identity", "pup_audit_icons"];  pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     let t0 = Instant::now();@@ -103,6 +106,8 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {         "pup_use" => settings::use_browser(ctx, args),         "pup_quit_idle_browsers" => settings::quit_idle_browsers(ctx),         "pup_rescan" => recovery::rescan(ctx, args),+        "pup_set_identity" => identity::set_identity(ctx, caller, args),+        "pup_audit_icons" => identity::audit(ctx),         v if v.starts_with("pup_") || v.starts_with("desktop_record") || v.starts_with("desktop_recorder") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),         v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),     };@@ -158,7 +163,8 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -             let kind = cdp.version().ok().and_then(|v| v.get("product").and_then(|p| p.as_str()).map(|s| s.to_string())).unwrap_or_default();             let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| kind.contains("Chrome") && (c.kind == "chrome-for-testing" || c.kind == "chrome") || kind.contains("Edg") && c.kind == "edge").unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: PathBuf::new(), build_id: None });             pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));-            let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid: 0, cdp_port: port, candidate: cand, profile_dir, reused: true } });+            let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;+            let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });             events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());             ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());             return Ok(h);@@ -319,6 +325,7 @@ 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); }             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" },@@ -351,6 +358,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if let Some(h) = hwnd { ctx.host.send_to_bottom_no_activate(h); }   // the first cross-origin paint can raise it: re-assert (Node: reassertBottomAfterNav)     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); }     pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));     let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());     let mut resp = ok(json!({@@ -381,6 +389,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(); });             if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }             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); }             ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "_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"] }))         }         Err(e) => err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL).")),@@ -439,7 +448,8 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         if ctx.sessions.for_profile(&s.profile).is_empty() {             if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {                 let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();-                if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } chrome_killed += 1; }+                // give Chrome a moment to exit on its own; only then reap a straggler (never a slow one mid-write)+                if b.launched.pid > 0 { for _ in 0..12 { if !ctx.host.alive(b.launched.pid) { break; } std::thread::sleep(Duration::from_millis(250)); } if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } chrome_killed += 1; }             }         }     }
rust/verbs/src/recovery.rs+3−1
@@ -21,7 +21,9 @@ fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> {     let cdp = Cdp::connect_port(port, Duration::from_secs(8)).ok()?;     let product = cdp.version().ok().and_then(|v| v["product"].as_str().map(|s| s.to_string())).unwrap_or_default();     let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| (product.contains("Edg") && c.kind == "edge") || (!product.contains("Edg") && c.kind != "edge")).unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: Default::default(), build_id: None });-    let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid: 0, cdp_port: port, candidate: cand, profile_dir, reused: true } });+    // learn the browser pid from the browser itself (SystemInfo.getProcessInfo) so a later close can reap it+    let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;+    let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });     events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());     ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());     pup_core::log(format!("Reconnected to Chrome on port {port} for profile \"{profile}\""));