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: dashboard page + SSE snapshot + image/action routes, /api/status, /identity, embedded MV3 toolbar extensions with --load-extension and pinned prefs, pup_dashboard verb
11 files changed
+402−18
rust/bridge/src/main.rs+21−3rust/browser/src/ext_assets.rs+46rust/browser/src/lib.rs+50−7rust/dashboard/Cargo.toml+4rust/dashboard/assets/icon.svg+1rust/dashboard/assets/say-phrases.json+1rust/dashboard/src/assets_gen.rs+46rust/dashboard/src/lib.rs+183−6rust/verbs/src/identity.rs+1rust/verbs/src/lib.rs+14−2tools/gen-assets.py+35rust/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>
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"]
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")