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
rust/: the tree 2.0.594 is built from, pushed whole (pup-bridge#117); ship.sh pushes it on every release from now on
ccda43b
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
//! The taskbar overlay badge (the active tab's favicon on an adaptive card), composed IN THE BINARY.
//! Until 2.0.568 this was JavaScript evaluated inside the page (badge_js), which a strict
//! Content-Security-Policy refused (Google Docs, claude.ai, the wiki), so those windows never got an
//! overlay ("no badge could be composed"; John, 2026-09-14, the all-engineering agenda). Same card as
//! before: 48 px, corner radius 6, #202124 (or #f1f3f4 when the icon is dark-dominant AND transparent),
//! the icon cover-fitted into a 44 px inset box, then scaled to overlaySize percent and pinned north-east
//! so the thread tile's bottom line of text stays readable (2.0.262).
use image::{imageops, Rgba, RgbaImage};
pub const BSZ: u32 = 48;
const FIN: u32 = 2;
/// Chrome's grey globe and the PDF sheet, the SVGs from generic_icon_svg rasterised once at 48 px.
pub static GENERIC_WEB: &[u8] = include_bytes!("../assets/badge-generic-web.png");
pub static GENERIC_PDF: &[u8] = include_bytes!("../assets/badge-generic-pdf.png");
pub struct Analysis { pub luminance: Option<u32>, pub transparent: bool, pub dark_dominant: bool }
/// PNG, ICO, JPEG, GIF, BMP, WebP. SVG favicons are not decodable here (no rasteriser in the binary) and
/// fall through to the next candidate or the generic glyph.
/// SVG or not: the first bytes name the format. Anything else starting with `<` is an HTML error page
/// served where an image was expected, and stays rejected.
pub fn is_svg(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]).to_ascii_lowercase();
let t = head.trim_start();
t.starts_with("<svg") || (t.starts_with("<?xml") && head.contains("<svg")) || (t.starts_with("<!--") && head.contains("<svg"))
}
/// Rasterize an SVG favicon to a square RGBA image (fit inside `px`, centered). Straight alpha out.
fn rasterize_svg(bytes: &[u8], px: u32) -> Option<RgbaImage> {
let tree = resvg::usvg::Tree::from_data(bytes, &resvg::usvg::Options::default()).ok()?;
let size = tree.size();
let (w, h) = (size.width(), size.height());
if w <= 0.0 || h <= 0.0 { return None; }
let scale = (px as f32 / w).min(px as f32 / h);
let (ow, oh) = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
let mut pm = resvg::tiny_skia::Pixmap::new(ow, oh)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut());
let mut out = RgbaImage::new(ow, oh);
for (i, p) in pm.pixels().iter().enumerate() {
let c = p.demultiply();
let (x, y) = ((i as u32) % ow, (i as u32) / ow);
out.put_pixel(x, y, image::Rgba([c.red(), c.green(), c.blue(), c.alpha()]));
}
Some(out)
}
/// The favicon-sourcing guard: an image is anything that is not an HTML page. SVG passes.
pub fn looks_like_image(bytes: &[u8]) -> bool { !bytes.trim_ascii_start().starts_with(b"<") || is_svg(bytes) }
pub fn decode(bytes: &[u8]) -> Option<RgbaImage> {
if bytes.is_empty() { return None; }
if bytes.trim_ascii_start().starts_with(b"<") { return if is_svg(bytes) { rasterize_svg(bytes, 64) } else { None }; }
image::load_from_memory(bytes).ok().map(|i| i.to_rgba8())
}
pub fn generic(is_pdf: bool) -> RgbaImage {
decode(if is_pdf { GENERIC_PDF } else { GENERIC_WEB }).unwrap_or_else(|| RgbaImage::new(BSZ, BSZ))
}
/// Node analyseIcon: mean luminance of the opaque pixels of a 16 px downscale, and whether any pixel is see-through.
pub fn analyse(img: &RgbaImage) -> Analysis {
let small = imageops::resize(img, 16, 16, imageops::FilterType::Triangle);
let (mut sum, mut n, mut clear) = (0f64, 0u32, 0u32);
for p in small.pixels() {
if p[3] < 250 { clear += 1; }
if p[3] > 40 { sum += 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; n += 1; }
}
let luminance = if n > 0 { Some((sum / n as f64).round() as u32) } else { None };
Analysis { luminance, transparent: clear > 0, dark_dominant: luminance.map(|l| l < 80).unwrap_or(false) }
}
fn rounded_cov(x: u32, y: u32, s: f32, r: f32) -> f32 {
let (cx, cy) = (x as f32 + 0.5 - s / 2.0, y as f32 + 0.5 - s / 2.0);
let (dx, dy) = ((cx.abs() - (s / 2.0 - r)).max(0.0), (cy.abs() - (s / 2.0 - r)).max(0.0));
(0.5 - ((dx * dx + dy * dy).sqrt() - r)).clamp(0.0, 1.0)
}
/// The finished 48 x 48 PNG.
pub fn compose(icon: &RgbaImage, pct: u32, light_card: bool) -> Vec<u8> {
let fill = if light_card { [0xf1, 0xf3, 0xf4] } else { [0x20, 0x21, 0x24] };
let mut card = RgbaImage::new(BSZ, BSZ);
for y in 0..BSZ { for x in 0..BSZ { let a = (rounded_cov(x, y, BSZ as f32, 6.0) * 255.0).round() as u8; card.put_pixel(x, y, Rgba([fill[0], fill[1], fill[2], a])); } }
// cover-fit into the inset box, centred (drawImage semantics: no clip, the canvas edge clips)
let bx = (BSZ - FIN * 2) as f32;
let (iw, ih) = (icon.width().max(1) as f32, icon.height().max(1) as f32);
let sc = (bx / iw).max(bx / ih);
let (dw, dh) = (((iw * sc).round() as u32).max(1), ((ih * sc).round() as u32).max(1));
let scaled = imageops::resize(icon, dw, dh, imageops::FilterType::Lanczos3);
let (ox, oy) = ((FIN as f32 + (bx - dw as f32) / 2.0).round() as i64, (FIN as f32 + (bx - dh as f32) / 2.0).round() as i64);
imageops::overlay(&mut card, &scaled, ox, oy);
let out = if pct < 100 {
let h = (((BSZ as f32) * pct as f32 / 100.0).round() as u32).max(8);
let small = imageops::resize(&card, h, h, imageops::FilterType::Lanczos3);
let mut c2 = RgbaImage::new(BSZ, BSZ);
imageops::overlay(&mut c2, &small, (BSZ - h) as i64, 0); // north-east, the tile text stays clear
c2
} else { card };
crate::png_encode_rgba(&out.into_raw(), BSZ, BSZ)
}
/// Crop a PNG (device pixels) to a rect; used by the extension lane's annotate capture, where the
/// whole viewport is captured and the clip is applied here.
pub fn crop_png(png: &[u8], x: u32, y: u32, w: u32, h: u32) -> Option<Vec<u8>> {
let img = image::load_from_memory(png).ok()?.to_rgba8();
if img.width() == 0 || img.height() == 0 { return None; }
let x = x.min(img.width() - 1); let y = y.min(img.height() - 1);
let w = w.max(1).min(img.width() - x); let h = h.max(1).min(img.height() - y);
let sub = imageops::crop_imm(&img, x, y, w, h).to_image();
Some(crate::png_encode_rgba(&sub.into_raw(), w, h))
}
/// The annotate capture with its caption strip (26 px, #0d1117, #00d4cb text) and a 320 px-wide preview,
/// composed in the binary (2.0.569; it was an in-page canvas, which a page CSP refused).
/// Returns (png, preview_rgba, preview_w, preview_h). The preview is RAW RGBA, not PNG: the overlay
/// paints it with putImageData (its v2.0.135 rule, so a page CSP can never veto it as an image load).
/// 2.0.573: the first port handed it a PNG, ImageData threw on the byte length, and John got a toast
/// with no thumbnail ("why the fuck do you keep regressing").
pub fn annotate_compose(png: &[u8], caption: Option<&str>) -> Option<(Vec<u8>, Vec<u8>, u32, u32)> {
let img = image::load_from_memory(png).ok()?.to_rgba8();
let (w, h) = (img.width().max(1), img.height().max(1));
let bar = if caption.is_some() { 26 } else { 0 };
let mut out = RgbaImage::new(w, h + bar);
imageops::overlay(&mut out, &img, 0, 0);
if let Some(text) = caption {
for y in h..h + bar { for x in 0..w { out.put_pixel(x, y, Rgba([0x0d, 0x11, 0x17, 255])); } }
if let Some(f) = crate::tile::font_handle() {
let mut buf = out.into_raw();
crate::tile::draw_text(&f, &mut buf, w, h + bar, text, 12.0, 8.0, h as f32 + bar as f32 / 2.0 + 4.0, [0x00, 0xd4, 0xcb]);
out = RgbaImage::from_raw(w, h + bar, buf)?;
}
}
let pw = w.min(320); let ph = ((h as f32) * pw as f32 / w as f32).round().max(1.0) as u32;
let preview = imageops::resize(&img, pw, ph, imageops::FilterType::Triangle);
Some((crate::png_encode_rgba(&out.into_raw(), w, h + bar), preview.into_raw(), pw, ph))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composes_generic_and_a_real_icon() {
for is_pdf in [false, true] {
let g = generic(is_pdf); assert_eq!((g.width(), g.height()), (48, 48));
let a = analyse(&g); assert!(!a.dark_dominant || !a.transparent);
let png = compose(&g, 75, false); assert!(png.len() > 200 && png.starts_with(b"\x89PNG"));
let png100 = compose(&g, 100, true); assert!(png100.len() > 200);
}
// a dark, transparent icon (the GitHub-cat case) must pick the light card
let mut cat = RgbaImage::new(32, 32);
for y in 8..24 { for x in 8..24 { cat.put_pixel(x, y, Rgba([20, 20, 20, 255])); } }
let a = analyse(&cat); assert!(a.dark_dominant && a.transparent, "{:?} {} {}", a.luminance, a.transparent, a.dark_dominant);
let dir = std::env::temp_dir().join("pup-badge-samples"); let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("cat-75.png"), compose(&cat, 75, a.dark_dominant && a.transparent)).unwrap();
std::fs::write(dir.join("web-75.png"), compose(&generic(false), 75, false)).unwrap();
std::fs::write(dir.join("web-100.png"), compose(&generic(false), 100, false)).unwrap();
}
}