← Commit history

v2.0.649 (insiders): pup-bridge#122 DPAPI secret store with migration; #107 selector targeting; #106 screenshot identity metadata + guard; #105 recording encode job

John Lauer ·737d1a1ea3 ·1mo ago ·parent eba41d7
7 files changed +178−51
rust/Cargo.lock+13−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-browser",  "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-core",  "pup-host",@@ -667,7 +667,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.648"+version = "2.0.649" dependencies = [  "serde_json",  "thiserror",@@ -677,7 +677,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.648"+version = "2.0.649" dependencies = [  "serde",  "serde_json",@@ -687,7 +687,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-cdp",  "pup-core",@@ -700,7 +700,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.648"+version = "2.0.649" dependencies = [  "serde",  "serde_json",@@ -709,23 +709,24 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-host", ]  [[package]] name = "pup-host-win"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-core",  "pup-host",+ "serde_json",  "windows-sys 0.59.0", ]  [[package]] name = "pup-identity"-version = "2.0.648"+version = "2.0.649" dependencies = [  "fontdue",  "image",@@ -735,7 +736,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-core",  "serde",@@ -745,7 +746,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.648"+version = "2.0.649" dependencies = [  "pup-core",  "pup-host",@@ -756,7 +757,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.648"+version = "2.0.649" dependencies = [  "base64",  "png 0.17.16",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session",  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.648"+version = "2.0.649" edition = "2021" license = "MIT" 
rust/host-win/Cargo.toml+3
@@ -6,12 +6,15 @@ license.workspace = true description = "The Windows implementation of pup-host (windows-sys). The ONLY crate with Win32 code."  [dependencies]+serde_json = "1" pup-host = { path = "../host" } pup-core = { path = "../core" }  [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [   "Win32_Foundation",+  "Win32_System_Memory",+  "Win32_Security_Cryptography",   "Win32_UI_WindowsAndMessaging",   "Win32_UI_HiDpi",   "Win32_System_Threading",
rust/host-win/src/lib.rs+84−21
@@ -382,34 +382,97 @@ mod imp { #[cfg(windows)] mod secrets {     use super::*;-    use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC};+    use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree, CredReadW, CREDENTIALW, CRED_TYPE_GENERIC};+    use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB};+    use windows_sys::Win32::Foundation::LocalFree;     fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }-    // keytar's layout: TargetName = "<service>/<account>", UserName = account, blob = UTF-8 secret+    // 2.0.649 (pup-bridge#122): ONE DPAPI-protected file holds every secret, instead of one Credential Manager+    // entry per login. Windows caps the per-user credential set: after a full browser import (871 logins) every+    // CredWriteW answered ERROR_NOT_ENOUGH_MEMORY (8) on John's laptop, even for a 1-byte probe. DPAPI+    // (CryptProtectData, current user, UI forbidden) has no such cap, is the same per-user key Chrome's own+    // password store uses, and stays encrypted at rest. Legacy Credential Manager entries are read once,+    // moved into the file, and deleted, which also frees the slots the import had consumed.+    const STORE: &str = "pup-vault.bin";+    static CACHE: std::sync::Mutex<Option<std::collections::BTreeMap<String, String>>> = std::sync::Mutex::new(None);+    fn store_path(h: &WinHost) -> std::path::PathBuf { h.state_dir().join(STORE) }+    fn dpapi(data: &[u8], protect: bool) -> Option<Vec<u8>> {+        unsafe {+            let mut input = CRYPT_INTEGER_BLOB { cbData: data.len() as u32, pbData: data.as_ptr() as *mut u8 };+            let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: std::ptr::null_mut() };+            let ok = if protect { CryptProtectData(&mut input, std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), 0x1 /* CRYPTPROTECT_UI_FORBIDDEN */, &mut out) }+                     else { CryptUnprotectData(&mut input, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), 0x1, &mut out) };+            if ok == 0 || out.pbData.is_null() { return None; }+            let v = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();+            LocalFree(out.pbData as _);+            Some(v)+        }+    }+    fn load(h: &WinHost) -> std::collections::BTreeMap<String, String> {+        let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());+        if let Some(m) = g.as_ref() { return m.clone(); }+        let mut m: std::collections::BTreeMap<String, String> = std::fs::read(store_path(h)).ok().and_then(|b| dpapi(&b, false)).and_then(|j| serde_json::from_slice(&j).ok()).unwrap_or_default();+        // one-time migration: every index entry still in Credential Manager moves here and its slot is freed+        let index = h.state_dir().join("pup-credentials.index.json");+        if let Ok(txt) = std::fs::read_to_string(&index) {+            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {+                let mut moved = 0usize;+                for e in v["entries"].as_array().cloned().unwrap_or_default() {+                    let acct = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("").to_string();+                    if acct.is_empty() { continue; }+                    let key = format!("adom-pup/{acct}");+                    if m.contains_key(&key) { continue; }+                    if let Some(secret) = cred_read(&key) { m.insert(key.clone(), secret); cred_delete(&key); moved += 1; }+                }+                if moved > 0 { let _ = save(h, &m); eprintln!("[vault] moved {moved} login(s) from Windows Credential Manager into the DPAPI store and freed their slots"); }+            }+        }+        *g = Some(m.clone());+        m+    }+    fn save(h: &WinHost, m: &std::collections::BTreeMap<String, String>) -> Result<(), String> {+        let json = serde_json::to_vec(m).map_err(|e| e.to_string())?;+        let enc = dpapi(&json, true).ok_or_else(|| "CryptProtectData failed".to_string())?;+        let p = store_path(h); let tmp = p.with_extension("bin.tmp");+        std::fs::write(&tmp, enc).map_err(|e| format!("write {}: {e}", tmp.display()))?;+        std::fs::rename(&tmp, &p).map_err(|e| format!("rename {}: {e}", p.display()))?;+        *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(m.clone());+        Ok(())+    }+    fn cred_read(target: &str) -> Option<String> {+        let t = wide(target);+        let mut p: *mut CREDENTIALW = std::ptr::null_mut();+        unsafe {+            if CredReadW(t.as_ptr(), CRED_TYPE_GENERIC, 0, &mut p) == 0 || p.is_null() { return None; }+            let c = &*p;+            let bytes = std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize).to_vec();+            CredFree(p as *mut _);+            String::from_utf8(bytes).ok()+        }+    }+    fn cred_delete(target: &str) -> bool { let t = wide(target); unsafe { CredDeleteW(t.as_ptr(), CRED_TYPE_GENERIC, 0) != 0 } }     impl HostSecrets for WinHost {         fn secret_get(&self, service: &str, account: &str) -> Option<String> {-            let target = wide(&format!("{service}/{account}"));-            let mut p: *mut CREDENTIALW = std::ptr::null_mut();-            unsafe {-                if CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut p) == 0 || p.is_null() { return None; }-                let c = &*p;-                let bytes = std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize).to_vec();-                CredFree(p as *mut _);-                String::from_utf8(bytes).ok().or_else(|| None)-            }+            let key = format!("{service}/{account}");+            if let Some(v) = load(self).get(&key) { return Some(v.clone()); }+            // a legacy entry the index did not name: move it too+            let v = cred_read(&key)?;+            let mut m = load(self); m.insert(key.clone(), v.clone());+            if save(self, &m).is_ok() { cred_delete(&key); }+            Some(v)         }         fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> {-            let mut target = wide(&format!("{service}/{account}"));-            let mut user = wide(account);-            let mut blob = secret.as_bytes().to_vec();-            let mut c: CREDENTIALW = unsafe { std::mem::zeroed() };-            c.Type = CRED_TYPE_GENERIC; c.TargetName = target.as_mut_ptr(); c.UserName = user.as_mut_ptr();-            c.CredentialBlob = blob.as_mut_ptr(); c.CredentialBlobSize = blob.len() as u32; c.Persist = CRED_PERSIST_ENTERPRISE;-            let ok = unsafe { CredWriteW(&c, 0) } != 0;-            if ok { Ok(()) } else { Err(format!("CredWriteW failed (error {})", std::io::Error::last_os_error())) }+            let key = format!("{service}/{account}");+            let mut m = load(self); m.insert(key.clone(), secret.to_string());+            save(self, &m)?;+            cred_delete(&key);   // never keep a second copy in the capped store+            Ok(())         }         fn secret_delete(&self, service: &str, account: &str) -> bool {-            let target = wide(&format!("{service}/{account}"));-            unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) != 0 }+            let key = format!("{service}/{account}");+            let mut m = load(self); let had = m.remove(&key).is_some();+            let saved = save(self, &m).is_ok();+            let legacy = cred_delete(&key);+            (had && saved) || legacy         }     } }
rust/verbs/src/input.rs+19−3
@@ -356,7 +356,23 @@ pub fn dialog(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };     let kind = args.get("type").and_then(|x| x.as_str()).unwrap_or("");-    let (x, y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0));+    let (mut x, mut y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0));+    // 2.0.649 (pup-bridge#107): `selector` was advertised and silently ignored (a documented selector-only click+    // landed at 0,0 with ok:true). Resolve it here, on either lane: exactly one match, scrolled into view, the+    // click lands on its centre, and the response says what was matched.+    let mut matched: Value = Value::Null;+    if let Some(sel) = args.get("selector").and_then(|v| v.as_str()).filter(|v| !v.is_empty()) {+        if args.get("x").is_some() || args.get("y").is_some() { return err("args_conflict", "pass selector OR x/y, not both", None); }+        let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };+        let expr = format!("(()=>{{const els=[...document.querySelectorAll({sel})];if(els.length!==1)return JSON.stringify({{count:els.length}});const el=els[0];el.scrollIntoView({{block:'center',inline:'center'}});const r=el.getBoundingClientRect();return JSON.stringify({{count:1,x:r.left+r.width/2,y:r.top+r.height/2,rect:{{left:r.left,top:r.top,width:r.width,height:r.height}},tag:el.tagName,text:(el.innerText||el.value||'').trim().slice(0,80)}})}})()", sel = js_str(sel));+        let v = match crate::transport::evaluate(&lane, &t, &expr) { Ok(v) => v, Err(e) => return e };+        let j: Value = v.as_str().and_then(|x| serde_json::from_str(x).ok()).unwrap_or(v.clone());+        match j["count"].as_u64() {+            Some(1) => { x = j["x"].as_f64().unwrap_or(0.0); y = j["y"].as_f64().unwrap_or(0.0); matched = json!({ "selector": sel, "tag": j["tag"], "text": j["text"], "clickedRect": j["rect"] }); std::thread::sleep(Duration::from_millis(120)); }+            Some(0) | None => return err("selector_not_found", format!("no element matches {sel}"), Some("Check the selector with pup_eval (document.querySelectorAll(...).length) or pass x/y from a bounding rect.")),+            Some(n) => return err("selector_ambiguous", format!("{n} elements match {sel}; a click needs exactly one"), Some("Narrow the selector (an id, an href suffix, :nth-of-type) or pass x/y.")),+        }+    }     // 2.0.648 (pup-bridge#120): TRUSTED input on the extension lane. The worker's "cdp" action drives the same     // Input.dispatchMouseEvent / dispatchKeyEvent through nbe's DevTools driver (chrome.debugger), so the event     // is as trusted as the CDP lane's: it lands in cross-origin frames (the reCAPTCHA iframe) that a page-side@@ -379,7 +395,7 @@ pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             _ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),         };         if let Err(e) = r { return e; }-        return out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "trusted": true, "lane": "ext" }));+        return out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "trusted": true, "lane": "ext", "matched": matched }));     }     let r = match kind {         "click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| click_once(ctx, &b.cdp, &t.cdp_session, x, y, "left", 1, 0)),@@ -395,7 +411,7 @@ pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         _ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),     };     if let Err(e) = r { return e; }-    out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y }))+    out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "matched": matched })) }  pub fn render_html(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/lib.rs+9−3
@@ -1505,7 +1505,7 @@ fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };         let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));         if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }-        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "transport": "ext", "_hint": "captureVisibleTab: the VISIBLE area of the active tab. READ the file before describing it." }));+        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "transport": "ext", "_hint": "captureVisibleTab: the VISIBLE area of the active tab. READ the file before describing it." }));     }     let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };     let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };@@ -1514,7 +1514,13 @@ fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         Ok(p) => p,         Err(e) => {             // OS fallback (Node 2.0.38x): ab captures the window even when the renderer is stuck.-            if let Some(h) = s.hwnd { if let Ok(r) = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": h }), caller, Duration::from_secs(15)) { return pup_core::merge(ok(r), json!({ "capturedVia": "os", "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "_hint": "CDP timed out; this is an OS window capture (window chrome included)." })); } }+            if let Some(h) = s.hwnd {+                // 2.0.649 (pup-bridge#106): the OS capture is by hwnd, the only path that can hand back another window's+                // document. Check the OS title against the tab before capturing; a mismatch is a typed error, not an image.+                let os_title = ctx.host.frame_windows_all(None).into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();+                let want = tab.title.trim();+                if !want.is_empty() && !os_title.contains(want) { return pup_core::merge(err("capture_identity_mismatch", format!("window {h} is titled {os_title:?} but this tab is {want:?}: not capturing another window's document"), Some("pup_window_info shows the session's hwnd and tabs; pup_list_windows shows every window. Re-open the page or pass tabId explicitly.")), json!({ "sessionId": s.id, "hwnd": h, "osTitle": os_title, "tab": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id } })); }+                if let Ok(r) = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": h }), caller, Duration::from_secs(15)) { return pup_core::merge(ok(r), json!({ "capturedVia": "os", "captured": { "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": h, "via": "os-window", "osTitle": os_title }, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "_hint": "CDP timed out; this is an OS window capture (window chrome included)." })); } }             return err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState to see whether the renderer is alive."));         }     };@@ -1522,7 +1528,7 @@ fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };     let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));     if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }-    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "fullPage": full, "capturedVia": "cdp", "_hint": "PNG of the page. READ the image before asserting anything rendered.", "_next": ["pup_eval", "pup_navigate"] }))+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "captured": json!({ "url": tab.url, "title": tab.title, "tabId": tab.tab_id, "windowId": s.window_id, "hwnd": s.hwnd, "via": "tab" }), "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "fullPage": full, "capturedVia": "cdp", "_hint": "PNG of the page. READ the image before asserting anything rendered.", "_next": ["pup_eval", "pup_navigate"] })) }  fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/record.rs+49−11
@@ -110,6 +110,19 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         "_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) {@@ -128,20 +141,44 @@ pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             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 { return err("no_recording", format!("window \"{}\" is not recording", s.id), None) };+    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 (out_path, encoder) = match ffmpeg_path() {-        Some(ff) => match mux_ffmpeg(&ff, &rec, actual_fps) { Ok(()) => (rec.file.clone(), "ffmpeg VP9 WebM".to_string()), Err(e) => { pup_core::log(format!("[{}] ffmpeg mux failed: {e} — packing MJPEG AVI instead", rec.id)); let avi = rec.file.with_extension("avi"); match pack_avi(&rec, &avi, actual_fps) { Ok(()) => (avi, "built-in MJPEG AVI (ffmpeg failed)".into()), Err(e2) => return err("mux_failed", format!("ffmpeg: {e}; avi: {e2}"), None) } } },-        None => { let avi = rec.file.with_extension("avi"); match pack_avi(&rec, &avi, actual_fps) { Ok(()) => (avi, "built-in MJPEG AVI (no ffmpeg on this box)".into()), Err(e) => return err("mux_failed", e, None) } }-    };-    let _ = std::fs::remove_dir_all(&rec.frames_dir);-    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()));-    ok(json!({ "success": true, "output": json!({ "ok": true, "recordingId": rec.id, "filePath": out_path.to_string_lossy().replace('\\', "/"), "frames": rec.frames, "durationMs": duration_ms, "fps": actual_fps.round(), "sizeKB": size / 1024, "encoder": encoder }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "filePath": out_path.to_string_lossy().replace('\\', "/"),-        "_hint": if encoder.contains("AVI") { "Saved as MJPEG AVI because ffmpeg is not installed (winget install Gyan.FFmpeg gives VP9 WebM next time). Pull it with adom-bridge pull_file." } else { "Saved. Pull it with adom-bridge pull_file {filePaths:[...]} or hand the path to the user." } }))+    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> {@@ -201,7 +238,8 @@ pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     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();-    ok(json!({ "success": true, "active": active, "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() }))+    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 {