app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
rust: 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
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}\""));