Skip to main content

hatchery_node/
browser_capture.rs

1//! Linux dig2browser capture inside the node (feature `d2b`).
2//!
3//! Owns a headless Chromium via dig2browser, captures viewport frames (JPEG via
4//! CDP `Page.captureScreenshot`, PNG fallback), and applies dig2-wire input.
5//! Transport is the restream G4aControl sidecar — this module never dials HQ.
6
7#![cfg(feature = "d2b")]
8
9use dig2browser::browser::{
10    BrowserPreference, BrowserProfile, LaunchConfig, StealthBrowser, StealthConfig, StealthPage,
11};
12use dig2browser::detect::args::RendererSandboxMode;
13use hatchery_core::{
14    decode_browser_input, BrowserFrameRegion, BrowserFrameTrace, BrowserInput, BrowserMouseButton, BrowserStreamEvent,
15};
16use serde_json::json;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::{mpsc, oneshot, Mutex};
21
22const DEFAULT_URL: &str = "about:blank";
23const CAPTURE_EVERY: Duration = Duration::from_millis(250);
24const META_EVERY: Duration = Duration::from_millis(1000);
25const VIEW_W: u32 = 1280;
26const VIEW_H: u32 = 720;
27
28pub struct BrowserSessionHandle {
29    pub session: String,
30    input_tx: mpsc::UnboundedSender<BrowserInput>,
31    stop_tx: Option<oneshot::Sender<()>>,
32    join: Option<tokio::task::JoinHandle<()>>,
33}
34
35impl BrowserSessionHandle {
36    pub fn push_input(&self, input: BrowserInput) {
37        let _ = self.input_tx.send(input);
38    }
39
40    pub async fn stop(mut self) {
41        if let Some(tx) = self.stop_tx.take() {
42            let _ = tx.send(());
43        }
44        if let Some(join) = self.join.take() {
45            let _ = join.await;
46        }
47    }
48}
49
50/// Registry of live browser sessions on this node.
51#[derive(Default)]
52pub struct BrowserSessionMap {
53    inner: Mutex<std::collections::BTreeMap<String, BrowserSessionHandle>>,
54}
55
56impl BrowserSessionMap {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    pub async fn start(
62        &self,
63        node_id: impl Into<String>,
64        session: String,
65        url: String,
66        profile_dir: PathBuf,
67        event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
68    ) -> Result<(), String> {
69        hatchery_core::validate_browser_session_id(&session)?;
70        let node_id = node_id.into();
71        let mut guard = self.inner.lock().await;
72        if guard.contains_key(&session) {
73            // Re-open from HQ (panel reopened / reload): reuse the running browser.
74            let _ = event_tx.send(BrowserStreamEvent::Started { node_id, session: session.clone(), url });
75            if let Some(h) = guard.get(&session) { h.push_input(BrowserInput::Navigate { url: String::new() }); }
76            return Ok(());
77        }
78        let handle = spawn_session(
79            node_id,
80            session.clone(),
81            if url.trim().is_empty() {
82                DEFAULT_URL.to_owned()
83            } else {
84                url
85            },
86            profile_dir,
87            event_tx,
88        )?;
89        guard.insert(session, handle);
90        Ok(())
91    }
92
93    pub async fn input(&self, session: &str, bytes: &[u8]) -> Result<(), String> {
94        let input = decode_browser_input(bytes)?;
95        let guard = self.inner.lock().await;
96        let Some(handle) = guard.get(session) else {
97            return Err("browser session not found".into());
98        };
99        handle.push_input(input);
100        Ok(())
101    }
102
103    pub async fn stop(&self, session: &str) -> Result<(), String> {
104        let mut guard = self.inner.lock().await;
105        let Some(handle) = guard.remove(session) else {
106            return Err("browser session not found".into());
107        };
108        drop(guard);
109        handle.stop().await;
110        Ok(())
111    }
112
113    pub async fn stop_all(&self) {
114        let mut guard = self.inner.lock().await;
115        let handles: Vec<_> = std::mem::take(&mut *guard).into_values().collect();
116        drop(guard);
117        for h in handles {
118            h.stop().await;
119        }
120    }
121}
122
123fn spawn_session(
124    node_id: String,
125    session: String,
126    start_url: String,
127    profile_dir: PathBuf,
128    event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
129) -> Result<BrowserSessionHandle, String> {
130    let (input_tx, input_rx) = mpsc::unbounded_channel();
131    let (stop_tx, stop_rx) = oneshot::channel();
132    let session_for_task = session.clone();
133    let join = tokio::spawn(async move {
134        if let Err(err) = run_session(
135            node_id.clone(),
136            session_for_task.clone(),
137            start_url,
138            profile_dir,
139            input_rx,
140            stop_rx,
141            event_tx.clone(),
142        )
143        .await
144        {
145            let _ = event_tx.send(BrowserStreamEvent::Error {
146                node_id: node_id.clone(),
147                session: session_for_task.clone(),
148                message: err,
149            });
150        }
151        let _ = event_tx.send(BrowserStreamEvent::Stopped {
152            node_id,
153            session: session_for_task,
154            reason: "closed".into(),
155        });
156    });
157    Ok(BrowserSessionHandle {
158        session,
159        input_tx,
160        stop_tx: Some(stop_tx),
161        join: Some(join),
162    })
163}
164
165async fn run_session(
166    node_id: String,
167    session: String,
168    start_url: String,
169    profile_dir: PathBuf,
170    mut input_rx: mpsc::UnboundedReceiver<BrowserInput>,
171    mut stop_rx: oneshot::Receiver<()>,
172    event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
173) -> Result<(), String> {
174    std::fs::create_dir_all(&profile_dir).map_err(|e| e.to_string())?;
175    // Shared node profile: one Chrome for every session, each session in its own
176    // window (a background tab never paints). Otherwise one Chrome per session.
177    let shared = shared::profile().is_some_and(|p| p == profile_dir);
178    let (owned, page) = if shared {
179        (None, shared::open_page(&profile_dir).await?)
180    } else {
181        let browser = StealthBrowser::launch_with(launch_config(&profile_dir), StealthConfig::default())
182            .await
183            .map_err(|e| e.to_string())?;
184        let page = browser.new_page("about:blank").await.map_err(|e| e.to_string())?;
185        (Some(browser), page)
186    };
187    let _ = page.set_viewport(VIEW_W, VIEW_H, 1.0).await;
188    // Headful: keep our page in front (a background tab never paints).
189    let _ = page.cdp_call("Page.bringToFront", None).await;
190    let _ = page
191        .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
192        .await;
193    let _ = page.cdp_call("Page.navigate", Some(json!({ "url": start_url }))).await;
194    let _ = event_tx.send(BrowserStreamEvent::Started {
195        node_id: node_id.clone(),
196        session: session.clone(),
197        url: start_url.clone(),
198    });
199
200    // True screencast over a side CDP connection to our page target (push frames,
201    // ack each); falls back to polling captureScreenshot if it can't be set up.
202    let (sc_tx, mut sc_rx) = mpsc::channel::<Vec<u8>>(4);
203    let screencast = match screencast::start(&profile_dir, &page, sc_tx).await {
204        Ok(h) => Some(h),
205        Err(e) => {
206            tracing::warn!(%session, error = %e, "d2b screencast unavailable; polling");
207            None
208        }
209    };
210
211    let mut tick = tokio::time::interval(if screencast.is_some() { META_EVERY } else { CAPTURE_EVERY });
212    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
213    let mut last_hash: u64 = 0;
214    let (mut url, mut title) = (start_url.clone(), String::new());
215    let mut input_n: u64 = 0;
216    // (input_n, received, cdp_ms, dispatched)
217    let mut pending: Option<(u64, std::time::Instant, u32, std::time::Instant, u32)> = None;
218    let mut last_frame: Option<Vec<u8>> = None;
219    let mut delta = delta::Delta::default();
220    loop {
221        let frame: Option<(Vec<u8>, &'static str)> = tokio::select! {
222            _ = &mut stop_rx => break,
223            f = sc_rx.recv(), if screencast.is_some() => f.map(|b| (b, "screencast")),
224            _ = tick.tick() => {
225                if screencast.is_some() {
226                    // Screencast pushes pixels; the tick only refreshes url/title.
227                    match tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await {
228                        Ok(Ok((u, ti))) if u != url || ti != title => {
229                            url = u; title = ti;
230                            last_frame.clone().map(|b| (b, "meta"))
231                        }
232                        _ => None,
233                    }
234                } else {
235                    if let Ok(Ok((u, ti))) = tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await { url = u; title = ti; }
236                    match tokio::time::timeout(Duration::from_secs(5), capture_frame(&page)).await {
237                        Ok(Ok(bytes)) => {
238                            let hash = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut h); h.finish() };
239                            if hash != last_hash { last_hash = hash; Some((bytes, "poll")) } else { None }
240                        }
241                        _ => None,
242                    }
243                }
244            }
245            input = input_rx.recv() => {
246                let Some(input) = input else { break };
247                let received = std::time::Instant::now();
248                input_n += 1;
249                let queue_ms = 0u32;
250                if matches!(&input, BrowserInput::Navigate { url } if url.is_empty()) {
251                    // Re-open: resend the last frame so a new viewer paints at once.
252                    last_frame.clone().map(|b| (b, "replay"))
253                } else {
254                    let t0 = std::time::Instant::now();
255                    let copy = matches!(&input, BrowserInput::Key { key } if keys::is_copy(key));
256                    let sel = if copy { keys::selection(&page).await } else { None };
257                    if let Some(text) = sel.filter(|t| !t.is_empty()) {
258                        let _ = event_tx.send(BrowserStreamEvent::Clipboard { node_id: node_id.clone(), session: session.clone(), text });
259                    }
260                    match apply_input(&page, &input).await {
261                        Err(err) => { tracing::warn!(%session, error = %err, "browser input failed"); None }
262                        Ok(()) => {
263                            let cdp_ms = t0.elapsed().as_millis() as u32;
264                            pending = Some((input_n, received, cdp_ms, std::time::Instant::now(), queue_ms));
265                            if screencast.is_none() {
266                                last_hash = 0;
267                                tick.reset_immediately();
268                            }
269                            None
270                        }
271                    }
272                }
273            }
274        };
275        let Some((bytes, source)) = frame else { continue };
276        // Screencast frames become deltas (changed rectangle only) when small enough:
277        // a few KB fit one TCP window on every long hop instead of several RTTs.
278        let (bytes, region) = if source == "screencast" {
279            match delta.encode(&bytes) {
280                delta::Out::Unchanged => continue,
281                delta::Out::Full => (bytes, None),
282                delta::Out::Region(b, r) => (b, Some(r)),
283            }
284        } else if source == "replay" || source == "meta" {
285            (delta.full_jpeg().unwrap_or(bytes), None)
286        } else {
287            (bytes, None)
288        };
289        if region.is_none() && (source == "screencast" || source == "poll") {
290            last_frame = Some(bytes.clone());
291        }
292        let trace = if source == "screencast" || source == "poll" {
293            pending.take().map(|(n, received, cdp_ms, dispatched, queue_ms)| BrowserFrameTrace {
294                input_n: n,
295                queue_ms,
296                cdp_ms,
297                to_frame_ms: dispatched.elapsed().as_millis() as u32,
298                node_ms: received.elapsed().as_millis() as u32,
299                source: if region.is_some() { "screencast-delta".to_owned() } else { source.to_owned() },
300            })
301        } else { None };
302        let _ = event_tx.send(BrowserStreamEvent::Frame {
303            node_id: node_id.clone(),
304            session: session.clone(),
305            bytes,
306            width: VIEW_W,
307            height: VIEW_H,
308            url: url.clone(),
309            title: title.clone(),
310            trace,
311            region,
312        });
313    }
314    if let Some(h) = screencast { h.abort(); }
315    match owned {
316        Some(browser) => { let _ = browser.close().await; }
317        None => { let _ = page.cdp_call("Page.close", None).await; }
318    }
319    Ok(())
320}
321
322fn launch_config(profile_dir: &std::path::Path) -> LaunchConfig {
323    LaunchConfig {
324        // Headful on Xvfb when a DISPLAY is present (less bot-detection); else headless.
325        headless: std::env::var("HATCHERY_D2B_HEADFUL").map(|v| v != "1").unwrap_or(true)
326            || std::env::var_os("DISPLAY").is_none(),
327        window_size: (VIEW_W, VIEW_H),
328        profile: BrowserProfile::Persistent(profile_dir.to_path_buf()),
329        browser_pref: BrowserPreference::ChromeOnly,
330        renderer_sandbox: RendererSandboxMode::CompatibilityDisabled,
331        extra_args: vec![
332            "--force-device-scale-factor=1".into(),
333            "--disable-dev-shm-usage".into(),
334            // Several session windows paint at once in one shared Chrome.
335            "--disable-backgrounding-occluded-windows".into(),
336            "--disable-renderer-backgrounding".into(),
337            "--disable-background-timer-throttling".into(),
338        ],
339        ..LaunchConfig::default()
340    }
341}
342
343/// One persistent browser profile shared by every session of this node.
344/// Set by `HATCHERY_BROWSER_PROFILE` (a user-data-dir path); cookies and logins
345/// survive node restarts and are the same for all sessions and CLIs.
346pub mod shared {
347    use super::*;
348    use std::sync::OnceLock;
349
350    pub fn profile() -> Option<PathBuf> {
351        std::env::var_os("HATCHERY_BROWSER_PROFILE")
352            .filter(|v| !v.is_empty())
353            .map(PathBuf::from)
354    }
355
356    fn cell() -> &'static Mutex<Option<StealthBrowser>> {
357        static CELL: OnceLock<Mutex<Option<StealthBrowser>>> = OnceLock::new();
358        CELL.get_or_init(|| Mutex::new(None))
359    }
360
361    /// A Chrome left over from a previous node run still holds the profile lock.
362    fn kill_stale(dir: &std::path::Path) {
363        let want = format!("--user-data-dir={}", dir.display());
364        let Ok(rd) = std::fs::read_dir("/proc") else { return };
365        for entry in rd.flatten() {
366            let Ok(pid) = entry.file_name().to_string_lossy().parse::<i32>() else { continue };
367            let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
368            let args: Vec<&[u8]> = raw.split(|b| *b == 0).collect();
369            if args.iter().any(|a| *a == want.as_bytes()) {
370                let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
371            }
372        }
373        std::thread::sleep(Duration::from_millis(500));
374        for f in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
375            let _ = std::fs::remove_file(dir.join(f));
376        }
377    }
378
379    pub async fn open_page(dir: &std::path::Path) -> Result<StealthPage, String> {
380        let mut g = cell().lock().await;
381        let mut last = String::new();
382        for _ in 0..2 {
383            if g.is_none() {
384                kill_stale(dir);
385                let b = StealthBrowser::launch_with(launch_config(dir), StealthConfig::default())
386                    .await
387                    .map_err(|e| e.to_string())?;
388                *g = Some(b);
389            }
390            let b = g.as_ref().expect("browser set");
391            match new_window_page(b, dir).await {
392                Ok(p) => return Ok(p),
393                Err(e) => {
394                    tracing::warn!(error = %e, "shared browser unusable; relaunching");
395                    last = e;
396                    if let Some(old) = g.take() { let _ = old.close().await; }
397                }
398            }
399        }
400        Err(last)
401    }
402
403    /// Target.createTarget{newWindow} on the browser endpoint, then attach.
404    async fn new_window_page(b: &StealthBrowser, dir: &std::path::Path) -> Result<StealthPage, String> {
405        use futures_util::{SinkExt, StreamExt};
406        use tokio_tungstenite::tungstenite::Message;
407        let port = super::screencast::devtools_port(dir).ok_or("devtools port not found")?;
408        let path = browser_ws_path(port).await?;
409        let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}{path}"))
410            .await
411            .map_err(|e| e.to_string())?;
412        ws.send(Message::text(json!({ "id": 1, "method": "Target.createTarget",
413            "params": { "url": "about:blank", "newWindow": true } }).to_string()))
414            .await
415            .map_err(|e| e.to_string())?;
416        let target = tokio::time::timeout(Duration::from_secs(10), async {
417            while let Some(Ok(m)) = ws.next().await {
418                let Message::Text(t) = m else { continue };
419                let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else { continue };
420                if v["id"] == 1 {
421                    return v["result"]["targetId"].as_str().map(str::to_owned).ok_or_else(|| t.to_string());
422                }
423            }
424            Err("browser endpoint closed".to_string())
425        })
426        .await
427        .map_err(|_| "createTarget timed out".to_string())??;
428        let _ = ws.close(None).await;
429        b.attach_page(&target).await.map_err(|e| e.to_string())
430    }
431
432    /// `/devtools/browser/<id>` from the DevTools HTTP endpoint (`/json/version`).
433    async fn browser_ws_path(port: u16) -> Result<String, String> {
434        use tokio::io::{AsyncReadExt, AsyncWriteExt};
435        let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| e.to_string())?;
436        // Chrome keeps the connection open: read headers, then Content-Length bytes.
437        s.write_all(b"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").await.map_err(|e| e.to_string())?;
438        let mut buf = Vec::new();
439        let body = tokio::time::timeout(Duration::from_secs(5), async {
440            let mut chunk = [0u8; 4096];
441            loop {
442                let n = s.read(&mut chunk).await.map_err(|e| e.to_string())?;
443                if n == 0 { return Err("devtools closed".to_string()) }
444                buf.extend_from_slice(&chunk[..n]);
445                let text = String::from_utf8_lossy(&buf).into_owned();
446                if let Some((head, body)) = text.split_once("\r\n\r\n") {
447                    let len = head.lines()
448                        .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok()))
449                        .unwrap_or(0);
450                    if body.len() >= len { return Ok(body.to_owned()) }
451                }
452            }
453        })
454        .await
455        .map_err(|_| "devtools /json/version timed out".to_string())??;
456        let body = body.as_str();
457        let v: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
458        let url = v["webSocketDebuggerUrl"].as_str().ok_or("no webSocketDebuggerUrl")?;
459        url.find("/devtools/browser").map(|i| url[i..].to_owned()).ok_or_else(|| "bad webSocketDebuggerUrl".into())
460    }
461}
462
463async fn page_meta(page: &StealthPage) -> Result<(String, String), String> {
464    let v = page.eval("[location.href, document.title]").await.map_err(|e| e.to_string())?;
465    let a = v.as_array().cloned().unwrap_or_default();
466    Ok((
467        a.first().and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
468        a.get(1).and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
469    ))
470}
471
472/// Delta frames: diff each decoded frame against the previous one in 32px tiles
473/// and send only the changed rectangle when it is a small part of the screen.
474mod delta {
475    use super::BrowserFrameRegion;
476    use image::{codecs::jpeg::JpegEncoder, ImageReader, RgbImage};
477
478    const TILE: u32 = 32;
479    #[derive(Default)]
480    pub struct Delta { prev: Option<RgbImage> }
481    pub enum Out { Unchanged, Full, Region(Vec<u8>, BrowserFrameRegion) }
482
483    impl Delta {
484        pub fn encode(&mut self, jpeg: &[u8]) -> Out {
485            let Ok(img) = ImageReader::new(std::io::Cursor::new(jpeg)).with_guessed_format().map_err(|_| ()).and_then(|r| r.decode().map_err(|_| ())) else { return Out::Full };
486            let cur = img.to_rgb8();
487            let Some(prev) = self.prev.as_ref().filter(|p| p.dimensions() == cur.dimensions()) else {
488                self.prev = Some(cur);
489                return Out::Full;
490            };
491            let (w, h) = cur.dimensions();
492            let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
493            let (pr, cr) = (prev.as_raw(), cur.as_raw());
494            for ty in (0..h).step_by(TILE as usize) {
495                for tx in (0..w).step_by(TILE as usize) {
496                    let tw = TILE.min(w - tx); let th = TILE.min(h - ty);
497                    let mut changed = false;
498                    for yy in ty..ty + th {
499                        let a = ((yy * w + tx) * 3) as usize; let b = a + (tw * 3) as usize;
500                        // JPEG noise tolerance: a tile changes when any byte moves > 24.
501                        if pr[a..b].iter().zip(&cr[a..b]).any(|(p, c)| p.abs_diff(*c) > 24) { changed = true; break; }
502                    }
503                    if changed { x0 = x0.min(tx); y0 = y0.min(ty); x1 = x1.max(tx + tw); y1 = y1.max(ty + th); }
504                }
505            }
506            if x0 == u32::MAX { return Out::Unchanged; }
507            let (rw, rh) = (x1 - x0, y1 - y0);
508            if (rw * rh) as u64 * 2 > (w * h) as u64 {
509                self.prev = Some(cur);
510                return Out::Full;
511            }
512            let crop = image::imageops::crop_imm(&cur, x0, y0, rw, rh).to_image();
513            let mut out = Vec::new();
514            if JpegEncoder::new_with_quality(&mut out, 70).encode_image(&crop).is_err() { self.prev = Some(cur); return Out::Full; }
515            // Base the next diff on what the viewer now shows (prev + this region).
516            let mut next = prev.clone();
517            image::imageops::replace(&mut next, &crop, x0 as i64, y0 as i64);
518            self.prev = Some(next);
519            Out::Region(out, BrowserFrameRegion { x: x0, y: y0, w: rw, h: rh, src_w: w, src_h: h })
520        }
521
522        /// Current composite as a full JPEG (for a viewer that just attached).
523        pub fn full_jpeg(&self) -> Option<Vec<u8>> {
524            let img = self.prev.as_ref()?;
525            let mut out = Vec::new();
526            JpegEncoder::new_with_quality(&mut out, 60).encode_image(img).ok()?;
527            Some(out)
528        }
529    }
530}
531
532/// Side CDP websocket to our page target for Page.startScreencast.
533mod screencast {
534    use super::*;
535    use futures_util::{SinkExt, StreamExt};
536    use tokio_tungstenite::tungstenite::Message;
537
538    /// DevTools port of the Chrome that owns `profile_dir`: DevToolsActivePort if
539    /// present, else the --remote-debugging-port of the process using that profile.
540    pub(super) fn devtools_port(profile_dir: &std::path::Path) -> Option<u16> {
541        if let Ok(s) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
542            if let Some(p) = s.lines().next().and_then(|l| l.trim().parse().ok()) { return Some(p); }
543        }
544        let want = format!("--user-data-dir={}", profile_dir.display());
545        for entry in std::fs::read_dir("/proc").ok()?.flatten() {
546            let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
547            let args: Vec<String> = raw.split(|b| *b == 0).map(|a| String::from_utf8_lossy(a).into_owned()).collect();
548            if !args.iter().any(|a| a == &want) { continue; }
549            if let Some(p) = args.iter().find_map(|a| a.strip_prefix("--remote-debugging-port=").and_then(|v| v.parse().ok())) {
550                return Some(p);
551            }
552        }
553        None
554    }
555
556    /// Max JPEG bytes per frame so one frame fits a single restream message.
557    const BUDGET: usize = 40 * 1024;
558
559    pub async fn start(
560        profile_dir: &std::path::Path,
561        page: &StealthPage,
562        out: mpsc::Sender<Vec<u8>>,
563    ) -> Result<tokio::task::JoinHandle<()>, String> {
564        let port = devtools_port(profile_dir).ok_or("devtools port not found")?;
565        let info = page.cdp_call("Target.getTargetInfo", None).await.map_err(|e| e.to_string())?;
566        let target = info["targetInfo"]["targetId"].as_str().ok_or("no target id")?.to_owned();
567        let url = format!("ws://127.0.0.1:{port}/devtools/page/{target}");
568        let (ws, _) = tokio_tungstenite::connect_async(&url).await.map_err(|e| e.to_string())?;
569        let (mut tx, mut rx) = ws.split();
570        let mut quality: u32 = 60;
571        let mut max_w: u32 = VIEW_W;
572        let mut id: u64 = 1;
573        let start_msg = |id: u64, q: u32, w: u32| json!({ "id": id, "method": "Page.startScreencast",
574            "params": { "format": "jpeg", "quality": q, "maxWidth": w, "maxHeight": w * VIEW_H / VIEW_W, "everyNthFrame": 1 } }).to_string();
575        tx.send(Message::text(json!({ "id": id, "method": "Page.enable" }).to_string())).await.map_err(|e| e.to_string())?;
576        id += 1;
577        tx.send(Message::text(start_msg(id, quality, max_w))).await.map_err(|e| e.to_string())?;
578        Ok(tokio::spawn(async move {
579            while let Some(Ok(msg)) = rx.next().await {
580                let Message::Text(text) = msg else { continue };
581                let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
582                if v["method"] != "Page.screencastFrame" { continue; }
583                let sid = v["params"]["sessionId"].clone();
584                // Ack first so Chrome can start producing the next frame immediately.
585                id += 1;
586                let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.screencastFrameAck", "params": { "sessionId": sid } }).to_string())).await;
587                let Some(b64) = v["params"]["data"].as_str() else { continue };
588                use base64::Engine;
589                let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else { continue };
590                if bytes.len() > BUDGET {
591                    // Too big for one restream message: step quality, then size, and restart.
592                    if quality > 25 { quality -= 15 } else if max_w > 640 { max_w = max_w * 3 / 4 } else { continue }
593                    id += 1;
594                    let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.stopScreencast" }).to_string())).await;
595                    id += 1;
596                    let _ = tx.send(Message::text(start_msg(id, quality, max_w))).await;
597                    continue;
598                }
599                // Keep latency low: if the consumer is behind, drop rather than queue.
600                let _ = out.try_send(bytes);
601            }
602        }))
603    }
604}
605
606async fn capture_frame(page: &StealthPage) -> Result<Vec<u8>, String> {
607    // JPEG via CDP, sized to fit one Noise transport message (64 KiB incl. envelope):
608    // step quality down, then scale, until under FRAME_BUDGET. Oversized frames used to
609    // kill the restream uplink ("noise: input error") on busy pages.
610    const FRAME_BUDGET: usize = 40 * 1024;
611    let mut bytes = Vec::new();
612    'fit: for scale in [1.0f64, 0.75, 0.5] {
613        for q in [60u32, 45, 32, 22, 14] {
614            let mut params = json!({ "format": "jpeg", "quality": q });
615            if scale < 1.0 {
616                params["clip"] = json!({ "x": 0, "y": 0, "width": VIEW_W, "height": VIEW_H, "scale": scale });
617            }
618            let v = page.cdp_call("Page.captureScreenshot", Some(params)).await.map_err(|e| e.to_string())?;
619            let b64 = v.get("data").and_then(|x| x.as_str()).ok_or("no jpeg data")?;
620            use base64::Engine;
621            bytes = base64::engine::general_purpose::STANDARD.decode(b64).map_err(|e| e.to_string())?;
622            if bytes.len() <= FRAME_BUDGET {
623                break 'fit;
624            }
625        }
626    }
627    if bytes.len() > FRAME_BUDGET {
628        return Err(format!("frame too large ({} bytes)", bytes.len()));
629    }
630    Ok(bytes)
631}
632
633async fn apply_input(page: &StealthPage, input: &BrowserInput) -> Result<(), String> {
634    match input {
635        BrowserInput::Click { x, y, button } => {
636            // Raw CDP, viewport CSS pixels (DPR 1): move, press, release — a real
637            // trusted click that also reaches cross-origin iframes (e.g. Turnstile).
638            let b = match button {
639                BrowserMouseButton::Left => "left",
640                BrowserMouseButton::Right => "right",
641                BrowserMouseButton::Middle => "middle",
642            };
643            tracing::info!(x = *x, y = *y, button = b, "d2b click");
644            page.cdp_call(
645                "Input.dispatchMouseEvent",
646                Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })),
647            )
648            .await
649            .map_err(|e| e.to_string())?;
650            for ty in ["mousePressed", "mouseReleased"] {
651                page.cdp_call(
652                    "Input.dispatchMouseEvent",
653                    Some(json!({ "type": ty, "x": x, "y": y, "button": b, "buttons": if ty == "mousePressed" { 1 } else { 0 }, "clickCount": 1 })),
654                )
655                .await
656                .map_err(|e| e.to_string())?;
657            }
658        }
659        BrowserInput::MouseDown { x, y, button } | BrowserInput::MouseUp { x, y, button } => {
660            let down = matches!(input, BrowserInput::MouseDown { .. });
661            let b = match button {
662                BrowserMouseButton::Left => "left",
663                BrowserMouseButton::Right => "right",
664                BrowserMouseButton::Middle => "middle",
665            };
666            let count = mouse::click_count(down, *x, *y);
667            if down {
668                page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })))
669                    .await
670                    .map_err(|e| e.to_string())?;
671            }
672            page.cdp_call(
673                "Input.dispatchMouseEvent",
674                Some(json!({ "type": if down { "mousePressed" } else { "mouseReleased" }, "x": x, "y": y,
675                    "button": b, "buttons": if down { mouse::bit(b) } else { 0 }, "clickCount": count })),
676            )
677            .await
678            .map_err(|e| e.to_string())?;
679            mouse::set_held(if down { mouse::bit(b) } else { 0 });
680        }
681        BrowserInput::MouseMove { x, y } => {
682            let held = mouse::held();
683            let b = match held { 1 => "left", 2 => "right", 4 => "middle", _ => "none" };
684            page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": b, "buttons": held })))
685                .await
686                .map_err(|e| e.to_string())?;
687        }
688        BrowserInput::Scroll { x, y, dx, dy } => {
689            page.wheel(*x, *y, *dx, *dy)
690                .await
691                .map_err(|e| e.to_string())?
692        }
693        BrowserInput::Key { key } => keys::press(page, key).await?,
694        BrowserInput::Text { text } => {
695            // Prefer CDP insertText; fall back to per-character key_press.
696            match page
697                .cdp_call("Input.insertText", Some(json!({ "text": text })))
698                .await
699            {
700                Ok(_) => {}
701                Err(_) => {
702                    for ch in text.chars() {
703                        page.key_press(&ch.to_string())
704                            .await
705                            .map_err(|e| e.to_string())?;
706                    }
707                }
708            }
709        }
710        BrowserInput::Navigate { url } if url.is_empty() => {}
711        BrowserInput::Navigate { url } => {
712            // Raw Page.navigate returns once the request commits; frames keep
713            // streaming while the page loads (goto blocked the loop on load).
714            tracing::info!(%url, "d2b navigate");
715            let _ = page.cdp_call("Page.bringToFront", None).await;
716            let r = tokio::time::timeout(
717                Duration::from_secs(20),
718                page.cdp_call("Page.navigate", Some(json!({ "url": url }))),
719            )
720            .await
721            .map_err(|_| "navigate timed out".to_string())?
722            .map_err(|e| e.to_string())?;
723            if let Some(err) = r.get("errorText").and_then(|v| v.as_str()) {
724                if !err.is_empty() {
725                    return Err(format!("navigate: {err}"));
726                }
727            }
728        }
729    }
730    Ok(())
731}
732
733/// Mouse state for press/drag/release (one operator drives a page at a time).
734mod mouse {
735    use std::sync::atomic::{AtomicU32, Ordering};
736    use std::sync::Mutex;
737    static HELD: AtomicU32 = AtomicU32::new(0);
738    static LAST: Mutex<Option<(std::time::Instant, f64, f64, u32)>> = Mutex::new(None);
739
740    pub fn bit(b: &str) -> u32 { match b { "left" => 1, "right" => 2, "middle" => 4, _ => 0 } }
741    pub fn held() -> u32 { HELD.load(Ordering::Relaxed) }
742    pub fn set_held(v: u32) { HELD.store(v, Ordering::Relaxed) }
743
744    /// Double / triple click: presses close in time and place count up.
745    pub fn click_count(down: bool, x: f64, y: f64) -> u32 {
746        let mut g = LAST.lock().unwrap_or_else(|e| e.into_inner());
747        let now = std::time::Instant::now();
748        let prev = g.filter(|(t, px, py, _)| now.duration_since(*t).as_millis() < 500 && (px - x).abs() < 6.0 && (py - y).abs() < 6.0);
749        if !down { return prev.map(|p| p.3).unwrap_or(1) }
750        let n = prev.map(|p| p.3 % 3 + 1).unwrap_or(1);
751        *g = Some((now, x, y, n));
752        n
753    }
754}
755
756/// Shared map for the restream bridge (process-global for the node binary).
757pub fn shared_map() -> Arc<BrowserSessionMap> {
758    use std::sync::OnceLock;
759    static MAP: OnceLock<Arc<BrowserSessionMap>> = OnceLock::new();
760    MAP.get_or_init(|| Arc::new(BrowserSessionMap::new())).clone()
761}
762
763/// Keyboard over raw CDP: "Control+Shift+a" style chords, named keys with their
764/// virtual key codes (Backspace, Delete, arrows...), editing commands for the
765/// usual shortcuts, and browser navigation keys (reload, back, forward).
766mod keys {
767    use super::*;
768
769    pub struct Chord<'a> { pub mods: u32, pub key: &'a str }
770
771    pub fn parse(combo: &str) -> Chord<'_> {
772        let mut mods = 0;
773        let mut rest = combo;
774        loop {
775            let Some((head, tail)) = rest.split_once('+') else { break };
776            if tail.is_empty() { break }
777            let bit = match head { "Alt" => 1, "Control" | "Ctrl" => 2, "Meta" => 4, "Shift" => 8, _ => break };
778            mods |= bit;
779            rest = tail;
780        }
781        Chord { mods, key: rest }
782    }
783
784    /// (code, windowsVirtualKeyCode, text)
785    pub fn def(key: &str) -> (String, u32, Option<String>) {
786        let named = |code: &str, vk: u32, text: Option<&str>| (code.to_owned(), vk, text.map(str::to_owned));
787        match key {
788            "Backspace" => named("Backspace", 8, None),
789            "Tab" => named("Tab", 9, None),
790            "Enter" => named("Enter", 13, Some("\r")),
791            "Escape" => named("Escape", 27, None),
792            "PageUp" => named("PageUp", 33, None),
793            "PageDown" => named("PageDown", 34, None),
794            "End" => named("End", 35, None),
795            "Home" => named("Home", 36, None),
796            "ArrowLeft" => named("ArrowLeft", 37, None),
797            "ArrowUp" => named("ArrowUp", 38, None),
798            "ArrowRight" => named("ArrowRight", 39, None),
799            "ArrowDown" => named("ArrowDown", 40, None),
800            "Insert" => named("Insert", 45, None),
801            "Delete" => named("Delete", 46, None),
802            k if k.len() > 1 && k.starts_with('F') && k[1..].parse::<u32>().is_ok_and(|n| (1..=12).contains(&n)) => {
803                let n: u32 = k[1..].parse().unwrap_or(1);
804                (k.to_owned(), 111 + n, None)
805            }
806            k => {
807                let mut it = k.chars();
808                match (it.next(), it.next()) {
809                    (Some(c), None) if c.is_ascii_alphabetic() => {
810                        let u = c.to_ascii_uppercase();
811                        (format!("Key{u}"), u as u32, Some(k.to_owned()))
812                    }
813                    (Some(c), None) if c.is_ascii_digit() => (format!("Digit{c}"), c as u32, Some(k.to_owned())),
814                    (Some(' '), None) => ("Space".into(), 32, Some(" ".into())),
815                    (Some(_), None) => (String::new(), 0, Some(k.to_owned())),
816                    _ => (String::new(), 0, None),
817                }
818            }
819        }
820    }
821
822    pub fn commands(mods: u32, key: &str) -> Vec<&'static str> {
823        if mods & 6 == 0 { return vec![] }
824        match (key.to_ascii_lowercase().as_str(), mods & 8 != 0) {
825            ("a", _) => vec!["selectAll"],
826            ("c", _) => vec!["copy"],
827            ("x", _) => vec!["cut"],
828            ("v", _) => vec!["paste"],
829            ("z", false) => vec!["undo"],
830            ("z", true) | ("y", _) => vec!["redo"],
831            _ => vec![],
832        }
833    }
834
835    pub fn is_copy(combo: &str) -> bool {
836        let c = parse(combo);
837        c.mods & 6 != 0 && matches!(c.key, "c" | "C" | "x" | "X" | "Insert")
838    }
839
840    /// Selected text of the page (inputs and textareas included), capped.
841    pub async fn selection(page: &StealthPage) -> Option<String> {
842        let v = page.eval("(() => { const a = document.activeElement; let t = ''; \
843            if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA') && typeof a.selectionStart === 'number') t = a.value.slice(a.selectionStart, a.selectionEnd); \
844            else t = String(window.getSelection() || ''); return t.slice(0, 100000); })()").await.ok()?;
845        v.as_str().map(str::to_owned)
846    }
847
848    enum Nav { Reload, Back, Forward }
849
850    fn nav(c: &Chord<'_>) -> Option<Nav> {
851        match (c.key, c.mods) {
852            ("BrowserRefresh", _) | ("F5", _) => Some(Nav::Reload),
853            ("r" | "R", m) if m & 6 != 0 => Some(Nav::Reload),
854            ("BrowserBack", _) => Some(Nav::Back),
855            ("ArrowLeft", 1) => Some(Nav::Back),
856            ("BrowserForward", _) => Some(Nav::Forward),
857            ("ArrowRight", 1) => Some(Nav::Forward),
858            _ => None,
859        }
860    }
861
862    async fn history(page: &StealthPage, step: i64) -> Result<(), String> {
863        let h = page.cdp_call("Page.getNavigationHistory", None).await.map_err(|e| e.to_string())?;
864        let i = h["currentIndex"].as_i64().unwrap_or(0) + step;
865        let entries = h["entries"].as_array().cloned().unwrap_or_default();
866        let Some(e) = usize::try_from(i).ok().and_then(|i| entries.get(i)) else { return Ok(()) };
867        page.cdp_call("Page.navigateToHistoryEntry", Some(json!({ "entryId": e["id"] })))
868            .await
869            .map_err(|e| e.to_string())?;
870        Ok(())
871    }
872
873    pub async fn press(page: &StealthPage, combo: &str) -> Result<(), String> {
874        let c = parse(combo);
875        if matches!(c.key, "Shift" | "Control" | "Alt" | "Meta" | "CapsLock" | "") { return Ok(()) }
876        if let Some(n) = nav(&c) {
877            return match n {
878                Nav::Reload => page.cdp_call("Page.reload", None).await.map(|_| ()).map_err(|e| e.to_string()),
879                Nav::Back => history(page, -1).await,
880                Nav::Forward => history(page, 1).await,
881            };
882        }
883        let (code, vk, text) = def(c.key);
884        let typed = if c.mods & 7 == 0 { text } else { None };
885        let mut down = json!({
886            "type": if typed.is_some() { "keyDown" } else { "rawKeyDown" },
887            "modifiers": c.mods, "key": c.key, "code": code,
888            "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
889        });
890        if let Some(t) = &typed { down["text"] = json!(t); down["unmodifiedText"] = json!(t); }
891        let cmds = commands(c.mods, c.key);
892        if !cmds.is_empty() { down["commands"] = json!(cmds); }
893        page.cdp_call("Input.dispatchKeyEvent", Some(down)).await.map_err(|e| e.to_string())?;
894        page.cdp_call("Input.dispatchKeyEvent", Some(json!({
895            "type": "keyUp", "modifiers": c.mods, "key": c.key, "code": code,
896            "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
897        }))).await.map_err(|e| e.to_string())?;
898        Ok(())
899    }
900
901    #[cfg(test)]
902    mod tests {
903        use super::*;
904        #[test]
905        fn chords_parse() {
906            let c = parse("Control+Shift+a"); assert_eq!((c.mods, c.key), (10, "a"));
907            let c = parse("Control++"); assert_eq!((c.mods, c.key), (2, "+"));
908            let c = parse("Backspace"); assert_eq!((c.mods, c.key), (0, "Backspace"));
909            assert_eq!(def("Delete").1, 46);
910            assert_eq!(def("a").0, "KeyA");
911            assert_eq!(commands(2, "a"), vec!["selectAll"]);
912        }
913    }
914}