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.730 (insiders): pup_eval and pup_login work on strict pages again (pup-bridge#130): American Express's login page…
705edb1
21d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
//! Recording. Tab recording (pup_record_*): CDP screencast frames to JPEGs, muxed to WebM by
//! ffmpeg when the box has it, else packed as an MJPEG AVI (pure Rust, no dependency) so a video
//! always comes out. The Chrome screen-share recorder (pup_screen_record_* / pup_recorder_*): the
//! embedded recorder.html in its own `_recorder` profile window, WebM chunks streamed over a
//! binding. ab-core owns whole-desktop recording natively (desktop_record_*) since ab 2.1.36.
use crate::input::resolve_tab;
use crate::{browser_for, Ctx};
use pup_core::{err, ok, Caller};
use pup_cdp::Cdp;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub struct TabRec { pub id: String, pub session: String, pub tab_id: String, pub cdp_session: String, pub frames_dir: PathBuf, pub file: PathBuf, pub fps: u32, pub started: Instant, pub frames: u32, pub stamps: Vec<f64>, pub sizes: Vec<(u32, u32)> }
pub type TabRecs = Arc<Mutex<HashMap<String, TabRec>>>; // by session id
pub fn recordings_dir(ctx: &Ctx) -> PathBuf { ctx.recordings_dir() }
pub fn ffmpeg_path() -> Option<PathBuf> {
if std::process::Command::new("ffmpeg").arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().map(|s| s.success()).unwrap_or(false) { return Some(PathBuf::from("ffmpeg")); }
let lad = std::env::var("LOCALAPPDATA").unwrap_or_default();
for p in ["C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe", "C:\\ffmpeg\\bin\\ffmpeg.exe", "C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] { if Path::new(p).is_file() { return Some(PathBuf::from(p)); } }
if let Ok(rd) = std::fs::read_dir(format!("{lad}\\Microsoft\\WinGet\\Packages")) { for e in rd.flatten() { if e.file_name().to_string_lossy().starts_with("Gyan.FFmpeg") { if let Ok(rd2) = std::fs::read_dir(e.path()) { for f in rd2.flatten() { let c = f.path().join("bin").join("ffmpeg.exe"); if c.is_file() { return Some(c); } } } } } }
None
}
/// Frames arrive on the event pump (Page.screencastFrame); this is the handler it calls.
pub fn on_frame(recs: &TabRecs, cdp: &Cdp, cdp_session: &str, params: &Value) {
let ack = params["sessionId"].clone();
let acked = cdp.send(Some(cdp_session), "Page.screencastFrameAck", json!({ "sessionId": ack }), Duration::from_secs(3));
let mut m = recs.lock().unwrap();
if let Err(e) = acked { if let Some(r) = m.values().find(|r| r.cdp_session == cdp_session) { if r.frames < 3 { pup_core::log(format!("[{}] ack error: {e}", r.id)); } } }
let Some(r) = m.values_mut().find(|r| r.cdp_session == cdp_session) else { return };
let Some(b64) = params["data"].as_str() else { return };
let Ok(bytes) = pup_cdp::base64_decode(b64) else { return };
r.frames += 1;
if r.frames == 1 { pup_core::log(format!("[{}] FIRST screencast frame ({} bytes)", r.id, bytes.len())); }
let name = format!("f{:06}.jpg", r.frames);
let _ = std::fs::write(r.frames_dir.join(name), &bytes);
r.stamps.push(params["metadata"]["timestamp"].as_f64().unwrap_or(r.started.elapsed().as_secs_f64()));
let (w, h) = (params["metadata"]["deviceWidth"].as_u64().unwrap_or(0) as u32, params["metadata"]["deviceHeight"].as_u64().unwrap_or(0) as u32);
if w > 0 && h > 0 { r.sizes.push((w, h)); }
}
// ── 2.0.581: recording rides ab's native recorder (Windows Graphics Capture + hardware H.264, MP4) ─────────
// Measured 2026-09-15 on AdomLapper: a pup window buried under a maximized Edge recorded live at its native
// 2542x1520, 2.1 MB for 20 s, frames only when the content changed. The CDP screencast (10 fps JPEG over the
// protocol socket, WebM, CDP lane only) stays ONLY as the fallback where ab's recorder does not exist.
// Keying on the hwnd is also what makes recording work on the extension lane.
static AB_RECS: Mutex<Option<std::collections::HashMap<String, (String, String)>>> = Mutex::new(None); // session -> (recordingId, path)
fn ab_data(r: Value) -> Value {
let o = r.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).unwrap_or(r);
if o.get("data").map(|d| d.is_object()).unwrap_or(false) { o["data"].clone() } else { o }
}
fn ab_rec_take(sid: &str) -> Option<(String, String)> { AB_RECS.lock().unwrap().as_mut().and_then(|m| m.remove(sid)) }
fn ab_rec_get(sid: &str) -> Option<(String, String)> { AB_RECS.lock().unwrap().as_ref().and_then(|m| m.get(sid).cloned()) }
pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s0 = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
if ab_rec_get(&s0.id).is_some() || ctx.tab_recs.lock().unwrap().contains_key(&s0.id) { return err("recording_in_progress", format!("window \"{}\" is already recording", s0.id), Some("pup_record_stop first.")); }
let hwnd = s0.hwnd.or_else(|| { crate::extdrive::rebind_unbound(ctx); ctx.sessions.get(&s0.id).and_then(|x| x.hwnd) });
if let Some(h) = hwnd {
let fps = args["fps"].as_u64().unwrap_or(30).clamp(1, 60);
let max_ms = args["maxDurationMs"].as_u64().unwrap_or(args["minutes"].as_u64().unwrap_or(20).clamp(1, 120) * 60_000);
let reason = args["reason"].as_str().map(|r| r.to_string()).unwrap_or_else(|| format!("{} is recording the pup window \"{}\"", if caller.thread.is_empty() { "an AI thread" } else { &caller.thread }, s0.id));
match ctx.ab.call("desktop_record_window_start", json!({ "hwnd": h, "fps": fps, "maxDurationMs": max_ms, "reason": reason }), caller, Duration::from_secs(30)) {
Ok(r) => {
let d = ab_data(r);
if let Some(rid) = d["recordingId"].as_str() {
let path = d["path"].as_str().unwrap_or("").replace('\\', "/");
AB_RECS.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(s0.id.clone(), (rid.to_string(), path.clone()));
pup_core::log(format!("[{rid}] ab native recorder on window {h} for \"{}\" @ {fps}fps -> {path}", s0.id));
let body = json!({ "ok": true, "recordingId": rid, "sessionId": s0.id, "tabId": s0.active().map(|t| t.tab_id.clone()), "fps": fps, "filePath": path, "width": d["width"], "height": d["height"], "engine": "ab native recorder (Windows Graphics Capture, hardware H.264 MP4)" });
return ok(json!({ "success": true, "output": body.to_string(), "recordingId": rid, "sessionId": s0.id, "fps": fps, "filePath": path, "width": d["width"], "height": d["height"], "engine": "ab",
"_hint": "Recording the whole pup window through ab's native recorder (background is fine; a covered window still records). Frames are emitted only when the content changes, so a static page yields few frames and a small file. pup_record_stop {} returns the MP4 path; pull it with adom-bridge pull_file {filePaths:[path]}." }));
}
pup_core::log(format!("[record] ab recorder answered without a recordingId ({}); falling back to the CDP screencast", d.to_string().chars().take(160).collect::<String>()));
}
Err(e) => pup_core::log(format!("[record] ab recorder unavailable ({e}); falling back to the CDP screencast")),
}
}
if crate::transport::is_ext(&s0) {
return err("recorder_unavailable", "this window's recording needs ab's native recorder (desktop_record_window_start), which did not answer", Some("Update Adom Bridge to 2.1.35+ (the native recorder). Until then, take pup_screenshot frames, or ask the user to update Adom Bridge."));
}
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
if ctx.tab_recs.lock().unwrap().contains_key(&s.id) { return err("recording_in_progress", format!("window \"{}\" is already recording", s.id), Some("pup_record_stop first.")); }
let fps = args["fps"].as_u64().unwrap_or(30).clamp(1, 60) as u32;
let quality = args["quality"].as_u64().unwrap_or(85).clamp(10, 100);
let ts = pup_core::now_ms();
let file = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.webm", pup_session::sanitize_sid(&s.id), t.tab_id));
let frames_dir = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.frames", pup_session::sanitize_sid(&s.id), t.tab_id));
let _ = std::fs::create_dir_all(&frames_dir);
let id = format!("rec-{}", ts % 100000);
ctx.tab_recs.lock().unwrap().insert(s.id.clone(), TabRec { id: id.clone(), session: s.id.clone(), tab_id: t.tab_id.clone(), cdp_session: t.cdp_session.clone(), frames_dir, file: file.clone(), fps, started: Instant::now(), frames: 0, stamps: Vec::new(), sizes: Vec::new() });
let every = (60 / fps).max(1);
// Node: page.bringToFront() before the screencast so Chrome's compositor stays warm — an occluded,
// never-activated tab emits no screencast frames at all. Re-park the OS window right after.
let _ = b.cdp.send(Some(&t.cdp_session), "Page.enable", json!({}), Duration::from_secs(5));
let _ = b.cdp.send(Some(&t.cdp_session), "Page.bringToFront", json!({}), Duration::from_secs(5));
pup_core::log(format!("[{id}] screencast on session {} (target {}) url={}", &t.cdp_session[..t.cdp_session.len().min(12)], &t.target_id[..t.target_id.len().min(12)], t.url));
let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true);
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.startScreencast", json!({ "format": "jpeg", "quality": quality, "everyNthFrame": every }), Duration::from_secs(10)) { ctx.tab_recs.lock().unwrap().remove(&s.id); return err("record_start_failed", e.to_string(), None); }
crate::highfps::grant(ctx, &s.id, args["minutes"].as_u64().unwrap_or(20));
pup_core::log(format!("[{id}] CDP screencast started @ {fps}fps on \"{}\" -> {}", s.id, file.display()));
ok(json!({ "success": true, "output": json!({ "ok": true, "recordingId": id, "sessionId": s.id, "tabId": t.tab_id, "fps": fps, "filePath": file.to_string_lossy().replace('\\', "/"), "encoder": if ffmpeg_path().is_some() { "ffmpeg (VP9 WebM)" } else { "built-in (MJPEG AVI, ffmpeg not found)" } }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "recordingId": id,
"_hint": "Recording this tab's page content (background is fine). Drive the page, then pup_record_stop {} returns the video path. The window rides a highFps lease while recording." }))
}
/// 2.0.649 (pup-bridge#105): the encode is a JOB. A VP9 mux that outlives the caller's timeout used to vanish
/// from status ("recording:false, current:null") while ffmpeg was still writing, and an early pull got a
/// truncated file that looked ready. Now stop returns at once with artifactReady:false when the mux is slow,
/// status reports the job, and a later stop or status hands back the finished file with its final size.
static ENC_JOBS: Mutex<Option<HashMap<String, Value>>> = Mutex::new(None); // by recordingId
fn job_set(id: &str, v: Value) { let mut g = ENC_JOBS.lock().unwrap(); g.get_or_insert_with(HashMap::new).insert(id.to_string(), v); }
fn job_get(id: &str) -> Option<Value> { ENC_JOBS.lock().unwrap().as_ref().and_then(|m| m.get(id).cloned()) }
fn jobs_for_session(sid: &str) -> Vec<Value> { ENC_JOBS.lock().unwrap().as_ref().map(|m| m.values().filter(|v| v["sessionId"].as_str() == Some(sid)).cloned().collect()).unwrap_or_default() }
fn finished_reply(j: &Value) -> Value {
let body = json!({ "ok": true, "recordingId": j["recordingId"], "filePath": j["filePath"], "bytes": j["bytes"], "sizeKB": j["bytes"].as_u64().unwrap_or(0) / 1024, "frames": j["frames"], "durationMs": j["durationMs"], "fps": j["fps"], "encoder": j["encoder"], "artifactReady": true });
ok(json!({ "success": true, "output": body.to_string(), "recordingId": j["recordingId"], "filePath": j["filePath"], "bytes": j["bytes"], "frames": j["frames"], "durationMs": j["durationMs"], "encoder": j["encoder"], "artifactReady": true, "phase": "done", "_hint": "Encoded. Pull it with adom-bridge pull_file {filePaths:[filePath]}; bytes is the final size." }))
}
pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
if let Some((rid, path0)) = ab_rec_take(&s.id) {
return match ctx.ab.call("desktop_record_window_stop", json!({ "recordingId": rid }), caller, Duration::from_secs(60)) {
Ok(r) => {
let d = ab_data(r);
let path = d["path"].as_str().map(|p| p.replace('\\', "/")).unwrap_or(path0);
let bytes = d["bytes"].as_u64().unwrap_or(0);
pup_core::log(format!("[{rid}] ab recorder stopped: {} frames, {} ms, {bytes} bytes -> {path}", d["frames"], d["elapsedMs"]));
let frames = d["frames"].as_u64().unwrap_or(0); let dur = d["elapsedMs"].as_u64().unwrap_or(0);
let body = json!({ "ok": true, "recordingId": rid, "filePath": path, "bytes": bytes, "sizeKB": bytes / 1024, "frames": frames, "durationMs": dur, "engine": "ab native recorder (H.264 MP4)" });
// the numbers ride at the TOP level too: the CLI drops a non-string `output`, and callers (ab-smoke) read frames there
ok(json!({ "success": true, "output": body.to_string(), "recordingId": rid, "filePath": path, "bytes": bytes, "sizeKB": bytes / 1024, "frames": frames, "recordedMs": dur, "engine": "ab",
"_hint": "Saved as H.264 MP4 by ab's native recorder. Pull it with adom-bridge pull_file {filePaths:[path]}. Frame count reflects content changes, not wall-clock: a static page records few frames by design." }))
}
Err(e) => err("record_stop_failed", format!("ab's recorder did not stop {rid}: {e}"), Some("desktop_record_window_status lists what ab still has running.")),
};
}
let Some(rec) = ctx.tab_recs.lock().unwrap().remove(&s.id) else {
// a stop retry after a timed-out stop: hand back the job, never restart an encode
if let Some(j) = jobs_for_session(&s.id).into_iter().max_by_key(|j| j["startedMs"].as_u64().unwrap_or(0)) {
return match j["phase"].as_str() { Some("done") => finished_reply(&j), Some("error") => err("mux_failed", j["error"].as_str().unwrap_or("encode failed").to_string(), None), _ => ok(json!({ "success": true, "recordingId": j["recordingId"], "filePath": j["filePath"], "artifactReady": false, "phase": "encoding", "statusVerb": "pup_record_status", "_hint": "Still encoding; the file on disk is partial. Poll pup_record_status until artifactReady:true." })) };
}
return err("no_recording", format!("window \"{}\" is not recording", s.id), None)
};
if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.send(Some(&rec.cdp_session), "Page.stopScreencast", json!({}), Duration::from_secs(5)); }
let duration_ms = rec.started.elapsed().as_millis() as u64;
if rec.frames == 0 { let _ = std::fs::remove_dir_all(&rec.frames_dir); return err("no_frames", "zero frames captured — no video produced", Some("Chrome's screencast only emits a frame when the page PAINTS: a static page yields none however long you record (a pup_render_html page whose inline script the tab origin's CSP blocked is static too: render on about:blank) (measured: the same window records an animated page fine). Make the page change while recording (scroll, navigate, drive it), or use pup_screen_record_start / ab's native desktop_record_start for a wall-clock capture.")); }
let actual_fps = rec.frames as f64 / (duration_ms.max(1) as f64 / 1000.0);
let rid = rec.id.clone(); let sid = s.id.clone();
let file_guess = rec.file.to_string_lossy().replace('\\', "/");
job_set(&rid, json!({ "recordingId": rid, "sessionId": sid, "phase": "encoding", "startedMs": pup_core::now_ms(), "frames": rec.frames, "durationMs": duration_ms, "fps": actual_fps, "filePath": file_guess }));
let rid2 = rid.clone(); let sid2 = sid.clone();
std::thread::Builder::new().name("record-encode".into()).spawn(move || {
let (res, encoder): (Result<PathBuf, String>, String) = match ffmpeg_path() {
Some(ff) => match mux_ffmpeg(&ff, &rec, actual_fps) { Ok(()) => (Ok(rec.file.clone()), "ffmpeg VP9 WebM".into()), Err(e) => { pup_core::log(format!("[{}] ffmpeg mux failed: {e} — packing MJPEG AVI instead", rec.id)); let avi = rec.file.with_extension("avi"); (pack_avi(&rec, &avi, actual_fps).map(|_| avi), "built-in MJPEG AVI (ffmpeg failed)".into()) } },
None => { let avi = rec.file.with_extension("avi"); (pack_avi(&rec, &avi, actual_fps).map(|_| avi), "built-in MJPEG AVI (no ffmpeg on this box)".into()) }
};
let _ = std::fs::remove_dir_all(&rec.frames_dir);
match res {
Ok(out_path) => {
let size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
pup_core::log(format!("[{}] {} frames over {:.1}s -> {} ({size} bytes, {encoder})", rec.id, rec.frames, duration_ms as f64 / 1000.0, out_path.display()));
job_set(&rid2, json!({ "recordingId": rid2, "sessionId": sid2, "phase": "done", "startedMs": pup_core::now_ms(), "frames": rec.frames, "durationMs": duration_ms, "fps": actual_fps, "filePath": out_path.to_string_lossy().replace('\\', "/"), "bytes": size, "encoder": encoder }));
}
Err(e) => job_set(&rid2, json!({ "recordingId": rid2, "sessionId": sid2, "phase": "error", "startedMs": pup_core::now_ms(), "error": e })),
}
}).ok();
// wait a bounded time for a fast encode, then hand the job back pollable
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(20) {
if let Some(j) = job_get(&rid) { match j["phase"].as_str() { Some("done") => return finished_reply(&j), Some("error") => return err("mux_failed", j["error"].as_str().unwrap_or("encode failed").to_string(), None), _ => {} } }
std::thread::sleep(Duration::from_millis(250));
}
ok(json!({ "success": true, "recordingId": rid, "filePath": file_guess, "durationMs": duration_ms, "artifactReady": false, "phase": "encoding", "statusVerb": "pup_record_status",
"_hint": "Capture stopped; the encoder is still running and the file on disk is PARTIAL. Poll pup_record_status (jobs[] / artifactReady) or call pup_record_stop again: it returns the finished file with its final bytes, never a second encode." }))
}
fn mux_ffmpeg(ff: &Path, rec: &TabRec, actual_fps: f64) -> Result<(), String> {
let mut lines = Vec::new();
for i in 0..rec.frames as usize {
lines.push(format!("file 'f{:06}.jpg'", i + 1));
let dur = match (rec.stamps.get(i), rec.stamps.get(i + 1)) { (Some(a), Some(b)) => (b - a).max(0.001), _ => (1.0 / actual_fps.max(1.0)).max(0.001) };
lines.push(format!("duration {dur:.4}"));
}
lines.push(format!("file 'f{:06}.jpg'", rec.frames));
let concat = rec.frames_dir.join("concat.txt");
std::fs::write(&concat, lines.join("\n")).map_err(|e| e.to_string())?;
let out = std::process::Command::new(ff).args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"]).arg(&concat).args(["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]).arg(&rec.file).output().map_err(|e| e.to_string())?;
if out.status.success() && rec.file.is_file() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).chars().take(300).collect()) }
}
/// MJPEG in a RIFF AVI (the simplest container every player and browser reads).
fn pack_avi(rec: &TabRec, out: &Path, fps: f64) -> Result<(), String> {
let (w, h) = rec.sizes.last().cloned().unwrap_or((1280, 720));
let mut frames: Vec<Vec<u8>> = Vec::with_capacity(rec.frames as usize);
for i in 1..=rec.frames { let p = rec.frames_dir.join(format!("f{i:06}.jpg")); if let Ok(b) = std::fs::read(p) { frames.push(b); } }
if frames.is_empty() { return Err("no frames on disk".into()); }
let fps_u = fps.round().clamp(1.0, 60.0) as u32;
let mut movi = Vec::new(); let mut idx = Vec::new();
for f in &frames {
let off = movi.len() as u32 + 4;
movi.extend_from_slice(b"00dc"); movi.extend_from_slice(&(f.len() as u32).to_le_bytes()); movi.extend_from_slice(f); if f.len() % 2 == 1 { movi.push(0); }
idx.extend_from_slice(b"00dc"); idx.extend_from_slice(&0x10u32.to_le_bytes()); idx.extend_from_slice(&off.to_le_bytes()); idx.extend_from_slice(&(f.len() as u32).to_le_bytes());
}
let mut avih = Vec::new();
avih.extend_from_slice(&(1_000_000 / fps_u).to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&0x10u32.to_le_bytes());
avih.extend_from_slice(&(frames.len() as u32).to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&1u32.to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes());
avih.extend_from_slice(&w.to_le_bytes()); avih.extend_from_slice(&h.to_le_bytes()); avih.extend_from_slice(&[0u8; 16]);
let mut strh = Vec::new();
strh.extend_from_slice(b"vids"); strh.extend_from_slice(b"MJPG"); strh.extend_from_slice(&[0u8; 12]); strh.extend_from_slice(&1u32.to_le_bytes()); strh.extend_from_slice(&fps_u.to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&(frames.len() as u32).to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&[0u8, 0, 0, 0, (w & 0xff) as u8, (w >> 8) as u8, (h & 0xff) as u8, (h >> 8) as u8]);
let mut strf = Vec::new();
strf.extend_from_slice(&40u32.to_le_bytes()); strf.extend_from_slice(&(w as i32).to_le_bytes()); strf.extend_from_slice(&(h as i32).to_le_bytes()); strf.extend_from_slice(&1u16.to_le_bytes()); strf.extend_from_slice(&24u16.to_le_bytes()); strf.extend_from_slice(b"MJPG"); strf.extend_from_slice(&(w * h * 3).to_le_bytes()); strf.extend_from_slice(&[0u8; 16]);
let chunk = |id: &[u8], body: &[u8]| { let mut v = Vec::new(); v.extend_from_slice(id); v.extend_from_slice(&(body.len() as u32).to_le_bytes()); v.extend_from_slice(body); if body.len() % 2 == 1 { v.push(0); } v };
let list = |kind: &[u8], body: &[u8]| { let mut inner = kind.to_vec(); inner.extend_from_slice(body); chunk(b"LIST", &inner) };
let strl = list(b"strl", &[chunk(b"strh", &strh), chunk(b"strf", &strf)].concat());
let hdrl = list(b"hdrl", &[chunk(b"avih", &avih), strl].concat());
let movi_list = list(b"movi", &movi);
let idx1 = chunk(b"idx1", &idx);
let mut riff_body = b"AVI ".to_vec(); riff_body.extend_from_slice(&hdrl); riff_body.extend_from_slice(&movi_list); riff_body.extend_from_slice(&idx1);
let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;
f.write_all(b"RIFF").and_then(|_| f.write_all(&(riff_body.len() as u32).to_le_bytes())).and_then(|_| f.write_all(&riff_body)).map_err(|e| e.to_string())
}
pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
if let Some((rid, path)) = ab_rec_get(&s.id) {
let live = ctx.ab.call("desktop_record_window_status", json!({}), caller, Duration::from_secs(15)).ok().map(ab_data)
.and_then(|d| d["recordings"].as_array().cloned()).unwrap_or_default().into_iter().find(|r| r["recordingId"].as_str() == Some(rid.as_str()));
let cur = json!({ "recordingId": rid, "filePath": path, "engine": "ab", "ab": live });
return ok(json!({ "success": true, "active": [cur.clone()], "output": json!({ "ok": true, "active": [cur.clone()], "sessionId": s.id, "recording": true, "current": cur }).to_string(), "sessionId": s.id, "window": s.id }));
}
let m = ctx.tab_recs.lock().unwrap();
let cur = m.get(&s.id).map(|r| json!({ "recordingId": r.id, "tabId": r.tab_id, "fps": r.fps, "frames": r.frames, "durationMs": r.started.elapsed().as_millis() as u64, "filePath": r.file.to_string_lossy().replace('\\', "/") }));
let active = cur.clone().map(|c| vec![c]).unwrap_or_default();
let jobs = jobs_for_session(&s.id); let encoding = jobs.iter().any(|j| j["phase"] == "encoding");
ok(json!({ "success": true, "active": active, "jobs": jobs, "encoding": encoding, "artifactReady": !encoding && cur.is_none(), "output": json!({ "ok": true, "active": active, "sessionId": s.id, "recording": cur.is_some(), "current": cur }).to_string(), "sessionId": s.id, "window": s.id, "recording": cur.is_some() }))
}
pub fn list(ctx: &Ctx) -> Value {
let mut rows = Vec::new();
if let Ok(rd) = std::fs::read_dir(recordings_dir(ctx)) { for e in rd.flatten() { let p = e.path(); let ext = p.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default(); if ["webm", "avi", "mp4"].contains(&ext.as_str()) { if let Ok(md) = p.metadata() { rows.push(json!({ "filePath": p.to_string_lossy().replace('\\', "/"), "sizeKB": md.len() / 1024, "mtime": md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64) })); } } } }
if let Ok(r) = ctx.ab.call("desktop_record_list", json!({}), &crate::identity::internal_caller(), Duration::from_secs(15)) {
for f in ab_data(r)["recordings"].as_array().cloned().unwrap_or_default() {
rows.push(json!({ "file": f["file"], "path": f["path"].as_str().unwrap_or("").replace('\\', "/"), "bytes": f["bytes"], "kind": f["kind"], "modified": f["modified"], "engine": "ab", "mtime": 0 }));
}
}
rows.sort_by(|a, b| b["mtime"].as_u64().cmp(&a["mtime"].as_u64()));
ok(json!({ "success": true, "output": json!({ "ok": true, "recordings": rows }).to_string(), "recordings": rows, "count": rows.len() }))
}
// ── the Chrome screen-share recorder (pup_screen_record_* / pup_recorder_*) ────────────
pub struct ScreenRec { pub cdp: Cdp, pub cdp_session: String, pub pid: u32, pub reason: String, pub current: Option<(String, PathBuf, std::fs::File, Instant, u32)>, pub clips: Vec<Value>, pub counter: u32 }
pub type ScreenRecState = Arc<Mutex<Option<ScreenRec>>>;
pub const SCREEN_HINT: &str = "Recording the DESKTOP: point the user to ab first. Adom Bridge's native recorder (desktop_record_start / desktop_record_stop, ab 2.1.36+) captures a whole monitor or one window with Windows Graphics Capture plus system audio to MP4, goes aggressive on GPU acceleration and offloads frames to the GPU when the machine has one, and needs no browser and no consent HUD. THIS verb is pup's fallback: a Google Meet style screen share (getDisplayMedia into WebM VP9, consent = the visible recorder HUD, system audio + mic mixing) that works on a very broad range of machines, including ones where the native path is unavailable. Use it when ab's recorder is missing or refuses; do not present desktop recording as pup's feature.";
fn recorder_html_path(ctx: &Ctx) -> PathBuf { let p = ctx.host.temp_dir().join("pup-recorder").join("recorder.html"); if !p.is_file() { let _ = std::fs::create_dir_all(p.parent().unwrap()); if let Some(b) = crate::assets_recorder_html() { let _ = std::fs::write(&p, b); } } p }
fn ensure_hud(ctx: &Ctx, reason: &str) -> Result<(), Value> {
if reason.trim().is_empty() { return Err(err("reason_required", "reason is required — explain to the user why you're recording", Some("It is rendered in the recorder HUD title; a recording never starts silently."))); }
let mut st = ctx.screen_rec.lock().unwrap();
if let Some(r) = st.as_mut() { if r.cdp.is_alive() { let _ = r.cdp.evaluate(&r.cdp_session, &format!("window.setSessionReason && window.setSessionReason({})", serde_json::to_string(reason).unwrap()), Duration::from_secs(5)); r.reason = reason.into(); return Ok(()); } }
*st = None;
let cand = pup_browser::detect_cft(&*ctx.host).or_else(|| pup_browser::list_system_browsers(&*ctx.host).into_iter().next()).ok_or_else(|| err("no_browser", "no browser to host the recorder HUD", None))?;
let profile_dir = pup_browser::profiles_dir(&*ctx.host).join("_recorder");
let _ = std::fs::create_dir_all(&profile_dir);
let html = recorder_html_path(ctx);
let url = format!("file:///{}?reason={}", html.to_string_lossy().replace('\\', "/"), reason.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>());
let port_file = profile_dir.join("DevToolsActivePort"); let _ = std::fs::remove_file(&port_file);
let args = vec![format!("--user-data-dir={}", profile_dir.display()), "--remote-debugging-port=0".into(), format!("--app={url}"), "--window-size=420,260".into(), "--use-fake-ui-for-media-stream".into(), "--auto-select-desktop-capture-source=Entire screen".into(), "--enable-features=GetDisplayMediaSet".into(), "--auto-accept-this-tab-capture".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-features=CalculateNativeWinOcclusion,MediaRouter".into()];
let args = [args, pup_browser::platform_switches()].concat();
let pid = ctx.host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| err("recorder_launch_failed", e.to_string(), None))?;
let t0 = Instant::now(); let mut port = None;
while t0.elapsed() < Duration::from_secs(20) { if let Ok(s) = std::fs::read_to_string(&port_file) { if let Some(p) = s.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) { port = Some(p); break; } } std::thread::sleep(Duration::from_millis(200)); }
let port = port.ok_or_else(|| err("recorder_launch_failed", "the recorder window published no DevTools port", None))?;
let cdp = Cdp::connect_port(port, Duration::from_secs(10)).map_err(|e| err("recorder_connect_failed", e.to_string(), None))?;
let target = cdp.targets().unwrap_or_default().into_iter().find(|t| t["type"] == json!("page")).and_then(|t| t["targetId"].as_str().map(|s| s.to_string())).ok_or_else(|| err("recorder_launch_failed", "no page target", None))?;
let sid = cdp.attach(&target).map_err(|e| err("recorder_connect_failed", e.to_string(), None))?;
let _ = cdp.page_enable(&sid);
for name in ["_saveChunk", "_recordingStopped", "_resizeWindow", "_recorderClosed", "_openRecordingsFolder"] { let _ = cdp.send(Some(&sid), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }
let _ = cdp.evaluate(&sid, "(() => { for (const n of ['_saveChunk','_recordingStopped','_resizeWindow','_recorderClosed','_openRecordingsFolder']) { const b = window[n]; window[n] = (a) => b(typeof a === 'string' ? a : JSON.stringify(a)); } return true; })()", Duration::from_secs(5));
// bottom-right, always on top is the HUD's own job; park it out of the way of pup windows
if let Ok((wid, _)) = cdp.window_for_target(&target) { if let Some((x, y, w, h)) = ctx.host.work_area() { let _ = cdp.set_window_bounds(wid, json!({ "left": x + w - 440, "top": y + h - 280, "width": 420, "height": 260 })); } }
let rx = cdp.subscribe(Some(&sid), Some("Runtime.bindingCalled"));
let state = ctx.screen_rec.clone(); let dir = recordings_dir(ctx);
std::thread::Builder::new().name("screen-rec".into()).spawn(move || {
while let Ok(ev) = rx.recv() {
let name = ev.params["name"].as_str().unwrap_or(""); let payload = ev.params["payload"].as_str().unwrap_or("").to_string();
let mut st = state.lock().unwrap();
let Some(r) = st.as_mut() else { break };
match name {
"_saveChunk" => { if let Some((_, _, f, _, n)) = r.current.as_mut() { if let Ok(b) = pup_cdp::base64_decode(payload.trim_matches('"')) { let _ = f.write_all(&b); *n += 1; } } }
"_recordingStopped" => { if let Some((id, path, f, t0, _)) = r.current.take() { drop(f); let ms = t0.elapsed().as_millis() as u64; let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); let _ = std::fs::write(path.with_extension("json"), json!({ "recordingId": id, "reason": r.reason, "durationMs": ms, "sizeKB": size / 1024, "filePath": path.to_string_lossy() }).to_string()); r.clips.push(json!({ "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "durationMs": ms, "sizeKB": size / 1024 })); pup_core::log(format!("[recorder] {id} stopped: {} bytes, {ms}ms", size)); } }
"_recorderClosed" => { pup_core::log("[recorder] HUD closed by the user"); *st = None; break; }
"_openRecordingsFolder" => { let opener = if cfg!(windows) { "explorer.exe" } else if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&dir).spawn(); }
_ => {}
}
}
}).ok();
*st = Some(ScreenRec { cdp, cdp_session: sid, pid, reason: reason.into(), current: None, clips: Vec::new(), counter: 0 });
Ok(())
}
pub fn recorder_open(ctx: &Ctx, args: &Value) -> Value {
if let Err(e) = ensure_hud(ctx, args["reason"].as_str().unwrap_or("")) { return e; }
ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "opened": true, "reason": args["reason"] }).to_string() }))
}
pub fn screen_start(ctx: &Ctx, args: &Value) -> Value {
if args["confirmDesktopNotTabRecording"].as_bool() != Some(true) { return err("desktop_record_needs_confirmation", "pup_screen_record_start records the WHOLE SCREEN through Chrome screen-share, not a pup tab.", Some("For a pup window/tab use pup_record_start (CDP screencast, background-safe). For a native no-browser MP4 use ab-core desktop_record_start. If you truly want the Chrome share picker path, re-call with confirmDesktopNotTabRecording:true.")); }
if let Err(e) = ensure_hud(ctx, args["reason"].as_str().unwrap_or("")) { return e; }
let mut st = ctx.screen_rec.lock().unwrap(); let r = st.as_mut().unwrap();
if r.current.is_some() { return err("recording_in_progress", "a screen recording is already running; stop it first", None); }
r.counter += 1; let id = format!("rec-{}", r.counter);
let path = recordings_dir(ctx).join(format!("rec-desktop-{}.webm", pup_core::now_ms()));
let f = match std::fs::File::create(&path) { Ok(f) => f, Err(e) => return err("recorder_file_failed", e.to_string(), None) };
r.current = Some((id.clone(), path.clone(), f, Instant::now(), 0));
let opts = json!({ "fps": args["fps"].as_u64().unwrap_or(30), "audio": args["audio"].as_bool().unwrap_or(false), "videoBitsPerSecond": args["videoBitsPerSecond"].as_u64().unwrap_or(4_000_000), "reason": r.reason, "maxDurationMs": args["maxDurationMs"].as_u64().unwrap_or(600_000) });
if let Err(e) = r.cdp.evaluate(&r.cdp_session, &format!("window.startRecording && window.startRecording({opts})"), Duration::from_secs(10)) { r.current = None; return err("recorder_start_failed", e.to_string(), None); }
ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "fps": opts["fps"], "audio": opts["audio"] }).to_string(), "recordingId": id }))
}
pub fn screen_stop(ctx: &Ctx) -> Value {
let (cdp, sid, id) = { let st = ctx.screen_rec.lock().unwrap(); let Some(r) = st.as_ref() else { return err("no_recorder", "the recorder HUD is not open", None) }; let Some((id, ..)) = r.current.as_ref() else { return err("no_recording", "no screen recording is running", None) }; (r.cdp.clone(), r.cdp_session.clone(), id.clone()) };
let _ = cdp.evaluate(&sid, "window.stopRecording && window.stopRecording()", Duration::from_secs(10));
let t0 = Instant::now();
loop { { let st = ctx.screen_rec.lock().unwrap(); if let Some(r) = st.as_ref() { if r.current.is_none() { let clip = r.clips.last().cloned().unwrap_or(json!({})); return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": pup_core::merge(json!({ "ok": true, "recordingId": id }), clip.clone()).to_string(), "filePath": clip["filePath"] })); } } else { break; } } if t0.elapsed() > Duration::from_secs(15) { break; } std::thread::sleep(Duration::from_millis(200)); }
err("recorder_stop_timeout", "the recorder did not confirm the stop within 15s", None)
}
pub fn screen_status(ctx: &Ctx) -> Value {
let st = ctx.screen_rec.lock().unwrap();
let (hud, reason, cur, clips) = match st.as_ref() { Some(r) => (r.cdp.is_alive(), Some(r.reason.clone()), r.current.as_ref().map(|(id, p, _, t0, n)| json!({ "recordingId": id, "durationMs": t0.elapsed().as_millis() as u64, "filePath": p.to_string_lossy().replace('\\', "/"), "chunks": n })), r.clips.clone()), None => (false, None, None, vec![]) };
ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "hudOpen": hud, "reason": reason, "currentRecording": cur, "clipsThisSession": clips }).to_string(), "hudOpen": hud }))
}
pub fn recorder_close(ctx: &Ctx) -> Value {
let mut st = ctx.screen_rec.lock().unwrap();
let Some(r) = st.take() else { return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "closed": false }).to_string() })) };
if r.current.is_some() { let _ = r.cdp.evaluate(&r.cdp_session, "window.stopRecording && window.stopRecording()", Duration::from_secs(10)); std::thread::sleep(Duration::from_millis(800)); }
let _ = r.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); r.cdp.close();
if r.pid > 0 { std::thread::sleep(Duration::from_millis(500)); if ctx.host.alive(r.pid) { ctx.host.kill(r.pid); } }
let summary = json!({ "clipCount": r.clips.len(), "totalSizeKB": r.clips.iter().map(|c| c["sizeKB"].as_u64().unwrap_or(0)).sum::<u64>(), "reason": r.reason, "clips": r.clips });
ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "sessionSummary": summary }).to_string() }))
}