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 dig2browser::stealth::LocaleProfile;
14use hatchery_core::{
15    decode_browser_input, BrowserFrameRegion, BrowserFrameTrace, BrowserInput, BrowserMouseButton, BrowserStreamEvent,
16};
17use serde_json::json;
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::sync::{mpsc, oneshot, Mutex};
22
23const DEFAULT_URL: &str = "about:blank";
24const CAPTURE_EVERY: Duration = Duration::from_millis(250);
25const META_EVERY: Duration = Duration::from_millis(1000);
26const VIEW_W: u32 = 1280;
27const VIEW_H: u32 = 720;
28
29pub struct BrowserSessionHandle {
30    pub session: String,
31    input_tx: mpsc::UnboundedSender<BrowserInput>,
32    /// Wake the capture loop to push a full frame (after uplink reconnect).
33    kick_tx: mpsc::UnboundedSender<()>,
34    stop_tx: Option<oneshot::Sender<()>>,
35    join: Option<tokio::task::JoinHandle<()>>,
36}
37
38impl BrowserSessionHandle {
39    pub fn push_input(&self, input: BrowserInput) {
40        let _ = self.input_tx.send(input);
41    }
42
43    pub fn kick_frame(&self) {
44        let _ = self.kick_tx.send(());
45    }
46
47    pub async fn stop(mut self) {
48        if let Some(tx) = self.stop_tx.take() {
49            let _ = tx.send(());
50        }
51        if let Some(join) = self.join.take() {
52            let _ = join.await;
53        }
54    }
55}
56
57/// Process-wide sink the active restream bridge installs. Browser sessions always
58/// send through this so frames survive bridge reconnect (noise decrypt / eof).
59#[derive(Clone, Default)]
60pub struct BrowserEventSink {
61    inner: Arc<Mutex<Option<mpsc::UnboundedSender<BrowserStreamEvent>>>>,
62}
63
64impl BrowserEventSink {
65    pub async fn set(&self, tx: mpsc::UnboundedSender<BrowserStreamEvent>) {
66        *self.inner.lock().await = Some(tx);
67    }
68
69    pub async fn clear(&self) {
70        *self.inner.lock().await = None;
71    }
72
73    pub async fn send(&self, ev: BrowserStreamEvent) {
74        let mut guard = self.inner.lock().await;
75        let live = guard.is_some();
76        match &ev {
77            BrowserStreamEvent::Frame { session, bytes, .. } => {
78                tracing::debug!(%session, bytes = bytes.len(), sink_live = live, "d2b frame → sink");
79            }
80            BrowserStreamEvent::Started { session, url, .. } => {
81                tracing::debug!(%session, %url, sink_live = live, "d2b started → sink");
82            }
83            _ => {}
84        }
85        if let Some(tx) = guard.as_ref() {
86            if tx.send(ev).is_err() {
87                tracing::warn!("d2b sink send failed (bridge rx dropped)");
88            }
89        } else {
90            tracing::warn!("d2b sink unbound; event dropped");
91        }
92    }
93}
94
95/// Registry of live browser sessions on this node.
96pub struct BrowserSessionMap {
97    inner: Mutex<std::collections::BTreeMap<String, BrowserSessionHandle>>,
98    sink: BrowserEventSink,
99}
100
101impl Default for BrowserSessionMap {
102    fn default() -> Self {
103        Self {
104            inner: Mutex::new(std::collections::BTreeMap::new()),
105            sink: BrowserEventSink::default(),
106        }
107    }
108}
109
110impl BrowserSessionMap {
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    pub fn sink(&self) -> BrowserEventSink {
116        self.sink.clone()
117    }
118
119    /// Bridge just came up: point sessions at its event channel and force a full frame.
120    pub async fn bind_bridge(&self, tx: mpsc::UnboundedSender<BrowserStreamEvent>) {
121        self.sink.set(tx).await;
122        let guard = self.inner.lock().await;
123        for h in guard.values() {
124            h.kick_frame();
125        }
126    }
127
128    pub async fn start(
129        &self,
130        node_id: impl Into<String>,
131        session: String,
132        url: String,
133        profile_dir: PathBuf,
134        event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
135    ) -> Result<(), String> {
136        hatchery_core::validate_browser_session_id(&session)?;
137        // Always refresh the live sink (HQ may Start on a fresh bridge).
138        self.sink.set(event_tx).await;
139        let node_id = node_id.into();
140        let mut guard = self.inner.lock().await;
141        if guard.contains_key(&session) {
142            // Re-open from HQ (panel reopened / reload): reuse the running browser.
143            self.sink
144                .send(BrowserStreamEvent::Started {
145                    node_id,
146                    session: session.clone(),
147                    url: url.clone(),
148                })
149                .await;
150            if let Some(h) = guard.get(&session) {
151                h.kick_frame();
152                if !url.trim().is_empty() {
153                    h.push_input(BrowserInput::Navigate { url });
154                }
155            }
156            return Ok(());
157        }
158        let handle = spawn_session(
159            node_id,
160            session.clone(),
161            if url.trim().is_empty() {
162                DEFAULT_URL.to_owned()
163            } else {
164                url
165            },
166            profile_dir,
167            self.sink.clone(),
168        )?;
169        guard.insert(session, handle);
170        Ok(())
171    }
172
173    pub async fn input(&self, session: &str, bytes: &[u8]) -> Result<(), String> {
174        let input = decode_browser_input(bytes)?;
175        let guard = self.inner.lock().await;
176        let Some(handle) = guard.get(session) else {
177            return Err("browser session not found".into());
178        };
179        handle.push_input(input);
180        Ok(())
181    }
182
183    pub async fn stop(&self, session: &str) -> Result<(), String> {
184        let mut guard = self.inner.lock().await;
185        let Some(handle) = guard.remove(session) else {
186            return Err("browser session not found".into());
187        };
188        drop(guard);
189        handle.stop().await;
190        Ok(())
191    }
192
193    pub async fn stop_all(&self) {
194        let mut guard = self.inner.lock().await;
195        let handles: Vec<_> = std::mem::take(&mut *guard).into_values().collect();
196        drop(guard);
197        for h in handles {
198            h.stop().await;
199        }
200    }
201}
202
203fn spawn_session(
204    node_id: String,
205    session: String,
206    start_url: String,
207    profile_dir: PathBuf,
208    sink: BrowserEventSink,
209) -> Result<BrowserSessionHandle, String> {
210    let (input_tx, input_rx) = mpsc::unbounded_channel();
211    let (kick_tx, kick_rx) = mpsc::unbounded_channel();
212    let (stop_tx, stop_rx) = oneshot::channel();
213    let session_for_task = session.clone();
214    let sink_err = sink.clone();
215    let join = tokio::spawn(async move {
216        if let Err(err) = run_session(
217            node_id.clone(),
218            session_for_task.clone(),
219            start_url,
220            profile_dir,
221            input_rx,
222            kick_rx,
223            stop_rx,
224            sink.clone(),
225        )
226        .await
227        {
228            sink_err
229                .send(BrowserStreamEvent::Error {
230                    node_id: node_id.clone(),
231                    session: session_for_task.clone(),
232                    message: err,
233                })
234                .await;
235        }
236        sink_err
237            .send(BrowserStreamEvent::Stopped {
238                node_id,
239                session: session_for_task,
240                reason: "closed".into(),
241            })
242            .await;
243    });
244    Ok(BrowserSessionHandle {
245        session,
246        input_tx,
247        kick_tx,
248        stop_tx: Some(stop_tx),
249        join: Some(join),
250    })
251}
252
253async fn run_session(
254    node_id: String,
255    session: String,
256    start_url: String,
257    profile_dir: PathBuf,
258    mut input_rx: mpsc::UnboundedReceiver<BrowserInput>,
259    mut kick_rx: mpsc::UnboundedReceiver<()>,
260    mut stop_rx: oneshot::Receiver<()>,
261    sink: BrowserEventSink,
262) -> Result<(), String> {
263    std::fs::create_dir_all(&profile_dir).map_err(|e| e.to_string())?;
264    // Shared node profile: one Chrome for every session, each session in its own
265    // window (a background tab never paints). Otherwise one Chrome per session.
266    let shared = shared::profile().is_some_and(|p| p == profile_dir);
267    let (owned, mut page) = if shared {
268        (None, shared::open_page(&profile_dir).await?)
269    } else {
270        ensure_profile_accept_languages(&profile_dir);
271        let browser = StealthBrowser::launch_with(launch_config(&profile_dir), stealth_config())
272            .await
273            .map_err(|e| e.to_string())?;
274        let page = browser.new_page("about:blank").await.map_err(|e| e.to_string())?;
275        (Some(browser), page)
276    };
277    let _ = page.set_viewport(VIEW_W, VIEW_H, 1.0).await;
278    apply_browser_locale(&page).await;
279    // Headful: keep our page in front (a background tab never paints).
280    let _ = page.cdp_call("Page.bringToFront", None).await;
281    let _ = page
282        .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
283        .await;
284    let _ = page.cdp_call("Page.navigate", Some(json!({ "url": start_url }))).await;
285    sink
286        .send(BrowserStreamEvent::Started {
287            node_id: node_id.clone(),
288            session: session.clone(),
289            url: start_url.clone(),
290        })
291        .await;
292    // First paint immediately — don't wait for screencast (HQ shows white otherwise).
293    let mut force_kick = true;
294
295    let opener_id = page
296        .cdp_call("Target.getTargetInfo", None)
297        .await
298        .ok()
299        .and_then(|v| v["targetInfo"]["targetId"].as_str().map(str::to_owned))
300        .unwrap_or_default();
301
302    // True screencast over a side CDP connection to our page target (push frames,
303    // ack each); falls back to polling captureScreenshot if it can't be set up.
304    let (sc_tx, mut sc_rx) = mpsc::channel::<Vec<u8>>(4);
305    let mut screencast = match screencast::start(&profile_dir, &page, sc_tx).await {
306        Ok(h) => Some(h),
307        Err(e) => {
308            tracing::warn!(%session, error = %e, "d2b screencast unavailable; polling");
309            None
310        }
311    };
312
313    // OAuth / window.open: Chrome opens a real second window we must stream, or
314    // the parent spinner waits forever (Continue with Google, etc.).
315    let (mut popup_rx, popup_watch) = match popups::start(&profile_dir, opener_id.clone()).await {
316        Ok(v) => v,
317        Err(e) => {
318            tracing::warn!(%session, error = %e, "d2b popup watch unavailable");
319            let (_t, r) = mpsc::unbounded_channel();
320            (r, tokio::spawn(async {}))
321        }
322    };
323    // Parent page parked here while we stream a popup; popup_id is the active child.
324    let mut parent_page: Option<StealthPage> = None;
325    let mut popup_id: Option<String> = None;
326    // Dedupe Opened events (create + infoChanged both fire).
327    let mut seen_popup: std::collections::HashSet<String> = std::collections::HashSet::new();
328
329    let mut tick = tokio::time::interval(if screencast.is_some() { META_EVERY } else { CAPTURE_EVERY });
330    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
331    let mut last_hash: u64 = 0;
332    let (mut url, mut title) = (start_url.clone(), String::new());
333    let mut input_n: u64 = 0;
334    // (input_n, received, cdp_ms, dispatched)
335    let mut pending: Option<(u64, std::time::Instant, u32, std::time::Instant, u32)> = None;
336    let mut last_frame: Option<Vec<u8>> = None;
337    let mut delta = delta::Delta::default();
338    // Set on every stream switch: a static page may never emit a screencast frame,
339    // so the next tick pushes one captured frame (+ fresh url/title) to HQ.
340    let mut meta_fail: u32 = 0;
341    macro_rules! restore_parent {
342        () => {
343            if let Some(p) = parent_page.take() {
344                page = p;
345                popup_id = None;
346                let _ = page.cdp_call("Page.bringToFront", None).await;
347                if let Some(h) = screencast.take() { h.abort(); }
348                let (tx, rx) = mpsc::channel::<Vec<u8>>(4);
349                sc_rx = rx;
350                screencast = screencast::start(&profile_dir, &page, tx).await.ok();
351                delta = delta::Delta::default();
352                last_frame = None;
353                last_hash = 0;
354                url.clear();
355                title.clear();
356                force_kick = true;
357                tick.reset_immediately();
358            }
359        };
360    }
361    loop {
362        let frame: Option<(Vec<u8>, &'static str)> = tokio::select! {
363            _ = &mut stop_rx => break,
364            _ = kick_rx.recv() => {
365                force_kick = true;
366                tick.reset_immediately();
367                None
368            }
369            pe = popup_rx.recv() => {
370                match pe {
371                    Some(popups::Ev::Opened { target_id, url: purl }) => {
372                        if popup_id.as_deref() == Some(target_id.as_str()) || !seen_popup.insert(target_id.clone()) {
373                            None
374                        } else if parent_page.is_some() {
375                            // Already showing a popup; ignore nested ones for now.
376                            tracing::info!(%session, %target_id, "d2b popup ignored (already in one)");
377                            None
378                        } else {
379                            tracing::info!(%session, %target_id, url = %purl, "d2b popup opened; switching stream");
380                            match attach_target(shared, &owned, &profile_dir, &target_id).await {
381                                Ok(child) => {
382                                    let _ = child.set_viewport(VIEW_W, VIEW_H, 1.0).await;
383                                    apply_browser_locale(&child).await;
384                                    let _ = child.cdp_call("Page.bringToFront", None).await;
385                                    let _ = child
386                                        .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
387                                        .await;
388                                    let old = std::mem::replace(&mut page, child);
389                                    parent_page = Some(old);
390                                    popup_id = Some(target_id);
391                                    if let Some(h) = screencast.take() { h.abort(); }
392                                    let (tx, rx) = mpsc::channel::<Vec<u8>>(4);
393                                    sc_rx = rx;
394                                    screencast = screencast::start(&profile_dir, &page, tx).await.ok();
395                                    delta = delta::Delta::default();
396                                    last_frame = None;
397                                    last_hash = 0;
398                                    force_kick = true;
399                                    tick.reset_immediately();
400                                    None
401                                }
402                                Err(e) => {
403                                    tracing::warn!(%session, error = %e, "d2b popup attach failed");
404                                    None
405                                }
406                            }
407                        }
408                    }
409                    Some(popups::Ev::Closed { target_id }) => {
410                        seen_popup.remove(&target_id);
411                        if popup_id.as_deref() == Some(target_id.as_str()) {
412                            tracing::info!(%session, %target_id, "d2b popup closed; restoring parent");
413                            restore_parent!();
414                        }
415                        None
416                    }
417                    None => None,
418                }
419            }
420            f = sc_rx.recv(), if screencast.is_some() => f.map(|b| (b, "screencast")),
421            _ = tick.tick() => {
422                if screencast.is_some() {
423                    // Screencast pushes pixels; the tick only refreshes url/title.
424                    match tokio::time::timeout(Duration::from_millis(1500), page_meta(&page)).await {
425                        Ok(Ok((u, ti))) => {
426                            meta_fail = 0;
427                            let changed = u != url || ti != title;
428                            url = u; title = ti;
429                            if force_kick || last_frame.is_none() {
430                                // No screencast frame since the switch: capture one so HQ
431                                // leaves the old (popup) picture and title.
432                                match tokio::time::timeout(Duration::from_secs(3), capture_frame(&page)).await {
433                                    Ok(Ok(bytes)) => { force_kick = false; Some((bytes, "poll")) }
434                                    Ok(Err(e)) => { tracing::warn!(%session, error = %e, "d2b force_kick capture failed"); None }
435                                    Err(_) => { tracing::warn!(%session, "d2b force_kick capture timeout"); None }
436                                }
437                            } else if changed {
438                                last_frame.clone().map(|b| (b, "meta"))
439                            } else { None }
440                        }
441                        _ if popup_id.is_some() && { meta_fail += 1; meta_fail >= 3 } => {
442                            meta_fail = 0;
443                            // Popup target died without a targetDestroyed (redirect/close race).
444                            tracing::info!(%session, "d2b popup unresponsive; restoring parent");
445                            if let Some(id) = popup_id.clone() { seen_popup.remove(&id); }
446                            restore_parent!();
447                            None
448                        }
449                        _ => None,
450                    }
451                } else {
452                    if let Ok(Ok((u, ti))) = tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await { url = u; title = ti; }
453                    match tokio::time::timeout(Duration::from_secs(5), capture_frame(&page)).await {
454                        Ok(Ok(bytes)) => {
455                            let hash = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut h); h.finish() };
456                            if hash != last_hash { last_hash = hash; Some((bytes, "poll")) } else { None }
457                        }
458                        _ => None,
459                    }
460                }
461            }
462            input = input_rx.recv() => {
463                let Some(input) = input else { break };
464                let received = std::time::Instant::now();
465                input_n += 1;
466                let queue_ms = 0u32;
467                if matches!(&input, BrowserInput::Navigate { url } if url.is_empty()) {
468                    // Re-open: resend the last frame so a new viewer paints at once.
469                    last_frame.clone().map(|b| (b, "replay"))
470                } else {
471                    // Address-bar navigate while an OAuth popup is up: close the popup
472                    // and drive the parent (operator expects the bar to leave the login).
473                    let input = if matches!(&input, BrowserInput::Navigate { url } if !url.is_empty())
474                        && popup_id.is_some()
475                    {
476                        if let Some(id) = popup_id.clone() {
477                            tracing::info!(%session, %id, "d2b navigate closes popup first");
478                            let _ = page.cdp_call("Target.closeTarget", Some(json!({ "targetId": id }))).await;
479                            seen_popup.remove(&id);
480                            restore_parent!();
481                        }
482                        input
483                    } else {
484                        input
485                    };
486                    let copy = matches!(&input, BrowserInput::Key { key } if keys::is_copy(key));
487                    let sel = if copy { keys::selection(&page).await } else { None };
488                    if let Some(text) = sel.filter(|t| !t.is_empty()) {
489                        sink.send(BrowserStreamEvent::Clipboard { node_id: node_id.clone(), session: session.clone(), text }).await;
490                    }
491                    match apply_input(&page, &input).await {
492                        Err(err) => { tracing::warn!(%session, error = %err, "browser input failed"); None }
493                        Ok(()) => {
494                            let cdp_ms = received.elapsed().as_millis() as u32;
495                            pending = Some((input_n, received, cdp_ms, std::time::Instant::now(), queue_ms));
496                            if screencast.is_none() {
497                                last_hash = 0;
498                                tick.reset_immediately();
499                            }
500                            None
501                        }
502                    }
503                }
504            }
505        };
506        let Some((bytes, source)) = frame else { continue };
507        // Screencast frames become deltas (changed rectangle only) when small enough:
508        // a few KB fit one TCP window on every long hop instead of several RTTs.
509        let (bytes, region) = if source == "screencast" {
510            match delta.encode(&bytes) {
511                delta::Out::Unchanged => continue,
512                delta::Out::Full => (bytes, None),
513                delta::Out::Region(b, r) => (b, Some(r)),
514            }
515        } else if source == "replay" || source == "meta" {
516            (delta.full_jpeg().unwrap_or(bytes), None)
517        } else {
518            (bytes, None)
519        };
520        if region.is_none() && (source == "screencast" || source == "poll") {
521            last_frame = Some(bytes.clone());
522        }
523        let trace = if source == "screencast" || source == "poll" {
524            pending.take().map(|(n, received, cdp_ms, dispatched, queue_ms)| BrowserFrameTrace {
525                input_n: n,
526                queue_ms,
527                cdp_ms,
528                to_frame_ms: dispatched.elapsed().as_millis() as u32,
529                node_ms: received.elapsed().as_millis() as u32,
530                source: if region.is_some() { "screencast-delta".to_owned() } else { source.to_owned() },
531            })
532        } else { None };
533        sink
534            .send(BrowserStreamEvent::Frame {
535                node_id: node_id.clone(),
536                session: session.clone(),
537                bytes,
538                width: VIEW_W,
539                height: VIEW_H,
540                url: url.clone(),
541                title: title.clone(),
542                trace,
543                region,
544            })
545            .await;
546    }
547    popup_watch.abort();
548    if let Some(h) = screencast { h.abort(); }
549    // Close an open popup first, then the parent page we stream.
550    if let Some(id) = popup_id.take() {
551        let _ = page.cdp_call("Target.closeTarget", Some(json!({ "targetId": id }))).await;
552        if let Some(parent) = parent_page.take() {
553            page = parent;
554        }
555    }
556    match owned {
557        Some(browser) => { let _ = browser.close().await; }
558        None => { let _ = page.cdp_call("Page.close", None).await; }
559    }
560    Ok(())
561}
562
563async fn attach_target(
564    shared_profile: bool,
565    owned: &Option<StealthBrowser>,
566    profile_dir: &std::path::Path,
567    target_id: &str,
568) -> Result<StealthPage, String> {
569    if shared_profile {
570        shared::attach(profile_dir, target_id).await
571    } else {
572        let b = owned.as_ref().ok_or("no browser for attach")?;
573        b.attach_page(target_id).await.map_err(|e| e.to_string())
574    }
575}
576
577
578fn launch_config(profile_dir: &std::path::Path) -> LaunchConfig {
579    // Do not pass a second --accept-lang here: dig2browser already sets one from the
580    // primary locale, and two flags make Chrome mangle q-values. Full en+ru list is
581    // applied via profile intl.accept_languages + Network.setExtraHTTPHeaders.
582    let extra_args = vec![
583        "--force-device-scale-factor=1".into(),
584        "--disable-dev-shm-usage".into(),
585        // Several session windows paint at once in one shared Chrome.
586        "--disable-backgrounding-occluded-windows".into(),
587        "--disable-renderer-backgrounding".into(),
588        "--disable-background-timer-throttling".into(),
589    ];
590    LaunchConfig {
591        // Headful on Xvfb when a DISPLAY is present (less bot-detection); else headless.
592        headless: std::env::var("HATCHERY_D2B_HEADFUL").map(|v| v != "1").unwrap_or(true)
593            || std::env::var_os("DISPLAY").is_none(),
594        window_size: (VIEW_W, VIEW_H),
595        profile: BrowserProfile::Persistent(profile_dir.to_path_buf()),
596        browser_pref: BrowserPreference::ChromeOnly,
597        renderer_sandbox: RendererSandboxMode::CompatibilityDisabled,
598        extra_args,
599        ..LaunchConfig::default()
600    }
601}
602
603/// Full Accept-Language header value, e.g. `en-US,en;q=0.9,ru;q=0.8`.
604/// Unset in OSS; host sets via `HATCHERY_BROWSER_ACCEPT_LANGUAGES`.
605fn browser_accept_languages() -> Option<String> {
606    std::env::var("HATCHERY_BROWSER_ACCEPT_LANGUAGES")
607        .ok()
608        .map(|v| v.trim().to_owned())
609        .filter(|v| !v.is_empty())
610}
611
612/// Language tags without q-values for `navigator.languages`.
613fn browser_language_tags() -> Vec<String> {
614    let Some(raw) = browser_accept_languages() else { return Vec::new() };
615    raw.split(',')
616        .map(|p| p.split(';').next().unwrap_or(p).trim().to_owned())
617        .filter(|t| !t.is_empty())
618        .collect()
619}
620
621/// Write `intl.accept_languages` into the Chrome profile Preferences (no cookie touch).
622/// Must run while Chrome is not holding the profile.
623fn ensure_profile_accept_languages(profile_dir: &std::path::Path) {
624    let Some(al) = browser_accept_languages() else { return };
625    let pref = profile_dir.join("Default").join("Preferences");
626    let Ok(raw) = std::fs::read_to_string(&pref) else {
627        // Fresh profile: Chrome will create Preferences; seed a minimal file.
628        let _ = std::fs::create_dir_all(profile_dir.join("Default"));
629        let seed = serde_json::json!({ "intl": { "accept_languages": al } });
630        let _ = std::fs::write(&pref, seed.to_string());
631        return;
632    };
633    let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else { return };
634    let obj = v.as_object_mut().unwrap();
635    let intl = obj
636        .entry("intl".to_owned())
637        .or_insert_with(|| json!({}))
638        .as_object_mut();
639    let Some(intl) = intl else { return };
640    intl.insert("accept_languages".into(), json!(al));
641    if let Ok(out) = serde_json::to_string(&v) {
642        let _ = std::fs::write(&pref, out);
643    }
644}
645
646/// Locale / timezone for dig2browser. Driven only by env so OSS stays agnostic:
647/// `HATCHERY_BROWSER_LANG` (BCP-47, e.g. en-US) and `HATCHERY_BROWSER_TZ`
648/// (IANA, e.g. America/New_York). Unset → dig2browser defaults (en-US, no TZ override).
649/// When TZ is set we also export it into this process so Chrome inherits it.
650fn stealth_config() -> StealthConfig {
651    let mut cfg = StealthConfig::default();
652    let lang = std::env::var("HATCHERY_BROWSER_LANG").ok().filter(|v| !v.is_empty());
653    let tz = std::env::var("HATCHERY_BROWSER_TZ").ok().filter(|v| !v.is_empty());
654    if lang.is_none() && tz.is_none() {
655        return cfg;
656    }
657    if let Some(ref t) = tz {
658        // Chrome Date/Intl without CDP still see the process TZ.
659        std::env::set_var("TZ", t);
660    }
661    cfg.locale = LocaleProfile {
662        locale: lang.unwrap_or_else(|| cfg.locale.locale.clone()),
663        timezone: tz.or(cfg.locale.timezone),
664    };
665    cfg
666}
667
668/// CDP locale/timezone on a target (parent or popup). dig2browser already applies
669/// Emulation.setTimezoneOverride via stealth; we also force setLocaleOverride when
670/// HATCHERY_BROWSER_LANG is set. When ACCEPT_LANGUAGES is set, re-assert
671/// navigator.languages after dig2's single-locale JS override.
672async fn apply_browser_locale(page: &StealthPage) {
673    if let Ok(lang) = std::env::var("HATCHERY_BROWSER_LANG") {
674        if !lang.is_empty() {
675            let _ = page
676                .cdp_call("Emulation.setLocaleOverride", Some(json!({ "locale": lang })))
677                .await;
678        }
679    }
680    if let Ok(tz) = std::env::var("HATCHERY_BROWSER_TZ") {
681        if !tz.is_empty() {
682            let _ = page
683                .cdp_call(
684                    "Emulation.setTimezoneOverride",
685                    Some(json!({ "timezoneId": tz })),
686                )
687                .await;
688        }
689    }
690    if let Some(al) = browser_accept_languages() {
691        // dig2's --accept-lang is en-only; force the full en+ru header on this target.
692        let _ = page.cdp_call("Network.enable", None).await;
693        let _ = page
694            .cdp_call(
695                "Network.setExtraHTTPHeaders",
696                Some(json!({ "headers": { "Accept-Language": al } })),
697            )
698            .await;
699    }
700    let tags = browser_language_tags();
701    if tags.is_empty() {
702        return;
703    }
704    let primary = tags.first().cloned().unwrap_or_else(|| "en-US".into());
705    let tags_json = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".into());
706    let src = format!(
707        r#"(function(){{
708  const tags = {tags_json};
709  try {{
710    Object.defineProperty(Navigator.prototype, 'languages', {{
711      get: () => Object.freeze(tags.slice()), configurable: true
712    }});
713    Object.defineProperty(Navigator.prototype, 'language', {{
714      get: () => {primary:?}, configurable: true
715    }});
716  }} catch (_) {{}}
717}})();"#
718    );
719    let _ = page
720        .cdp_call(
721            "Page.addScriptToEvaluateOnNewDocument",
722            Some(json!({ "source": src })),
723        )
724        .await;
725    let _ = page
726        .eval(&format!(
727            "(() => {{ try {{ Object.defineProperty(navigator,'languages',{{get:()=>Object.freeze({tags_json}),configurable:true}}); Object.defineProperty(navigator,'language',{{get:()=>{primary:?},configurable:true}}); }} catch(_){{}} return [navigator.language, navigator.languages]; }})()"
728        ))
729        .await;
730}
731
732/// One persistent browser profile shared by every session of this node.
733/// Set by `HATCHERY_BROWSER_PROFILE` (a user-data-dir path); cookies and logins
734/// survive node restarts and are the same for all sessions and CLIs.
735pub mod shared {
736    use super::*;
737    use std::sync::OnceLock;
738
739    pub fn profile() -> Option<PathBuf> {
740        std::env::var_os("HATCHERY_BROWSER_PROFILE")
741            .filter(|v| !v.is_empty())
742            .map(PathBuf::from)
743    }
744
745    fn cell() -> &'static Mutex<Option<StealthBrowser>> {
746        static CELL: OnceLock<Mutex<Option<StealthBrowser>>> = OnceLock::new();
747        CELL.get_or_init(|| Mutex::new(None))
748    }
749
750    /// A Chrome left over from a previous node run still holds the profile lock.
751    fn kill_stale(dir: &std::path::Path) {
752        let want = format!("--user-data-dir={}", dir.display());
753        let Ok(rd) = std::fs::read_dir("/proc") else { return };
754        for entry in rd.flatten() {
755            let Ok(pid) = entry.file_name().to_string_lossy().parse::<i32>() else { continue };
756            let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
757            let args: Vec<&[u8]> = raw.split(|b| *b == 0).collect();
758            if args.iter().any(|a| *a == want.as_bytes()) {
759                let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
760            }
761        }
762        std::thread::sleep(Duration::from_millis(500));
763        for f in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
764            let _ = std::fs::remove_file(dir.join(f));
765        }
766    }
767
768    pub async fn open_page(dir: &std::path::Path) -> Result<StealthPage, String> {
769        let mut g = cell().lock().await;
770        let mut last = String::new();
771        for _ in 0..2 {
772            if g.is_none() {
773                kill_stale(dir);
774                ensure_profile_accept_languages(dir);
775                let b = StealthBrowser::launch_with(launch_config(dir), stealth_config())
776                    .await
777                    .map_err(|e| e.to_string())?;
778                *g = Some(b);
779            }
780            let b = g.as_ref().expect("browser set");
781            match new_window_page(b, dir).await {
782                Ok(p) => return Ok(p),
783                Err(e) => {
784                    tracing::warn!(error = %e, "shared browser unusable; relaunching");
785                    last = e;
786                    if let Some(old) = g.take() { let _ = old.close().await; }
787                }
788            }
789        }
790        Err(last)
791    }
792
793    /// Attach to an existing target (OAuth popup / window.open) in the shared Chrome.
794    pub async fn attach(dir: &std::path::Path, target_id: &str) -> Result<StealthPage, String> {
795        let g = cell().lock().await;
796        let b = g.as_ref().ok_or("shared browser not running")?;
797        // Ensure this is still the profile we expect (cell is process-global).
798        let _ = dir;
799        b.attach_page(target_id).await.map_err(|e| e.to_string())
800    }
801
802    /// Target.createTarget{newWindow} on the browser endpoint, then attach.
803    async fn new_window_page(b: &StealthBrowser, dir: &std::path::Path) -> Result<StealthPage, String> {
804        use futures_util::{SinkExt, StreamExt};
805        use tokio_tungstenite::tungstenite::Message;
806        let port = super::screencast::devtools_port(dir).ok_or("devtools port not found")?;
807        let path = browser_ws_path(port).await?;
808        let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}{path}"))
809            .await
810            .map_err(|e| e.to_string())?;
811        ws.send(Message::text(json!({ "id": 1, "method": "Target.createTarget",
812            "params": { "url": "about:blank", "newWindow": true } }).to_string()))
813            .await
814            .map_err(|e| e.to_string())?;
815        let target = tokio::time::timeout(Duration::from_secs(10), async {
816            while let Some(Ok(m)) = ws.next().await {
817                let Message::Text(t) = m else { continue };
818                let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else { continue };
819                if v["id"] == 1 {
820                    return v["result"]["targetId"].as_str().map(str::to_owned).ok_or_else(|| t.to_string());
821                }
822            }
823            Err("browser endpoint closed".to_string())
824        })
825        .await
826        .map_err(|_| "createTarget timed out".to_string())??;
827        let _ = ws.close(None).await;
828        b.attach_page(&target).await.map_err(|e| e.to_string())
829    }
830
831    /// `/devtools/browser/<id>` from the DevTools HTTP endpoint (`/json/version`).
832    async fn browser_ws_path(port: u16) -> Result<String, String> {
833        use tokio::io::{AsyncReadExt, AsyncWriteExt};
834        let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| e.to_string())?;
835        // Chrome keeps the connection open: read headers, then Content-Length bytes.
836        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())?;
837        let mut buf = Vec::new();
838        let body = tokio::time::timeout(Duration::from_secs(5), async {
839            let mut chunk = [0u8; 4096];
840            loop {
841                let n = s.read(&mut chunk).await.map_err(|e| e.to_string())?;
842                if n == 0 { return Err("devtools closed".to_string()) }
843                buf.extend_from_slice(&chunk[..n]);
844                let text = String::from_utf8_lossy(&buf).into_owned();
845                if let Some((head, body)) = text.split_once("\r\n\r\n") {
846                    let len = head.lines()
847                        .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok()))
848                        .unwrap_or(0);
849                    if body.len() >= len { return Ok(body.to_owned()) }
850                }
851            }
852        })
853        .await
854        .map_err(|_| "devtools /json/version timed out".to_string())??;
855        let body = body.as_str();
856        let v: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
857        let url = v["webSocketDebuggerUrl"].as_str().ok_or("no webSocketDebuggerUrl")?;
858        url.find("/devtools/browser").map(|i| url[i..].to_owned()).ok_or_else(|| "bad webSocketDebuggerUrl".into())
859    }
860}
861
862async fn page_meta(page: &StealthPage) -> Result<(String, String), String> {
863    let v = page.eval("[location.href, document.title]").await.map_err(|e| e.to_string())?;
864    let a = v.as_array().cloned().unwrap_or_default();
865    Ok((
866        a.first().and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
867        a.get(1).and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
868    ))
869}
870
871/// Delta frames: diff each decoded frame against the previous one in 32px tiles
872/// and send only the changed rectangle when it is a small part of the screen.
873mod delta {
874    use super::BrowserFrameRegion;
875    use image::{codecs::jpeg::JpegEncoder, ImageReader, RgbImage};
876
877    const TILE: u32 = 32;
878    #[derive(Default)]
879    pub struct Delta { prev: Option<RgbImage> }
880    pub enum Out { Unchanged, Full, Region(Vec<u8>, BrowserFrameRegion) }
881
882    impl Delta {
883        pub fn encode(&mut self, jpeg: &[u8]) -> Out {
884            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 };
885            let cur = img.to_rgb8();
886            let Some(prev) = self.prev.as_ref().filter(|p| p.dimensions() == cur.dimensions()) else {
887                self.prev = Some(cur);
888                return Out::Full;
889            };
890            let (w, h) = cur.dimensions();
891            let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
892            let (pr, cr) = (prev.as_raw(), cur.as_raw());
893            for ty in (0..h).step_by(TILE as usize) {
894                for tx in (0..w).step_by(TILE as usize) {
895                    let tw = TILE.min(w - tx); let th = TILE.min(h - ty);
896                    let mut changed = false;
897                    for yy in ty..ty + th {
898                        let a = ((yy * w + tx) * 3) as usize; let b = a + (tw * 3) as usize;
899                        // JPEG noise tolerance: a tile changes when any byte moves > 24.
900                        if pr[a..b].iter().zip(&cr[a..b]).any(|(p, c)| p.abs_diff(*c) > 24) { changed = true; break; }
901                    }
902                    if changed { x0 = x0.min(tx); y0 = y0.min(ty); x1 = x1.max(tx + tw); y1 = y1.max(ty + th); }
903                }
904            }
905            if x0 == u32::MAX { return Out::Unchanged; }
906            let (rw, rh) = (x1 - x0, y1 - y0);
907            if (rw * rh) as u64 * 2 > (w * h) as u64 {
908                self.prev = Some(cur);
909                return Out::Full;
910            }
911            let crop = image::imageops::crop_imm(&cur, x0, y0, rw, rh).to_image();
912            let mut out = Vec::new();
913            if JpegEncoder::new_with_quality(&mut out, 70).encode_image(&crop).is_err() { self.prev = Some(cur); return Out::Full; }
914            // Base the next diff on what the viewer now shows (prev + this region).
915            let mut next = prev.clone();
916            image::imageops::replace(&mut next, &crop, x0 as i64, y0 as i64);
917            self.prev = Some(next);
918            Out::Region(out, BrowserFrameRegion { x: x0, y: y0, w: rw, h: rh, src_w: w, src_h: h })
919        }
920
921        /// Current composite as a full JPEG (for a viewer that just attached).
922        pub fn full_jpeg(&self) -> Option<Vec<u8>> {
923            let img = self.prev.as_ref()?;
924            let mut out = Vec::new();
925            JpegEncoder::new_with_quality(&mut out, 60).encode_image(img).ok()?;
926            Some(out)
927        }
928    }
929}
930
931/// Watch the browser target tree for window.open / OAuth popups belonging to our page.
932mod popups {
933    use super::*;
934    use futures_util::{SinkExt, StreamExt};
935    use tokio_tungstenite::tungstenite::Message;
936
937    pub enum Ev {
938        /// A page target opened by `opener` (our session page).
939        Opened { target_id: String, url: String },
940        /// A page target we were watching went away (window.close).
941        Closed { target_id: String },
942    }
943
944    /// Discover page targets that our `opener` already opened (e.g. Google OAuth
945    /// before we started watching) and watch for new ones.
946    pub async fn start(
947        profile_dir: &std::path::Path,
948        opener_id: String,
949    ) -> Result<(mpsc::UnboundedReceiver<Ev>, tokio::task::JoinHandle<()>), String> {
950        let port = screencast::devtools_port(profile_dir).ok_or("devtools port not found")?;
951        let path = browser_ws_path(port).await?;
952        let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}{path}"))
953            .await
954            .map_err(|e| e.to_string())?;
955        let (tx, rx) = mpsc::unbounded_channel();
956        // Enable target discovery; also pull current targets so an already-open
957        // OAuth window is adopted immediately.
958        ws.send(Message::text(
959            json!({ "id": 1, "method": "Target.setDiscoverTargets", "params": { "discover": true } })
960                .to_string(),
961        ))
962        .await
963        .map_err(|e| e.to_string())?;
964        ws.send(Message::text(json!({ "id": 2, "method": "Target.getTargets" }).to_string()))
965            .await
966            .map_err(|e| e.to_string())?;
967        let join = tokio::spawn(async move {
968            while let Some(Ok(msg)) = ws.next().await {
969                let Message::Text(text) = msg else { continue };
970                let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
971                if v["id"] == 2 {
972                    if let Some(arr) = v["result"]["targetInfos"].as_array() {
973                        for t in arr {
974                            maybe_open(&tx, &opener_id, t);
975                        }
976                    }
977                    continue;
978                }
979                match v["method"].as_str() {
980                    Some("Target.targetCreated") => {
981                        maybe_open(&tx, &opener_id, &v["params"]["targetInfo"]);
982                    }
983                    Some("Target.targetDestroyed") => {
984                        if let Some(id) = v["params"]["targetId"].as_str() {
985                            let _ = tx.send(Ev::Closed {
986                                target_id: id.to_owned(),
987                            });
988                        }
989                    }
990                    Some("Target.targetInfoChanged") => {
991                        // URL fills in after creation; surface Opened again with the URL.
992                        maybe_open(&tx, &opener_id, &v["params"]["targetInfo"]);
993                    }
994                    _ => {}
995                }
996            }
997        });
998        Ok((rx, join))
999    }
1000
1001    fn maybe_open(tx: &mpsc::UnboundedSender<Ev>, opener_id: &str, info: &serde_json::Value) {
1002        let ty = info["type"].as_str().unwrap_or("");
1003        if ty != "page" && ty != "webview" {
1004            return;
1005        }
1006        let Some(tid) = info["targetId"].as_str() else { return };
1007        let opener = info["openerId"].as_str().unwrap_or("");
1008        if opener != opener_id {
1009            return;
1010        }
1011        let url = info["url"].as_str().unwrap_or("").to_owned();
1012        let _ = tx.send(Ev::Opened {
1013            target_id: tid.to_owned(),
1014            url,
1015        });
1016    }
1017
1018    async fn browser_ws_path(port: u16) -> Result<String, String> {
1019        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1020        let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
1021            .await
1022            .map_err(|e| e.to_string())?;
1023        s.write_all(b"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
1024            .await
1025            .map_err(|e| e.to_string())?;
1026        let mut buf = Vec::new();
1027        let body = tokio::time::timeout(Duration::from_secs(5), async {
1028            let mut chunk = [0u8; 4096];
1029            loop {
1030                let n = s.read(&mut chunk).await.map_err(|e| e.to_string())?;
1031                if n == 0 {
1032                    return Err("devtools closed".to_string());
1033                }
1034                buf.extend_from_slice(&chunk[..n]);
1035                let text = String::from_utf8_lossy(&buf).into_owned();
1036                if let Some((head, body)) = text.split_once("\r\n\r\n") {
1037                    let len = head
1038                        .lines()
1039                        .find_map(|l| {
1040                            l.to_ascii_lowercase()
1041                                .strip_prefix("content-length:")
1042                                .and_then(|v| v.trim().parse::<usize>().ok())
1043                        })
1044                        .unwrap_or(0);
1045                    if body.len() >= len {
1046                        return Ok(body.to_owned());
1047                    }
1048                }
1049            }
1050        })
1051        .await
1052        .map_err(|_| "devtools /json/version timed out".to_string())??;
1053        let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
1054        let url = v["webSocketDebuggerUrl"]
1055            .as_str()
1056            .ok_or("no webSocketDebuggerUrl")?;
1057        url.find("/devtools/browser")
1058            .map(|i| url[i..].to_owned())
1059            .ok_or_else(|| "bad webSocketDebuggerUrl".into())
1060    }
1061}
1062
1063/// Side CDP websocket to our page target for Page.startScreencast.
1064mod screencast {
1065    use super::*;
1066    use futures_util::{SinkExt, StreamExt};
1067    use tokio_tungstenite::tungstenite::Message;
1068
1069    /// DevTools port of the Chrome that owns `profile_dir`: DevToolsActivePort if
1070    /// present, else the --remote-debugging-port of the process using that profile.
1071    pub(super) fn devtools_port(profile_dir: &std::path::Path) -> Option<u16> {
1072        if let Ok(s) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
1073            if let Some(p) = s.lines().next().and_then(|l| l.trim().parse().ok()) { return Some(p); }
1074        }
1075        let want = format!("--user-data-dir={}", profile_dir.display());
1076        for entry in std::fs::read_dir("/proc").ok()?.flatten() {
1077            let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
1078            let args: Vec<String> = raw.split(|b| *b == 0).map(|a| String::from_utf8_lossy(a).into_owned()).collect();
1079            if !args.iter().any(|a| a == &want) { continue; }
1080            if let Some(p) = args.iter().find_map(|a| a.strip_prefix("--remote-debugging-port=").and_then(|v| v.parse().ok())) {
1081                return Some(p);
1082            }
1083        }
1084        None
1085    }
1086
1087    /// Max JPEG bytes per frame so one frame fits a single restream message.
1088    const BUDGET: usize = 40 * 1024;
1089
1090    pub async fn start(
1091        profile_dir: &std::path::Path,
1092        page: &StealthPage,
1093        out: mpsc::Sender<Vec<u8>>,
1094    ) -> Result<tokio::task::JoinHandle<()>, String> {
1095        let port = devtools_port(profile_dir).ok_or("devtools port not found")?;
1096        let info = page.cdp_call("Target.getTargetInfo", None).await.map_err(|e| e.to_string())?;
1097        let target = info["targetInfo"]["targetId"].as_str().ok_or("no target id")?.to_owned();
1098        let url = format!("ws://127.0.0.1:{port}/devtools/page/{target}");
1099        let (ws, _) = tokio_tungstenite::connect_async(&url).await.map_err(|e| e.to_string())?;
1100        let (mut tx, mut rx) = ws.split();
1101        let mut quality: u32 = 50;
1102        let mut max_w: u32 = VIEW_W;
1103        let mut id: u64 = 1;
1104        let start_msg = |id: u64, q: u32, w: u32| json!({ "id": id, "method": "Page.startScreencast",
1105            "params": { "format": "jpeg", "quality": q, "maxWidth": w, "maxHeight": w * VIEW_H / VIEW_W, "everyNthFrame": 1 } }).to_string();
1106        tx.send(Message::text(json!({ "id": id, "method": "Page.enable" }).to_string())).await.map_err(|e| e.to_string())?;
1107        id += 1;
1108        tx.send(Message::text(start_msg(id, quality, max_w))).await.map_err(|e| e.to_string())?;
1109        Ok(tokio::spawn(async move {
1110            while let Some(Ok(msg)) = rx.next().await {
1111                let Message::Text(text) = msg else { continue };
1112                let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
1113                if v["method"] != "Page.screencastFrame" { continue; }
1114                let sid = v["params"]["sessionId"].clone();
1115                // Ack first so Chrome can start producing the next frame immediately.
1116                id += 1;
1117                let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.screencastFrameAck", "params": { "sessionId": sid } }).to_string())).await;
1118                let Some(b64) = v["params"]["data"].as_str() else { continue };
1119                use base64::Engine;
1120                let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else { continue };
1121                if bytes.len() > BUDGET {
1122                    // Too big for one restream message: step quality, then size, and restart.
1123                    if quality > 25 { quality -= 15 } else if max_w > 640 { max_w = max_w * 3 / 4 } else { continue }
1124                    id += 1;
1125                    let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.stopScreencast" }).to_string())).await;
1126                    id += 1;
1127                    let _ = tx.send(Message::text(start_msg(id, quality, max_w))).await;
1128                    continue;
1129                }
1130                // Keep latency low: if the consumer is behind, drop rather than queue.
1131                let _ = out.try_send(bytes);
1132            }
1133        }))
1134    }
1135}
1136
1137async fn capture_frame(page: &StealthPage) -> Result<Vec<u8>, String> {
1138    // JPEG via CDP, sized to fit one Noise transport message (64 KiB incl. envelope):
1139    // step quality down, then scale, until under FRAME_BUDGET. Oversized frames used to
1140    // kill the restream uplink ("noise: input error") on busy pages.
1141    const FRAME_BUDGET: usize = 40 * 1024;
1142    let mut bytes = Vec::new();
1143    'fit: for scale in [1.0f64, 0.75, 0.5] {
1144        for q in [60u32, 45, 32, 22, 14] {
1145            let mut params = json!({ "format": "jpeg", "quality": q });
1146            if scale < 1.0 {
1147                params["clip"] = json!({ "x": 0, "y": 0, "width": VIEW_W, "height": VIEW_H, "scale": scale });
1148            }
1149            let v = page.cdp_call("Page.captureScreenshot", Some(params)).await.map_err(|e| e.to_string())?;
1150            let b64 = v.get("data").and_then(|x| x.as_str()).ok_or("no jpeg data")?;
1151            use base64::Engine;
1152            bytes = base64::engine::general_purpose::STANDARD.decode(b64).map_err(|e| e.to_string())?;
1153            if bytes.len() <= FRAME_BUDGET {
1154                break 'fit;
1155            }
1156        }
1157    }
1158    if bytes.len() > FRAME_BUDGET {
1159        return Err(format!("frame too large ({} bytes)", bytes.len()));
1160    }
1161    Ok(bytes)
1162}
1163
1164async fn apply_input(page: &StealthPage, input: &BrowserInput) -> Result<(), String> {
1165    match input {
1166        BrowserInput::Click { x, y, button } => {
1167            // Raw CDP, viewport CSS pixels (DPR 1): move, press, release — a real
1168            // trusted click that also reaches cross-origin iframes (e.g. Turnstile).
1169            let b = match button {
1170                BrowserMouseButton::Left => "left",
1171                BrowserMouseButton::Right => "right",
1172                BrowserMouseButton::Middle => "middle",
1173            };
1174            tracing::info!(x = *x, y = *y, button = b, "d2b click");
1175            page.cdp_call(
1176                "Input.dispatchMouseEvent",
1177                Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })),
1178            )
1179            .await
1180            .map_err(|e| e.to_string())?;
1181            for ty in ["mousePressed", "mouseReleased"] {
1182                page.cdp_call(
1183                    "Input.dispatchMouseEvent",
1184                    Some(json!({ "type": ty, "x": x, "y": y, "button": b, "buttons": if ty == "mousePressed" { 1 } else { 0 }, "clickCount": 1 })),
1185                )
1186                .await
1187                .map_err(|e| e.to_string())?;
1188            }
1189        }
1190        BrowserInput::MouseDown { x, y, button } | BrowserInput::MouseUp { x, y, button } => {
1191            let down = matches!(input, BrowserInput::MouseDown { .. });
1192            let b = match button {
1193                BrowserMouseButton::Left => "left",
1194                BrowserMouseButton::Right => "right",
1195                BrowserMouseButton::Middle => "middle",
1196            };
1197            let count = mouse::click_count(down, *x, *y);
1198            if down {
1199                page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })))
1200                    .await
1201                    .map_err(|e| e.to_string())?;
1202            }
1203            page.cdp_call(
1204                "Input.dispatchMouseEvent",
1205                Some(json!({ "type": if down { "mousePressed" } else { "mouseReleased" }, "x": x, "y": y,
1206                    "button": b, "buttons": if down { mouse::bit(b) } else { 0 }, "clickCount": count })),
1207            )
1208            .await
1209            .map_err(|e| e.to_string())?;
1210            mouse::set_held(if down { mouse::bit(b) } else { 0 });
1211        }
1212        BrowserInput::MouseMove { x, y } => {
1213            let held = mouse::held();
1214            let b = match held { 1 => "left", 2 => "right", 4 => "middle", _ => "none" };
1215            page.cdp_call("Input.dispatchMouseEvent", Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": b, "buttons": held })))
1216                .await
1217                .map_err(|e| e.to_string())?;
1218        }
1219        BrowserInput::Scroll { x, y, dx, dy } => {
1220            page.wheel(*x, *y, *dx, *dy)
1221                .await
1222                .map_err(|e| e.to_string())?
1223        }
1224        BrowserInput::Key { key } => keys::press(page, key).await?,
1225        BrowserInput::Text { text } => {
1226            // Prefer CDP insertText; fall back to per-character key_press.
1227            match page
1228                .cdp_call("Input.insertText", Some(json!({ "text": text })))
1229                .await
1230            {
1231                Ok(_) => {}
1232                Err(_) => {
1233                    for ch in text.chars() {
1234                        page.key_press(&ch.to_string())
1235                            .await
1236                            .map_err(|e| e.to_string())?;
1237                    }
1238                }
1239            }
1240        }
1241        BrowserInput::Navigate { url } if url.is_empty() => {}
1242        BrowserInput::Navigate { url } => {
1243            // Raw Page.navigate returns once the request commits; frames keep
1244            // streaming while the page loads (goto blocked the loop on load).
1245            // Cap the wait so a hung CDP cannot freeze the capture loop.
1246            tracing::info!(%url, "d2b navigate");
1247            let _ = page.cdp_call("Page.bringToFront", None).await;
1248            match tokio::time::timeout(
1249                Duration::from_secs(5),
1250                page.cdp_call("Page.navigate", Some(json!({ "url": url }))),
1251            )
1252            .await
1253            {
1254                Ok(Ok(r)) => {
1255                    if let Some(err) = r.get("errorText").and_then(|v| v.as_str()) {
1256                        if !err.is_empty() {
1257                            return Err(format!("navigate: {err}"));
1258                        }
1259                    }
1260                }
1261                Ok(Err(e)) => return Err(e.to_string()),
1262                Err(_) => tracing::warn!(%url, "d2b navigate timed out; continuing"),
1263            }
1264        }
1265    }
1266    Ok(())
1267}
1268
1269/// Mouse state for press/drag/release (one operator drives a page at a time).
1270mod mouse {
1271    use std::sync::atomic::{AtomicU32, Ordering};
1272    use std::sync::Mutex;
1273    static HELD: AtomicU32 = AtomicU32::new(0);
1274    static LAST: Mutex<Option<(std::time::Instant, f64, f64, u32)>> = Mutex::new(None);
1275
1276    pub fn bit(b: &str) -> u32 { match b { "left" => 1, "right" => 2, "middle" => 4, _ => 0 } }
1277    pub fn held() -> u32 { HELD.load(Ordering::Relaxed) }
1278    pub fn set_held(v: u32) { HELD.store(v, Ordering::Relaxed) }
1279
1280    /// Double / triple click: presses close in time and place count up.
1281    pub fn click_count(down: bool, x: f64, y: f64) -> u32 {
1282        let mut g = LAST.lock().unwrap_or_else(|e| e.into_inner());
1283        let now = std::time::Instant::now();
1284        let prev = g.filter(|(t, px, py, _)| now.duration_since(*t).as_millis() < 500 && (px - x).abs() < 6.0 && (py - y).abs() < 6.0);
1285        if !down { return prev.map(|p| p.3).unwrap_or(1) }
1286        let n = prev.map(|p| p.3 % 3 + 1).unwrap_or(1);
1287        *g = Some((now, x, y, n));
1288        n
1289    }
1290}
1291
1292/// Shared map for the restream bridge (process-global for the node binary).
1293pub fn shared_map() -> Arc<BrowserSessionMap> {
1294    use std::sync::OnceLock;
1295    static MAP: OnceLock<Arc<BrowserSessionMap>> = OnceLock::new();
1296    MAP.get_or_init(|| Arc::new(BrowserSessionMap::new())).clone()
1297}
1298
1299/// Keyboard over raw CDP: "Control+Shift+a" style chords, named keys with their
1300/// virtual key codes (Backspace, Delete, arrows...), editing commands for the
1301/// usual shortcuts, and browser navigation keys (reload, back, forward).
1302mod keys {
1303    use super::*;
1304
1305    pub struct Chord<'a> { pub mods: u32, pub key: &'a str }
1306
1307    pub fn parse(combo: &str) -> Chord<'_> {
1308        let mut mods = 0;
1309        let mut rest = combo;
1310        loop {
1311            let Some((head, tail)) = rest.split_once('+') else { break };
1312            if tail.is_empty() { break }
1313            let bit = match head { "Alt" => 1, "Control" | "Ctrl" => 2, "Meta" => 4, "Shift" => 8, _ => break };
1314            mods |= bit;
1315            rest = tail;
1316        }
1317        Chord { mods, key: rest }
1318    }
1319
1320    /// (code, windowsVirtualKeyCode, text)
1321    pub fn def(key: &str) -> (String, u32, Option<String>) {
1322        let named = |code: &str, vk: u32, text: Option<&str>| (code.to_owned(), vk, text.map(str::to_owned));
1323        match key {
1324            "Backspace" => named("Backspace", 8, None),
1325            "Tab" => named("Tab", 9, None),
1326            "Enter" => named("Enter", 13, Some("\r")),
1327            "Escape" => named("Escape", 27, None),
1328            "PageUp" => named("PageUp", 33, None),
1329            "PageDown" => named("PageDown", 34, None),
1330            "End" => named("End", 35, None),
1331            "Home" => named("Home", 36, None),
1332            "ArrowLeft" => named("ArrowLeft", 37, None),
1333            "ArrowUp" => named("ArrowUp", 38, None),
1334            "ArrowRight" => named("ArrowRight", 39, None),
1335            "ArrowDown" => named("ArrowDown", 40, None),
1336            "Insert" => named("Insert", 45, None),
1337            "Delete" => named("Delete", 46, None),
1338            k if k.len() > 1 && k.starts_with('F') && k[1..].parse::<u32>().is_ok_and(|n| (1..=12).contains(&n)) => {
1339                let n: u32 = k[1..].parse().unwrap_or(1);
1340                (k.to_owned(), 111 + n, None)
1341            }
1342            k => {
1343                let mut it = k.chars();
1344                match (it.next(), it.next()) {
1345                    (Some(c), None) if c.is_ascii_alphabetic() => {
1346                        let u = c.to_ascii_uppercase();
1347                        (format!("Key{u}"), u as u32, Some(k.to_owned()))
1348                    }
1349                    (Some(c), None) if c.is_ascii_digit() => (format!("Digit{c}"), c as u32, Some(k.to_owned())),
1350                    (Some(' '), None) => ("Space".into(), 32, Some(" ".into())),
1351                    (Some(_), None) => (String::new(), 0, Some(k.to_owned())),
1352                    _ => (String::new(), 0, None),
1353                }
1354            }
1355        }
1356    }
1357
1358    pub fn commands(mods: u32, key: &str) -> Vec<&'static str> {
1359        if mods & 6 == 0 { return vec![] }
1360        match (key.to_ascii_lowercase().as_str(), mods & 8 != 0) {
1361            ("a", _) => vec!["selectAll"],
1362            ("c", _) => vec!["copy"],
1363            ("x", _) => vec!["cut"],
1364            ("v", _) => vec!["paste"],
1365            ("z", false) => vec!["undo"],
1366            ("z", true) | ("y", _) => vec!["redo"],
1367            _ => vec![],
1368        }
1369    }
1370
1371    pub fn is_copy(combo: &str) -> bool {
1372        let c = parse(combo);
1373        c.mods & 6 != 0 && matches!(c.key, "c" | "C" | "x" | "X" | "Insert")
1374    }
1375
1376    /// Selected text of the page (inputs and textareas included), capped.
1377    pub async fn selection(page: &StealthPage) -> Option<String> {
1378        let v = page.eval("(() => { const a = document.activeElement; let t = ''; \
1379            if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA') && typeof a.selectionStart === 'number') t = a.value.slice(a.selectionStart, a.selectionEnd); \
1380            if (!t) t = String(window.getSelection() || ''); return t.slice(0, 100000); })()").await.ok()?;
1381        v.as_str().map(str::to_owned)
1382    }
1383
1384    enum Nav { Reload, Back, Forward }
1385
1386    fn nav(c: &Chord<'_>) -> Option<Nav> {
1387        match (c.key, c.mods) {
1388            ("BrowserRefresh", _) | ("F5", _) => Some(Nav::Reload),
1389            ("r" | "R", m) if m & 6 != 0 => Some(Nav::Reload),
1390            ("BrowserBack", _) => Some(Nav::Back),
1391            ("ArrowLeft", 1) => Some(Nav::Back),
1392            ("BrowserForward", _) => Some(Nav::Forward),
1393            ("ArrowRight", 1) => Some(Nav::Forward),
1394            _ => None,
1395        }
1396    }
1397
1398    async fn history(page: &StealthPage, step: i64) -> Result<(), String> {
1399        let h = page.cdp_call("Page.getNavigationHistory", None).await.map_err(|e| e.to_string())?;
1400        let i = h["currentIndex"].as_i64().unwrap_or(0) + step;
1401        let entries = h["entries"].as_array().cloned().unwrap_or_default();
1402        let Some(e) = usize::try_from(i).ok().and_then(|i| entries.get(i)) else { return Ok(()) };
1403        page.cdp_call("Page.navigateToHistoryEntry", Some(json!({ "entryId": e["id"] })))
1404            .await
1405            .map_err(|e| e.to_string())?;
1406        Ok(())
1407    }
1408
1409    pub async fn press(page: &StealthPage, combo: &str) -> Result<(), String> {
1410        let c = parse(combo);
1411        if matches!(c.key, "Shift" | "Control" | "Alt" | "Meta" | "CapsLock" | "") { return Ok(()) }
1412        if let Some(n) = nav(&c) {
1413            return match n {
1414                Nav::Reload => page.cdp_call("Page.reload", None).await.map(|_| ()).map_err(|e| e.to_string()),
1415                Nav::Back => history(page, -1).await,
1416                Nav::Forward => history(page, 1).await,
1417            };
1418        }
1419        let (code, vk, text) = def(c.key);
1420        let typed = if c.mods & 7 == 0 { text } else { None };
1421        let mut down = json!({
1422            "type": if typed.is_some() { "keyDown" } else { "rawKeyDown" },
1423            "modifiers": c.mods, "key": c.key, "code": code,
1424            "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
1425        });
1426        if let Some(t) = &typed { down["text"] = json!(t); down["unmodifiedText"] = json!(t); }
1427        let cmds = commands(c.mods, c.key);
1428        if !cmds.is_empty() { down["commands"] = json!(cmds); }
1429        page.cdp_call("Input.dispatchKeyEvent", Some(down)).await.map_err(|e| e.to_string())?;
1430        page.cdp_call("Input.dispatchKeyEvent", Some(json!({
1431            "type": "keyUp", "modifiers": c.mods, "key": c.key, "code": code,
1432            "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
1433        }))).await.map_err(|e| e.to_string())?;
1434        Ok(())
1435    }
1436
1437    #[cfg(test)]
1438    mod tests {
1439        use super::*;
1440        #[test]
1441        fn chords_parse() {
1442            let c = parse("Control+Shift+a"); assert_eq!((c.mods, c.key), (10, "a"));
1443            let c = parse("Control++"); assert_eq!((c.mods, c.key), (2, "+"));
1444            let c = parse("Backspace"); assert_eq!((c.mods, c.key), (0, "Backspace"));
1445            assert_eq!(def("Delete").1, 46);
1446            assert_eq!(def("a").0, "KeyA");
1447            assert_eq!(commands(2, "a"), vec!["selectAll"]);
1448        }
1449    }
1450}