← Commit history

rust: dashboard page + SSE snapshot + image/action routes, /api/status, /identity, embedded MV3 toolbar extensions with --load-extension and pinned prefs, pup_dashboard verb

John Lauer ·e980e826f9 ·1mo ago ·parent fc4af03
11 files changed +402−18
rust/bridge/src/main.rs+21−3
@@ -41,7 +41,7 @@ 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), self_ref: Mutex::new(std::sync::Weak::new()),+        bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()),     });     *ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx); @@ -91,8 +91,26 @@ fn main() {                     let reply = pup_verbs::dispatch(&c, &caller, &verb, &args);                     let _ = req.respond(json_response(200, &reply));                 }-                (Method::Get, "/") | (Method::Get, "/dashboard") => { let _ = req.respond(json_response(200, &json!({ "bridge": "puppeteer", "shell": "rust", "version": pup_core::version(), "_hint": "Phase 1 of the Rust port: the dashboard page lands in Phase 2. /health and /status are live; POST /command drives the verbs." }))); }-                _ => { let _ = req.respond(json_response(404, &json!({ "success": false, "errorCode": "not_found", "path": path }))); }+                (method, p) => {+                    let query = url.split('?').nth(1).unwrap_or("").to_string();+                    let mut req = req;+                    let mut body = String::new();+                    if method == Method::Post { let _ = req.as_reader().read_to_string(&mut body); }+                    let caller = caller_from(&req);+                    match pup_dashboard::route(&c, &caller, method.as_str(), p, &query, &body) {+                        Some(pup_dashboard::Reply::Json(status, v)) => { let _ = req.respond(json_response(status, &v)); }+                        Some(pup_dashboard::Reply::Bytes(status, ct, bytes, cache)) => {+                            let mut r = Response::from_data(bytes).with_status_code(status).with_header(Header::from_bytes("Content-Type", ct).unwrap());+                            if let Some(cc) = cache { r = r.with_header(Header::from_bytes("Cache-Control", cc).unwrap()); }+                            let _ = req.respond(r);+                        }+                        Some(pup_dashboard::Reply::Stream(reader)) => {+                            let r = Response::new(tiny_http::StatusCode(200), vec![Header::from_bytes("Content-Type", "text/event-stream").unwrap(), Header::from_bytes("Cache-Control", "no-cache").unwrap(), Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()], reader, None, None);+                            let _ = req.respond(r);+                        }+                        None => { let _ = req.respond(json_response(404, &json!({ "success": false, "errorCode": "not_found", "path": p }))); }+                    }+                }             }         });     }
rust/browser/src/ext_assets.rsadded+46
@@ -0,0 +1,46 @@+//! GENERATED by tools/gen-assets.py: the MV3 extension folders, embedded. Do not edit.++/// (source folder, staged folder name under ~/.adom, files)+pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[+    ("extension", "pup-extension", &[+        ("content.js", include_bytes!("../../../src/extension/content.js")),+        ("fg-500.woff2", include_bytes!("../../../src/extension/fg-500.woff2")),+        ("fg-700.woff2", include_bytes!("../../../src/extension/fg-700.woff2")),+        ("icon128.png", include_bytes!("../../../src/extension/icon128.png")),+        ("icon16.png", include_bytes!("../../../src/extension/icon16.png")),+        ("icon48.png", include_bytes!("../../../src/extension/icon48.png")),+        ("manifest.json", include_bytes!("../../../src/extension/manifest.json")),+        ("popup.html", include_bytes!("../../../src/extension/popup.html")),+        ("popup.js", include_bytes!("../../../src/extension/popup.js")),+        ("satoshi-400.woff2", include_bytes!("../../../src/extension/satoshi-400.woff2")),+        ("satoshi-500.woff2", include_bytes!("../../../src/extension/satoshi-500.woff2")),+        ("worker.js", include_bytes!("../../../src/extension/worker.js")),+    ]),+    ("extension-identity", "pup-identity-extension", &[+        ("fg-500.woff2", include_bytes!("../../../src/extension-identity/fg-500.woff2")),+        ("fg-700.woff2", include_bytes!("../../../src/extension-identity/fg-700.woff2")),+        ("icon128.png", include_bytes!("../../../src/extension-identity/icon128.png")),+        ("icon16.png", include_bytes!("../../../src/extension-identity/icon16.png")),+        ("icon48.png", include_bytes!("../../../src/extension-identity/icon48.png")),+        ("identity.html", include_bytes!("../../../src/extension-identity/identity.html")),+        ("identity.js", include_bytes!("../../../src/extension-identity/identity.js")),+        ("manifest.json", include_bytes!("../../../src/extension-identity/manifest.json")),+        ("satoshi-400.woff2", include_bytes!("../../../src/extension-identity/satoshi-400.woff2")),+        ("satoshi-500.woff2", include_bytes!("../../../src/extension-identity/satoshi-500.woff2")),+    ]),+    ("extension-identity-anon", "pup-identity-anon-extension", &[+        ("anon.html", include_bytes!("../../../src/extension-identity-anon/anon.html")),+        ("icon128.png", include_bytes!("../../../src/extension-identity-anon/icon128.png")),+        ("icon16.png", include_bytes!("../../../src/extension-identity-anon/icon16.png")),+        ("icon48.png", include_bytes!("../../../src/extension-identity-anon/icon48.png")),+        ("manifest.json", include_bytes!("../../../src/extension-identity-anon/manifest.json")),+    ]),+    ("extension-annotate", "pup-annotate-extension", &[+        ("go.html", include_bytes!("../../../src/extension-annotate/go.html")),+        ("go.js", include_bytes!("../../../src/extension-annotate/go.js")),+        ("icon128.png", include_bytes!("../../../src/extension-annotate/icon128.png")),+        ("icon16.png", include_bytes!("../../../src/extension-annotate/icon16.png")),+        ("icon48.png", include_bytes!("../../../src/extension-annotate/icon48.png")),+        ("manifest.json", include_bytes!("../../../src/extension-annotate/manifest.json")),+    ]),+];
rust/browser/src/lib.rs+50−7
@@ -12,6 +12,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; +pub mod ext_assets;++/// Chrome extension ids (deterministic from each manifest's `key`, so staging path does not matter).+pub const EXT_IDS: &[(&str, &str)] = &[("pup-extension", "liomcaladmooagjiebkfgegciadapnpi"), ("pup-annotate-extension", "ghadnmahlldiigemdbekmhcbfcpkmclg"), ("pup-identity-extension", "mngfopllhdfgomjphhejmcdimcohcgnf"), ("pup-identity-anon-extension", "mhoenaodccbppcinfbaifamkgfhbakbe")];+ /// The Chrome for Testing build pup drives. Same build id the Node build pinned through /// puppeteer 24.43, so both builds share one cache folder. Bump deliberately, with a ship note. pub const CFT_BUILD_ID: &str = "148.0.7778.97";@@ -344,10 +349,32 @@ fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<Instal  // ── profile + launch (Node: prepareCleanProfile + getOrLaunchBrowser) ────────────────── +/// Stage the embedded MV3 extensions under ~/.adom/<staged name> (Node: _stageExtDir) and write+/// the toolbar's port.json so the popup finds the live bridge. Returns the dirs to --load-extension,+/// in the order the toolbar shows them: annotate pen, toolbar, identity (adom-you) or anonymous tile.+pub fn stage_extensions(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool) -> Vec<PathBuf> {+    if !toolbar_on { return Vec::new(); }+    let root = host.state_dir();+    let mut staged: std::collections::HashMap<&str, PathBuf> = std::collections::HashMap::new();+    for (src, dst, files) in ext_assets::EXTENSIONS {+        let dir = root.join(dst);+        if fs::create_dir_all(&dir).is_err() { continue; }+        for (name, bytes) in *files {+            let p = dir.join(name);+            let same = fs::metadata(&p).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);+            if !same { let _ = fs::write(&p, bytes); }+        }+        if *src == "extension" { let _ = fs::write(dir.join("port.json"), format!("{{\"port\":{port},\"version\":\"{}\",\"shell\":\"rust\"}}", pup_core::version())); }+        staged.insert(dst, dir);+    }+    let identity = if profile == "adom-you" { "pup-identity-extension" } else { "pup-identity-anon-extension" };+    ["pup-annotate-extension", "pup-extension", identity].iter().filter_map(|k| staged.get(k).cloned()).collect()+}+ pub fn profiles_dir(host: &dyn Host) -> PathBuf { host.state_dir().join("pup-profiles") }  /// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features).-pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool) -> Vec<String> {+pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Vec<String> {     let mut v: Vec<String> = vec![         format!("--user-data-dir={}", profile_dir.display()),         "--remote-debugging-port=0".into(),@@ -362,12 +389,13 @@ pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool) -     ];     if high_fps { v.extend(["--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"].map(String::from)); }     if insecure_dev { v.push("--disable-web-security".into()); }+    if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }     v.push("about:blank".into());     v }  /// Node: prepareCleanProfile (the Preferences the profile must carry before Chrome opens it).-pub fn prepare_profile(profile_dir: &Path) {+pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool) {     let default = profile_dir.join("Default");     let _ = fs::create_dir_all(&default);     let prefs_path = default.join("Preferences");@@ -378,6 +406,18 @@ pub fn prepare_profile(profile_dir: &Path) {     prefs["webrtc"]["ip_handling_policy"] = json!("disable_non_proxied_udp");     prefs["webrtc"]["nonproxied_udp_enabled"] = json!(false);     prefs["webrtc"]["multiple_routes_enabled"] = json!(false);+    if pin_extensions {+        // the toolbar order IS the pinned_extensions array order (Node v2.0.150): foreign pins kept, ours appended in canonical order+        let ours: Vec<&str> = EXT_IDS.iter().map(|(_, id)| *id).collect();+        let prev: Vec<Value> = prefs["extensions"]["pinned_extensions"].as_array().cloned().unwrap_or_default();+        let mut pinned: Vec<Value> = prev.into_iter().filter(|x| x.as_str().map(|s| !ours.contains(&s)).unwrap_or(false)).collect();+        for (_, id) in EXT_IDS { pinned.push(json!(id)); }+        prefs["extensions"]["pinned_extensions"] = Value::Array(pinned);+    }+    prefs["browser"]["promotional_tabs_enabled"] = json!(false);+    prefs["session"]["restore_on_startup"] = json!(5);+    prefs["session"]["startup_urls"] = json!([]);+    prefs["profile"]["password_manager_leak_detection"] = json!(false);     prefs["credentials_enable_service"] = json!(false);   // pup runs its own vault; no Chrome save bubbles     prefs["profile"]["password_manager_enabled"] = json!(false);     let _ = fs::write(&prefs_path, serde_json::to_string(&prefs).unwrap_or_default());@@ -398,12 +438,14 @@ pub fn existing_port(profile_dir: &Path) -> Option<u16> {  /// Spawn-verified launch: the browser is started detached, the DevTools port is read from the /// profile's DevToolsActivePort (written by Chrome), and /json/version must answer.-pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: bool, insecure_dev: bool) -> Result<Launched, String> {+pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Result<Launched, String> {     let _ = fs::create_dir_all(profile_dir);     let port_file = profile_dir.join("DevToolsActivePort");     let _ = fs::remove_file(&port_file);-    prepare_profile(profile_dir);-    let args = chrome_switches(profile_dir, high_fps, insecure_dev);+    // extensions ride ONLY Chrome for Testing (branded Chrome 137+ dropped --load-extension)+    let exts: Vec<PathBuf> = if cand.kind == "chrome-for-testing" { extensions.to_vec() } else { Vec::new() };+    prepare_profile(profile_dir, !exts.is_empty());+    let args = chrome_switches(profile_dir, high_fps, insecure_dev, &exts);     let t0 = Instant::now();     let pid = host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("spawn {}: {e}", cand.executable_path.display()))?;     while t0.elapsed() < Duration::from_secs(30) {@@ -445,11 +487,12 @@ mod tests {         let _ = fs::remove_dir_all(&tmp);     }     #[test] fn switches_have_one_disable_features() {-        let s = chrome_switches(Path::new("/tmp/p"), false, false);+        let s = chrome_switches(Path::new("/tmp/p"), false, false, &[]);         assert_eq!(s.iter().filter(|x| x.starts_with("--disable-features=")).count(), 1);         assert!(s.iter().any(|x| x == "--remote-debugging-port=0"));         assert!(!s.iter().any(|x| x == "--disable-web-security"));-        assert!(chrome_switches(Path::new("/tmp/p"), true, true).iter().any(|x| x == "--disable-web-security"));+        assert!(chrome_switches(Path::new("/tmp/p"), true, true, &[]).iter().any(|x| x == "--disable-web-security"));+        assert!(chrome_switches(Path::new("/tmp/p"), false, false, &[PathBuf::from("/a"), PathBuf::from("/b")]).iter().any(|x| x == "--load-extension=/a,/b"));     }     #[test] fn unpack_real_zip_and_reject_junk() {         let tmp = std::env::temp_dir().join(format!("pup-unpack-test-{}", std::process::id()));
rust/dashboard/Cargo.toml+4
@@ -9,3 +9,7 @@ description = "The HTTP routes under /dashboard, /health, /status, /identity, th serde_json.workspace = true pup-core = { path = "../core" } pup-verbs = { path = "../verbs" }+ureq.workspace = true+pup-identity = { path = "../identity" }+pup-session = { path = "../session" }+pup-cdp = { path = "../cdp" }
rust/dashboard/assets/icon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/><path d="M2.75 8.75h18.5"/><circle cx="12" cy="14.2" r="3.1"/></svg>\ No newline at end of file
rust/dashboard/assets/say-phrases.jsonadded+1
@@ -0,0 +1 @@+["open your dashboard in pup","open my wiki page in pup","open my app in pup","screenshot this site in pup","record a clip of this page in pup","show me the logged-out view in pup"]\ No newline at end of file
rust/dashboard/src/assets_gen.rsadded+46
@@ -0,0 +1,46 @@+//! GENERATED by tools/gen-assets.py: dashboard images and page assets, embedded. Do not edit.++pub static FILES: &[(&str, &[u8])] = &[+    ("icons/settings-ex/anncapbar.png", include_bytes!("../../../src/icons/settings-ex/anncapbar.png")),+    ("icons/settings-ex/aumid.png", include_bytes!("../../../src/icons/settings-ex/aumid.png")),+    ("icons/settings-ex/fgcaption.png", include_bytes!("../../../src/icons/settings-ex/fgcaption.png")),+    ("icons/settings-ex/groupgrouped.png", include_bytes!("../../../src/icons/settings-ex/groupgrouped.png")),+    ("icons/settings-ex/groupsplit.png", include_bytes!("../../../src/icons/settings-ex/groupsplit.png")),+    ("icons/settings-ex/iconstyle.png", include_bytes!("../../../src/icons/settings-ex/iconstyle.png")),+    ("icons/settings-ex/iconstylecat.png", include_bytes!("../../../src/icons/settings-ex/iconstylecat.png")),+    ("icons/settings-ex/iconstylestd.png", include_bytes!("../../../src/icons/settings-ex/iconstylestd.png")),+    ("icons/settings-ex/jlannotate.png", include_bytes!("../../../src/icons/settings-ex/jlannotate.png")),+    ("icons/settings-ex/jlcloseall.png", include_bytes!("../../../src/icons/settings-ex/jlcloseall.png")),+    ("icons/settings-ex/jldashboard.png", include_bytes!("../../../src/icons/settings-ex/jldashboard.png")),+    ("icons/settings-ex/jlviewtasks.png", include_bytes!("../../../src/icons/settings-ex/jlviewtasks.png")),+    ("icons/settings-ex/jumplist.png", include_bytes!("../../../src/icons/settings-ex/jumplist.png")),+    ("icons/settings-ex/oboff.png", include_bytes!("../../../src/icons/settings-ex/oboff.png")),+    ("icons/settings-ex/obon.png", include_bytes!("../../../src/icons/settings-ex/obon.png")),+    ("icons/settings-ex/obsize100.png", include_bytes!("../../../src/icons/settings-ex/obsize100.png")),+    ("icons/settings-ex/obsize50.png", include_bytes!("../../../src/icons/settings-ex/obsize50.png")),+    ("icons/settings-ex/obsize66.png", include_bytes!("../../../src/icons/settings-ex/obsize66.png")),+    ("icons/settings-ex/obsize75.png", include_bytes!("../../../src/icons/settings-ex/obsize75.png")),+    ("icons/settings-ex/ownerchip.png", include_bytes!("../../../src/icons/settings-ex/ownerchip.png")),+    ("icons/settings-ex/tbactflash.png", include_bytes!("../../../src/icons/settings-ex/tbactflash.png")),+    ("icons/settings-ex/tbactoff.png", include_bytes!("../../../src/icons/settings-ex/tbactoff.png")),+    ("icons/settings-ex/tbactprogress.png", include_bytes!("../../../src/icons/settings-ex/tbactprogress.png")),+    ("icons/settings-ex/toolbarext.png", include_bytes!("../../../src/icons/settings-ex/toolbarext.png")),+    ("icons/settings-samples/digikey.svg", include_bytes!("../../../src/icons/settings-samples/digikey.svg")),+    ("icons/settings-samples/mouser.ico", include_bytes!("../../../src/icons/settings-samples/mouser.ico")),+    ("icons/settings-samples/ti.ico", include_bytes!("../../../src/icons/settings-samples/ti.ico")),+    ("icons/settings-samples/wiki.svg", include_bytes!("../../../src/icons/settings-samples/wiki.svg")),+    ("icons/tab-badges/badge-2.png", include_bytes!("../../../src/icons/tab-badges/badge-2.png")),+    ("icons/tab-badges/badge-3.png", include_bytes!("../../../src/icons/tab-badges/badge-3.png")),+    ("icons/tab-badges/badge-4.png", include_bytes!("../../../src/icons/tab-badges/badge-4.png")),+    ("icons/tab-badges/badge-5.png", include_bytes!("../../../src/icons/tab-badges/badge-5.png")),+    ("icons/tab-badges/badge-6.png", include_bytes!("../../../src/icons/tab-badges/badge-6.png")),+    ("icons/tab-badges/badge-7.png", include_bytes!("../../../src/icons/tab-badges/badge-7.png")),+    ("icons/tab-badges/badge-8.png", include_bytes!("../../../src/icons/tab-badges/badge-8.png")),+    ("icons/tab-badges/badge-9.png", include_bytes!("../../../src/icons/tab-badges/badge-9.png")),+    ("icons/tab-badges/badge-9plus.png", include_bytes!("../../../src/icons/tab-badges/badge-9plus.png")),+    ("newbie-panel.js", include_bytes!("../../../src/newbie-panel.js")),+    ("welcome.html", include_bytes!("../../../src/welcome.html")),+    ("recorder.html", include_bytes!("../../../src/recorder.html")),+];++pub fn get(name: &str) -> Option<&'static [u8]> { FILES.iter().find(|(n, _)| *n == name).map(|(_, b)| *b) }
rust/dashboard/src/lib.rs+183−6
@@ -1,10 +1,30 @@-//! pup-dashboard: the HTTP routes that are not verbs. Phase 1: /health and /status (the chip-//! contract ab renders verbatim: led / summary / tooltip), built from what the verbs crate knows.-//! The dashboard page, its SSE feed and the Hydrogen proxy routes land in Phase 2 as embedded-//! assets (include_str!) behind the same function-per-route shape.+//! pup-dashboard: every HTTP route that is not a verb. /health and /status (the chip contract ab+//! renders verbatim), the live dashboard page (the Node build's single-file page, embedded) with+//! its SSE snapshot feed, its action and image routes, /api/status (the LED row), /identity, and+//! the embedded page assets. Each route is one function; the binary only maps paths here. +pub mod assets_gen;++use pup_core::{Caller}; use pup_verbs::Ctx; use serde_json::{json, Value};+use std::io::Read;+use std::time::{Duration, Instant};++pub const DASHBOARD_HTML: &str = include_str!("../assets/dashboard.html");+pub const ICON_SVG: &str = include_str!("../assets/icon.svg");+pub const SAY_PHRASES: &str = include_str!("../assets/say-phrases.json");++pub enum Reply { Json(u16, Value), Bytes(u16, String, Vec<u8>, Option<String>), Stream(Box<dyn Read + Send>) }++fn q(query: &str, key: &str) -> Option<String> {+    query.split('&').find_map(|kv| { let mut it = kv.splitn(2, '='); if it.next()? == key { Some(url_decode(it.next().unwrap_or(""))) } else { None } })+}+fn url_decode(s: &str) -> String {+    let b = s.as_bytes(); let mut out = Vec::with_capacity(b.len()); let mut i = 0;+    while i < b.len() { if b[i] == b'%' && i + 2 < b.len() { if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) { out.push(v); i += 3; continue; } } if b[i] == b'+' { out.push(b' '); } else { out.push(b[i]); } i += 1; }+    String::from_utf8_lossy(&out).to_string()+}  fn session_rows(ctx: &Ctx) -> Vec<Value> {     let active = ctx.sessions.active_id();@@ -32,8 +52,8 @@ pub fn status(ctx: &Ctx) -> Value {         ("red", "Chrome install failed".to_string(), format!("Puppeteer bridge v{v} (rust)\nChrome for Testing install FAILED: {}\nRetry pup_prewarm (usually a network/proxy or disk issue).", rd["lastError"].as_str().unwrap_or("?")))     } else if rd["installing"] == json!(true) {         let pct = rd["installProgressPct"].as_u64().unwrap_or(0);-        let phase = if rd["installPhase"] == json!("unpacking") { "Unpacking".to_string() } else { format!("Installing Chrome {pct}%") };-        ("yellow", phase.clone(), format!("Puppeteer bridge v{v} (rust)\n{} — pup not ready yet. Poll pup_readiness until ready.", if rd["installPhase"] == json!("unpacking") { "Unpacking Chrome for Testing".to_string() } else { format!("Downloading Chrome for Testing ({pct}%)") }))+        let unpack = rd["installPhase"] == json!("unpacking");+        ("yellow", if unpack { "Unpacking Chrome".to_string() } else { format!("Installing Chrome {pct}%") }, format!("Puppeteer bridge v{v} (rust)\n{} — pup not ready yet. Poll pup_readiness until ready.", if unpack { "Unpacking Chrome for Testing".to_string() } else { format!("Downloading Chrome for Testing ({pct}%)") }))     } else if rd["ready"] != json!(true) {         ("yellow", "No browser yet".to_string(), format!("Puppeteer bridge v{v} (rust)\nNo Chrome/Edge detected and Chrome for Testing not cached — pup will fetch CfT on first use (or call pup_prewarm)."))     } else {@@ -46,5 +66,162 @@ pub fn status(ctx: &Ctx) -> Value {         "led": led, "summary": summary, "tooltip": tooltip,         "sessionCount": n, "activeSession": ctx.sessions.active_id(), "sessions": list, "chrome": rd,         "adVersion": ctx.ad_version.lock().unwrap().clone(), "uptimeSec": ctx.started.elapsed().as_secs(),+        "pupshotUrl": format!("http://127.0.0.1:{}/dashboard", ctx.port),     }) }++/// The live snapshot the dashboard page renders (Node: dashboardSnapshot), same field names.+pub fn snapshot(ctx: &Ctx) -> Value {+    let now = pup_core::now_ms();+    let mut wins: Vec<Value> = Vec::new();+    let mut buttons: Vec<Value> = Vec::new();+    for s in ctx.sessions.all() {+        let states = ctx.tabstate.lock().unwrap();+        let tabs: Vec<Value> = s.tabs.iter().map(|t| { let st = states.get(&t.cdp_session); json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": s.active().map(|a| a.tab_id == t.tab_id).unwrap_or(false), "crashed": false, "crashReason": Value::Null, "opener": "agent", "errorCount": st.map(|x| x.errors.len()).unwrap_or(0), "fav": Value::Null }) }).collect();+        let errors: Vec<Value> = s.tabs.iter().flat_map(|t| states.get(&t.cdp_session).map(|x| x.errors.clone()).unwrap_or_default()).collect();+        drop(states);+        let badge_file = ctx.host.temp_dir().join("pup-badge-debug").join(format!("{}.png", pup_session::sanitize_sid(&s.id)));+        let painted = badge_file.exists();+        let overlay_led = if s.app_id.is_none() { "red" } else if painted { "green" } else { "amber" };+        let url = s.active().map(|t| t.url.clone()).unwrap_or_default();+        wins.push(json!({+            "sessionId": s.id, "profile": s.profile, "owner": s.owner, "ownerSource": if s.owner.is_some() { "caller-identity" } else { Value::Null.as_str().unwrap_or("none") }, "ownerThread": s.owner, "ownerContainer": s.owner_container, "lastThread": s.owner,+            "foreground": s.foreground, "foregroundReason": s.last_note, "foregroundAt": Value::Null, "foregroundVerb": Value::Null, "background": if s.foreground { "foreground" } else { "backgrounded" },+            "highFps": false, "highFpsMinutesLeft": 0, "openedAt": s.created_ms, "uptimeMs": now.saturating_sub(s.created_ms), "tabCount": s.tabs.len(), "tabs": tabs,+            "overlay": if painted { "page-favicon" } else { "none" }, "overlayWhy": { "at": if painted { now } else { 0 }, "why": if painted { "painted through ab desktop_taskbar" } else { "not painted yet" } },+            "_curAppId": s.app_id, "wiki": { "authed": Value::Null, "view": Value::Null, "isWiki": pup_identity::is_adom_url(&url) },+            "identityPending": false, "identityPendingMs": 0, "overlayLed": overlay_led, "hwnd": s.hwnd, "emulatedViewport": s.emulated, "lostBrowser": pup_verbs::browser_for(ctx, &s.profile).is_none(),+            "lastAgentUpdate": s.last_agent_update_ms, "errorCount": errors.len(), "recentErrors": errors.iter().rev().take(5).map(|e| json!({ "type": e["type"], "text": e["text"].as_str().unwrap_or("").chars().take(200).collect::<String>() })).collect::<Vec<_>>(), "flashCount": 0, "url": url,+        }));+        let (key, label, base_icon) = match &s.app_id { Some(a) => (format!("aumid:{a}"), s.id.clone(), format!("/dashboard/stampicon?session={}", s.id)), None => (format!("chrome-process:{}", s.profile), format!("Chrome ({})", s.profile), format!("/dashboard/baseicon?profile={}", s.profile)) };+        if let Some(b) = buttons.iter_mut().find(|b| b["key"] == json!(key)) { b["windows"].as_array_mut().unwrap().push(json!(s.id)); if b["led"] == json!("green") && overlay_led != "green" { b["led"] = json!(overlay_led); } }+        else { buttons.push(json!({ "key": key, "label": label, "kind": "process", "baseIcon": base_icon, "grouping": ctx.settings.get("taskbarGrouping", "split"), "windows": [s.id], "badgeSession": s.id, "badgeAt": now, "badgeVia": "newest-painted", "badgeLed": overlay_led, "led": overlay_led, "identityPending": 0, "ledWhy": match overlay_led { "green" => "every window wears its real page-favicon overlay — dashboard matches the taskbar", "amber" => "stamped, overlay not painted yet (or the page had no favicon)", _ => "a window under this button is not identity-stamped yet" } })); }+    }+    wins.sort_by(|a, b| b["openedAt"].as_u64().cmp(&a["openedAt"].as_u64()));+    let mut threads: Vec<String> = wins.iter().filter_map(|w| w["owner"].as_str().map(|s| s.to_string())).collect(); threads.sort(); threads.dedup();+    let mut profiles = serde_json::Map::new();+    for w in &wins { let p = w["profile"].as_str().unwrap_or("").to_string(); let n = profiles.get(&p).and_then(|v| v.as_u64()).unwrap_or(0); profiles.insert(p, json!(n + 1)); }+    let toasts: Vec<Value> = ctx.toasts.lock().unwrap().iter().filter(|t| now.saturating_sub(t["at"].as_u64().unwrap_or(0)) < 15000).cloned().collect();+    json!({+        "ts": now,+        "bridge": { "version": pup_core::version(), "shell": "rust", "platform": std::env::consts::OS, "uptimeMs": ctx.started.elapsed().as_millis() as u64, "depsOk": true, "minimalTouch": ctx.settings.get("minimalTouch", "on") == "on", "flashedPending": 0, "settings": pup_verbs::settings::snapshot(ctx), "win": { "inProcess": true, "why": Value::Null }, "loops": [], "abEnumLastMin": 0 },+        "counts": { "windows": wins.len(), "tabs": wins.iter().map(|w| w["tabCount"].as_u64().unwrap_or(0)).sum::<u64>(), "browsers": ctx.browsers.lock().unwrap().len(), "threads": threads.len() },+        "threads": threads, "profiles": profiles, "taskbarButtons": buttons, "untrackedWindows": [], "toasts": toasts, "regen": Value::Null, "windows": wins,+    })+}++/// The SSE feed: a snapshot every 2 s for as long as the client reads (Node: /dashboard/events).+struct SseReader { ctx: std::sync::Arc<Ctx>, buf: Vec<u8>, pos: usize, last: Option<Instant> }+impl Read for SseReader {+    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {+        if self.pos >= self.buf.len() {+            if let Some(t) = self.last { let el = t.elapsed(); if el < Duration::from_secs(2) { std::thread::sleep(Duration::from_secs(2) - el); } }+            let payload = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| snapshot(&self.ctx))) { Ok(v) => v.to_string(), Err(_) => json!({ "error": "snapshot panicked", "ts": pup_core::now_ms() }).to_string() };+            self.buf = format!("data: {payload}\n\n").into_bytes(); self.pos = 0; self.last = Some(Instant::now());+        }+        let n = (self.buf.len() - self.pos).min(out.len());+        out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]); self.pos += n;+        Ok(n)+    }+}++fn png_1x1() -> Vec<u8> { pup_cdp::base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==").unwrap_or_default() }++/// LED row for the dock card (Node: /api/status).+pub fn api_status(ctx: &Ctx) -> Value {+    let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+    let failed = rd["installPhase"] == json!("failed"); let installing = rd["installing"] == json!(true); let cft = rd["chromeForTestingInstalled"] == json!(true);+    let ready = rd["ready"] == json!(true);+    let leds = vec![+        json!({ "key": "chrome", "label": "Chrome for Testing", "state": if failed { "fail" } else if cft { "ok" } else { "warn" }, "detail": if failed { format!("install FAILED: {}", rd["lastError"].as_str().unwrap_or("?")) } else if installing { format!("downloading ({}%)", rd["installProgressPct"].as_u64().unwrap_or(0)) } else if cft { format!("installed, build {}", rd["chromeBuildId"].as_str().unwrap_or("")) } else { "not installed yet".into() }, "short": if cft { "installed" } else if installing { "installing" } else { "not installed" }, "_hint": if cft { Value::Null } else { json!("Run pup_prewarm to install and warm Chrome for Testing before the first window.") } }),+        json!({ "key": "bridge", "label": "Bridge", "state": "ok", "detail": format!("pup {} (rust) answering on port {}", pup_core::version(), ctx.port), "short": "answering", "_hint": Value::Null }),+        json!({ "key": "profile", "label": "Profile", "state": if ready { "ok" } else { "warn" }, "detail": format!("{} window(s) on the durable adom-you profile", ctx.sessions.for_profile("adom-you").len()), "short": if ready { "adom-you" } else { "waiting" }, "_hint": Value::Null }),+    ];+    json!({ "ok": true, "leds": leds, "version": pup_core::version(), "shell": "rust", "windows": ctx.sessions.count(), "sayPhrases": serde_json::from_str::<Value>(SAY_PHRASES).unwrap_or(json!([])) })+}++/// /identity: who the desktop user is (from ~/.adom/session.json + carbon), cached 10 min.+pub fn identity(ctx: &Ctx) -> Value {+    static CACHE: std::sync::Mutex<Option<(Instant, Value)>> = std::sync::Mutex::new(None);+    if let Some((t, v)) = CACHE.lock().unwrap().as_ref() { if t.elapsed() < Duration::from_secs(600) { return v.clone(); } }+    let sess: Value = std::fs::read_to_string(ctx.host.state_dir().join("session.json")).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));+    let token = sess["token"].as_str().or(sess["session_token"].as_str()).or(sess["access_token"].as_str()).or(sess["session"]["token"].as_str()).map(|s| s.to_string());+    let (mut user, mut orgs) = (Value::Null, Vec::new());+    if let Some(t) = &token {+        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(6)).build();+        if let Ok(r) = agent.get("https://carbon.adom.inc/user").set("Authorization", &format!("Bearer {t}")).call() { user = r.into_json().unwrap_or(Value::Null); }+        if let Ok(r) = agent.get("https://carbon.adom.inc/user/orgs").set("Authorization", &format!("Bearer {t}")).call() { let j: Value = r.into_json().unwrap_or(json!([])); orgs = j.as_array().cloned().or_else(|| j["orgs"].as_array().cloned()).unwrap_or_default(); }+    }+    let u = if user.is_object() { user } else { sess["user"].clone() };+    let v = json!({ "ok": true, "displayName": u["display_name"].as_str().or(u["displayName"].as_str()).or(u["name"].as_str()), "username": u["name"].as_str().or(u["username"].as_str()), "email": u["email"], "avatarUrl": u["avatar_url"].as_str().or(u["avatarUrl"].as_str()), "avatarB64": ctx.settings.all().get("identityAvatarB64").cloned().unwrap_or(Value::Null), "isAdmin": u["is_admin"].as_bool().or(u["isAdmin"].as_bool()).unwrap_or(false), "orgs": orgs, "signedIn": token.is_some() });+    *CACHE.lock().unwrap() = Some((Instant::now(), v.clone()));+    v+}++fn action(ctx: &Ctx, caller: &Caller, body: &str) -> Value {+    let a: Value = serde_json::from_str(body).unwrap_or(json!({}));+    let act = a["action"].as_str().unwrap_or("");+    let user = Caller { thread: "user-dashboard".into(), reason: format!("dashboard action {act}"), ..caller.clone() };+    match act {+        "close-all" => { let ids: Vec<String> = ctx.sessions.all().iter().map(|s| s.id.clone()).collect(); for id in &ids { let _ = pup_verbs::close_window(ctx, &user, &json!({ "window": id, "takeover": true })); } json!({ "ok": true, "closed": ids.len() }) }+        "close-window" => { let sid = a["sessionId"].as_str().unwrap_or(""); let r = pup_verbs::close_window(ctx, &user, &json!({ "window": sid, "takeover": true })); json!({ "ok": r["ok"] == json!(true), "closed": sid }) }+        "close-tab" => { let r = pup_verbs::tabs::close_tab(ctx, &user, &json!({ "window": a["sessionId"], "tabId": a["tabId"], "takeover": true })); json!({ "ok": r["ok"] == json!(true), "closed": a["tabId"] }) }+        "set-setting" => { let r = pup_verbs::settings::configure(ctx, &json!({ a["key"].as_str().unwrap_or("").to_string(): a["value"].clone() })); json!({ "ok": r["rejected"].as_array().map(|x| x.is_empty()).unwrap_or(false), "settings": r["settings"], "rejected": r["rejected"] }) }+        "test-caption" => { let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500); let _ = ctx.ab.call("desktop_caption", json!({ "text": "pup: this is what a foreground caption looks like", "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": "pup-test-caption" }), &user, Duration::from_secs(6)); json!({ "ok": true, "ms": ms }) }+        "import-scan" | "import-run" => json!({ "ok": false, "error": "browser-login import is not in the Rust build yet (Phase 3)" }),+        _ => json!({ "ok": false, "error": format!("unknown action {act}") }),+    }+}++fn tile_for(ctx: &Ctx, sid: &str) -> Option<Vec<u8>> {+    let s = ctx.sessions.get(sid)?;+    let name = s.owner.clone().unwrap_or(s.id.clone());+    let tile = ctx.host.temp_dir().join("pup-thread-tiles").join(pup_identity::TILE_GEN).join(format!("{}.ico", pup_identity::thread_slug(&name)));+    std::fs::read(tile).ok()+}++/// One function per route; the binary only maps method+path here. None = 404.+pub fn route(ctx: &std::sync::Arc<Ctx>, caller: &Caller, method: &str, path: &str, query: &str, body: &str) -> Option<Reply> {+    let png = |b: Vec<u8>, cache: &str| Some(Reply::Bytes(200, "image/png".into(), b, Some(cache.into())));+    match (method, path) {+        ("GET", "/") | ("GET", "/dashboard") | ("GET", "/dashboard/") => {+            let html = DASHBOARD_HTML.replace("${BRIDGE_VERSION}", pup_core::version()).replace("${JSON.stringify(SAY_PHRASES)}", SAY_PHRASES);+            Some(Reply::Bytes(200, "text/html; charset=utf-8".into(), html.into_bytes(), Some("no-store".into())))+        }+        ("GET", "/dashboard/events") => Some(Reply::Stream(Box::new(SseReader { ctx: ctx.clone(), buf: Vec::new(), pos: 0, last: None }))),+        ("GET", "/dashboard/icon.svg") => Some(Reply::Bytes(200, "image/svg+xml".into(), ICON_SVG.as_bytes().to_vec(), Some("public, max-age=3600".into()))),+        ("GET", "/favicon.ico") => Some(Reply::Bytes(200, "image/svg+xml".into(), ICON_SVG.as_bytes().to_vec(), Some("public, max-age=3600".into()))),+        ("GET", "/dashboard/newbie-panel.js") => Some(Reply::Bytes(200, "application/javascript".into(), assets_gen::get("newbie-panel.js").map(|b| b.to_vec()).unwrap_or_default(), Some("public, max-age=600".into()))),+        ("GET", "/dashboard/example") => { let n = q(query, "name").unwrap_or_default(); assets_gen::get(&format!("icons/settings-ex/{n}.png")).map(|b| Reply::Bytes(200, "image/png".into(), b.to_vec(), Some("no-store".into()))) }+        ("GET", "/dashboard/sample-favicon") => { let n = q(query, "name").unwrap_or_default(); let (file, ct) = match n.as_str() { "wiki" => ("wiki.svg", "image/svg+xml"), "ti" => ("ti.ico", "image/x-icon"), "mouser" => ("mouser.ico", "image/x-icon"), "digikey" => ("digikey.svg", "image/svg+xml"), _ => return None }; assets_gen::get(&format!("icons/settings-samples/{file}")).map(|b| Reply::Bytes(200, ct.into(), b.to_vec(), Some("public, max-age=3600".into()))) }+        ("GET", "/dashboard/sample-cat") => { let n = q(query, "name").unwrap_or_default(); pup_identity::ICONS.iter().find(|(name, _)| *name == n || name.trim_end_matches(".ico") == n.trim_end_matches(".ico")).map(|(_, b)| Reply::Bytes(200, "image/x-icon".into(), b.to_vec(), Some("public, max-age=3600".into()))) }+        ("GET", "/dashboard/stampicon") => { let sid = q(query, "session").unwrap_or_default(); match tile_for(ctx, &sid) { Some(b) => Some(Reply::Bytes(200, "image/x-icon".into(), b, Some("public, max-age=60".into()))), None => Some(Reply::Bytes(200, "image/x-icon".into(), pup_identity::ICONS[1].1.to_vec(), Some("public, max-age=60".into()))) } }+        ("GET", "/dashboard/badge") | ("GET", "/dashboard/bestbadge") => { let sid = q(query, "session").unwrap_or_default(); let f = ctx.host.temp_dir().join("pup-badge-debug").join(format!("{}.png", pup_session::sanitize_sid(&sid))); match std::fs::read(f) { Ok(b) => png(b, "public, max-age=30"), Err(_) => png(png_1x1(), "no-store") } }+        ("GET", "/dashboard/baseicon") => Some(Reply::Bytes(200, "image/x-icon".into(), pup_identity::ICONS[0].1.to_vec(), Some("public, max-age=3600".into()))),+        ("GET", "/dashboard/favicon") => {+            let sid = q(query, "session").unwrap_or_default();+            let s = ctx.sessions.get(&sid)?; let t = q(query, "tab").and_then(|id| s.tab_by_id(&id).cloned()).or_else(|| s.active().cloned())?;+            let b = pup_verbs::browser_for(ctx, &s.profile)?;+            let js = r#"(async () => { const l = document.querySelector('link[rel~="icon"], link[rel="shortcut icon"]'); const href = (l && l.href) || (location.origin + '/favicon.ico'); try { const r = await fetch(href, { credentials: 'include' }); if (!r.ok) return null; const bl = await r.blob(); return await new Promise(res => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(null); fr.readAsDataURL(bl); }); } catch (e) { return null; } })()"#;+            let generic = || Some(Reply::Bytes(200, "image/svg+xml".into(), ICON_SVG.as_bytes().to_vec(), Some("public, max-age=60".into())));+            let Some(data) = b.cdp.evaluate(&t.cdp_session, js, Duration::from_secs(8)).ok().and_then(|v| v.as_str().map(|s| s.to_string())) else { return generic() };+            let Some((mime, b64)) = data.trim_start_matches("data:").split_once(";base64,") else { return generic() };+            match pup_cdp::base64_decode(b64) { Ok(bytes) if !bytes.is_empty() => Some(Reply::Bytes(200, mime.to_string(), bytes, Some("public, max-age=60".into()))), _ => generic() }+        }+        ("GET", "/dashboard/apply-progress") => Some(Reply::Json(200, json!({ "ok": true, "apply": Value::Null }))),+        ("POST", "/dashboard/toast") | ("POST", "/ui/toast") => {+            let b: Value = serde_json::from_str(body).unwrap_or(json!({}));+            let msg = b["msg"].as_str().or(b["message"].as_str()).unwrap_or("").chars().take(200).collect::<String>();+            if msg.is_empty() { return Some(Reply::Json(200, json!({ "ok": false, "error": "msg required" }))); }+            let mut t = ctx.toasts.lock().unwrap(); let id = t.len() as u64 + 1;+            t.push(json!({ "id": id, "msg": msg, "type": if ["success", "error", "info"].contains(&b["type"].as_str().unwrap_or("")) { b["type"].clone() } else { json!("info") }, "at": pup_core::now_ms() }));+            while t.len() > 20 { t.remove(0); }+            Some(Reply::Json(200, json!({ "ok": true })))+        }+        ("POST", "/dashboard/action") => Some(Reply::Json(200, action(ctx, caller, body))),+        ("GET", "/api/status") => Some(Reply::Json(200, api_status(ctx))),+        ("GET", "/identity") => Some(Reply::Json(200, identity(ctx))),+        ("POST", "/api/demo-send") | ("POST", "/ai-threads/run-prompt") | ("GET", "/ai-threads/run-prompt/status") => Some(Reply::Json(501, json!({ "success": false, "errorCode": "not_in_rust_yet", "error": "Send-to-AI (Hydrogen proxy) lands in Phase 3 of the Rust port" }))),+        _ => None,+    }+}
rust/verbs/src/identity.rs+1
@@ -115,6 +115,7 @@ pub fn overlay(ctx: &Ctx, sid: &str, caller: &Caller) {     let Some(b) = browser_for(ctx, &s.profile) else { return };     let pct: u32 = ctx.settings.get("overlaySize", "75").parse().unwrap_or(75);     let badge = b.cdp.evaluate(&t.cdp_session, &id::badge_js(pct), Duration::from_secs(12)).ok().and_then(|v| v.as_str().map(|s| s.to_string())).and_then(|d| d.split("base64,").nth(1).map(|s| s.to_string()));+    if let Some(b64) = &badge { if let Ok(png) = pup_cdp::base64_decode(b64) { let dir = ctx.host.temp_dir().join("pup-badge-debug"); let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(format!("{}.png", pup_session::sanitize_sid(sid))), png); } }     let 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 }) };
rust/verbs/src/lib.rs+14−2
@@ -47,6 +47,7 @@ pub struct Ctx {     pub started: Instant,     pub ad_version: Mutex<Option<String>>,     pub self_ref: Mutex<std::sync::Weak<Ctx>>,+    pub toasts: Mutex<Vec<Value>>, }  impl Ctx {@@ -59,7 +60,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_set_identity", "pup_audit_icons"];+    "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", "pup_dashboard"];  pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     let t0 = Instant::now();@@ -107,6 +108,16 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {         "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_dashboard" => {+            let human = caller.thread == "user-taskbar-menu" || caller.thread == "user-dashboard";+            let url = format!("http://127.0.0.1:{}/dashboard", ctx.port);+            let mut a = json!({ "window": "pup-dashboard", "profile": "pup-dashboard", "url": url, "takeover": true });+            if human { a["foreground"] = json!(true); a["foregroundReason"] = json!("you clicked \"Open pup dashboard\" in the taskbar menu"); }+            let existed = ctx.sessions.exists("pup-dashboard");+            let r = if existed { navigate(ctx, caller, &a) } else { open_window(ctx, caller, &a) };+            if human { let _ = window::raise(ctx, caller, &json!({ "window": "pup-dashboard", "takeover": true, "foregroundReason": "you clicked \"Open pup dashboard\" in the taskbar menu" })); }+            pup_core::merge(r, json!({ "sessionId": "pup-dashboard", "dashboardUrl": format!("http://127.0.0.1:{}/dashboard", ctx.port), "reused": existed, "foregrounded": human, "_hint": if human { "The pup dashboard was raised because a human clicked the taskbar task." } else { "The pup dashboard is open in its own pup window (session pup-dashboard), BACKGROUNDED like any pup window; raise it with pup_raise_os_window {foregroundReason} if the user asked to see it." } }))+        }         "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.")),@@ -183,7 +194,8 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -     let mut errors = Vec::new();     for c in &cands {         let t0 = Instant::now();-        match pup_browser::launch(&*ctx.host, c, &profile_dir, high_fps, insecure_dev) {+        let exts = pup_browser::stage_extensions(&*ctx.host, ctx.port, profile, ctx.prefer_cft());+        match pup_browser::launch(&*ctx.host, c, &profile_dir, high_fps, insecure_dev, &exts) {             Ok(l) => match Cdp::connect_port(l.cdp_port, Duration::from_secs(10)) {                 Ok(cdp) => {                     pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));
tools/gen-assets.pyadded+35
@@ -0,0 +1,35 @@+#!/usr/bin/env python3+"""Embed browser-side assets into the Rust build (run by tools/ship.sh; commit the outputs).+- rust/browser/src/ext_assets.rs: the four MV3 extension folders (toolbar, identity, identity-anon, annotate)+- rust/dashboard/src/assets_gen.rs: dashboard images (settings-ex, settings-samples, tab-badges), newbie-panel.js, recorder.html, welcome.html+Files are referenced with include_bytes! relative to the crate, so the exe carries them; nothing is copied into rust/."""+import os, sys+root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+def rel(p, crate): return os.path.relpath(p, os.path.join(root, 'rust', crate, 'src')).replace('\\', '/')+# extensions+dirs = [('extension', 'pup-extension'), ('extension-identity', 'pup-identity-extension'), ('extension-identity-anon', 'pup-identity-anon-extension'), ('extension-annotate', 'pup-annotate-extension')]+out = ["//! GENERATED by tools/gen-assets.py: the MV3 extension folders, embedded. Do not edit.", "", "/// (source folder, staged folder name under ~/.adom, files)", "pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &["]+for src, dst in dirs:+    d = os.path.join(root, 'src', src)+    if not os.path.isdir(d): continue+    files = sorted(f for f in os.listdir(d) if os.path.isfile(os.path.join(d, f)))+    out.append(f'    ("{src}", "{dst}", &[')+    for f in files: out.append(f'        ("{f}", include_bytes!("{rel(os.path.join(d, f), "browser")}")),')+    out.append('    ]),')+out.append('];')+open(os.path.join(root, 'rust', 'browser', 'src', 'ext_assets.rs'), 'w').write("\n".join(out) + "\n")+# dashboard assets+items = []+for sub in ['icons/settings-ex', 'icons/settings-samples', 'icons/tab-badges']:+    d = os.path.join(root, 'src', sub)+    for f in sorted(os.listdir(d)):+        p = os.path.join(d, f)+        if os.path.isfile(p): items.append((f'{sub}/{f}', p))+for f in ['newbie-panel.js', 'welcome.html', 'recorder.html']:+    p = os.path.join(root, 'src', f)+    if os.path.isfile(p): items.append((f, p))+out = ["//! GENERATED by tools/gen-assets.py: dashboard images and page assets, embedded. Do not edit.", "", "pub static FILES: &[(&str, &[u8])] = &["]+for name, p in items: out.append(f'    ("{name}", include_bytes!("{rel(p, "dashboard")}")),')+out.append('];'); out.append(''); out.append('pub fn get(name: &str) -> Option<&\'static [u8]> { FILES.iter().find(|(n, _)| *n == name).map(|(_, b)| *b) }')+open(os.path.join(root, 'rust', 'dashboard', 'src', 'assets_gen.rs'), 'w').write("\n".join(out) + "\n")+print(f"ext_assets.rs: {len(dirs)} folders; assets_gen.rs: {len(items)} files")