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.
master
John Lauer
v2.0.688 (insiders): Smoother taskbar tile text: every tile size is drawn at 4x and averaged down, so letters sit at ex…
80a3d14
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
//! 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;
pub mod hydrogen;
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();
ctx.sessions.all().iter().map(|s| json!({ "sessionId": s.id, "url": s.active().map(|t| t.url.clone()), "active": active.as_deref() == Some(&s.id), "ownerThread": s.owner, "view": Value::Null, "authed": Value::Null })).collect()
}
/// GET /health (Node: the /health branch): what the toolbar extension and ab's probes read.
pub fn health(ctx: &Ctx) -> Value {
let st = &ctx.settings;
json!({
"status": "ok", "bridge": "puppeteer", "version": pup_core::version(), "shell": "rust", "port": ctx.port,
"toolbar": { "enabled": st.get("toolbarExtension", "on") != "off", "ownerBadge": st.get("tbOwnerBadge", "on") != "off", "snipHotkey": st.get("tbSnipHotkey", "on") != "off", "floaty": st.get("tbFloatyPencil", "off") == "on" },
"browserCount": ctx.browsers.lock().unwrap().len(),
"sessionCount": ctx.sessions.count(), "activeSession": ctx.sessions.active_id(), "sessions": session_rows(ctx),
"chrome": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()),
})
}
/// GET /status: the status-chip contract (led/summary/tooltip verbatim in ab's UI).
pub fn status(ctx: &Ctx) -> Value {
let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());
let v = pup_core::version();
let n = ctx.sessions.count();
let (led, summary, tooltip) = if rd["installPhase"] == json!("failed") {
("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 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 {
let kind = rd["browserKind"].as_str().unwrap_or("?"); let src = rd["browserSource"].as_str().unwrap_or("?");
("green", format!("{n} window{} · {kind}", if n == 1 { "" } else { "s" }), format!("Puppeteer bridge v{v} (rust) — ready\nDrives {kind} ({src}) · {n} session(s){}", if rd["chromeForTestingInstalled"] == json!(true) { format!("\nCfT {} cached", rd["chromeBuildId"].as_str().unwrap_or("")) } else { String::new() }))
};
let list: Vec<Value> = ctx.sessions.all().iter().map(|s| json!({ "sessionId": s.id, "url": s.active().map(|t| t.url.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id) })).collect();
json!({
"ok": true, "bridge": "puppeteer", "version": v, "shell": "rust", "ready": rd["ready"], "warming": false,
"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, "fav": t.fav, "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 = s.overlay_led.clone().unwrap_or_else(|| if s.app_id.is_none() { "red".into() } else if painted { "green".into() } else { "amber".into() });
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": s.overlay_desc.clone().unwrap_or_else(|| if painted { "page-favicon".into() } else { "none".into() }), "overlayWhy": s.overlay_why.clone().unwrap_or_else(|| json!({ "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.as_str() { "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, "n").or_else(|| 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()))) }
// 2.0.687: the page sends n=, the route read name=, so every Settings sample badge was a broken image
("GET", "/dashboard/sample-favicon") => { let n = q(query, "n").or_else(|| 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, "n").or_else(|| 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))),
// 2.0.534: the identity extension paints the user's avatar on its toolbar button (John: "you should be
// showing my avatar up in your button"). The bytes come through pup so the extension needs no host
// permission for wherever the avatar lives; 10-minute cache like identity() itself.
("GET", "/identity/avatar") => {
static AV: std::sync::Mutex<Option<(Instant, String, Vec<u8>)>> = std::sync::Mutex::new(None);
if let Some((t, m, b)) = AV.lock().unwrap().as_ref() { if t.elapsed() < Duration::from_secs(600) { return Some(Reply::Bytes(200, m.clone(), b.clone(), Some("public, max-age=600".into()))); } }
let id = identity(ctx);
let mut got: Option<(String, Vec<u8>)> = None;
if let Some(b64) = id["avatarB64"].as_str() {
let raw = b64.split("base64,").last().unwrap_or(b64);
if let Ok(bytes) = pup_cdp::base64_decode(raw) { got = Some(("image/png".into(), bytes)); }
}
if got.is_none() {
if let Some(u) = id["avatarUrl"].as_str() {
let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(8)).redirects(4).build();
if let Ok(r) = agent.get(u).call() {
let mime = r.header("Content-Type").unwrap_or("image/png").split(';').next().unwrap_or("image/png").to_string();
let mut b = Vec::new(); let _ = r.into_reader().take(2 * 1024 * 1024).read_to_end(&mut b);
if !b.is_empty() { got = Some((mime, b)); }
}
}
}
match got {
Some((m, b)) => { *AV.lock().unwrap() = Some((Instant::now(), m.clone(), b.clone())); Some(Reply::Bytes(200, m, b, Some("public, max-age=600".into()))) }
None => Some(Reply::Json(404, json!({ "ok": false, "error": "no avatar for this identity" }))),
}
}
_ => hydrogen::route(method, path, query, body),
}
}