Skip to main content

hatchery_core/
browser_wire.rs

1//! Opaque browser-stream wire (dig2-compatible).
2//!
3//! Carried inside the restream G4aControl sidecar as `{"browser": ...}` JSON, or as
4//! raw input bytes inside that JSON. C2 never parses these bytes.
5
6use serde::{Deserialize, Serialize};
7
8const WIRE_VERSION: u8 = 1;
9const KIND_MOUSE: u8 = 1;
10const KIND_KEY: u8 = 2;
11const KIND_SCROLL: u8 = 3;
12const KIND_NAV: u8 = 4;
13const KIND_TEXT: u8 = 5;
14const MOUSE_DOWN: u8 = 1;
15const MOUSE_MOVE: u8 = 2;
16const MOUSE_UP: u8 = 3;
17const MOUSE_CLICK: u8 = 4;
18const KEY_PRESS: u8 = 3;
19const MAX_KEY: usize = 64;
20const MAX_TEXT: usize = 2048;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum BrowserMouseButton {
25    Left,
26    Middle,
27    Right,
28}
29
30/// HQ → node browser control (over the restream control channel).
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "op", rename_all = "snake_case")]
33pub enum BrowserClientOp {
34    Start {
35        session: String,
36        #[serde(default)]
37        url: String,
38        #[serde(default)]
39        profile_dir: Option<String>,
40    },
41    /// Dig2-compatible input bytes (click / scroll / key / text / nav).
42    Input {
43        session: String,
44        #[serde(with = "serde_bytes_b64")]
45        bytes: Vec<u8>,
46    },
47    Stop {
48        session: String,
49    },
50    /// Session-local server pool (tesserax-http). Reply body carries JSON.
51    LocalServerStart {
52        session: String,
53        kind: String,
54        #[serde(default)]
55        root: Option<String>,
56        #[serde(default)]
57        cwd: Option<String>,
58        #[serde(default)]
59        bin: Option<String>,
60        #[serde(default)]
61        open: bool,
62        #[serde(default)]
63        spa: Option<bool>,
64    },
65    LocalServerStop {
66        session: String,
67        id: String,
68    },
69    LocalServerList {
70        session: String,
71    },
72}
73
74/// Node → HQ browser events (frames and lifecycle).
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "op", rename_all = "snake_case")]
77pub enum BrowserStreamEvent {
78    Started {
79        node_id: String,
80        session: String,
81        url: String,
82    },
83    Frame {
84        node_id: String,
85        session: String,
86        /// PNG or JPEG bytes.
87        #[serde(with = "serde_bytes_b64")]
88        bytes: Vec<u8>,
89        width: u32,
90        height: u32,
91        #[serde(default)]
92        url: String,
93        #[serde(default)]
94        title: String,
95        /// Latency trace for the first frame after an input (node-side hops).
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        trace: Option<BrowserFrameTrace>,
98        /// Delta frame: `bytes` covers only this region of the full image.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        region: Option<BrowserFrameRegion>,
101    },
102    Stopped {
103        node_id: String,
104        session: String,
105        #[serde(default)]
106        reason: String,
107    },
108    Error {
109        node_id: String,
110        session: String,
111        message: String,
112    },
113    /// Text the page copied (Ctrl+C / Ctrl+X): HQ puts it on the operator clipboard.
114    Clipboard {
115        node_id: String,
116        session: String,
117        text: String,
118    },
119    /// Result of a LocalServer* control op (list/start/stop), for HQ sidebar.
120    LocalServer {
121        node_id: String,
122        session: String,
123        ok: bool,
124        #[serde(default)]
125        message: String,
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        body: Option<serde_json::Value>,
128    },
129}
130
131/// Changed rectangle of a delta frame, in source-image pixels.
132#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct BrowserFrameRegion {
134    pub x: u32,
135    pub y: u32,
136    pub w: u32,
137    pub h: u32,
138    /// Full source image size the region is relative to.
139    pub src_w: u32,
140    pub src_h: u32,
141}
142
143/// Node-side timings (milliseconds) for the frame that first reflects an input.
144#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct BrowserFrameTrace {
146    /// Count of inputs this browser session has received (monotonic).
147    pub input_n: u64,
148    /// Bridge receipt → CDP dispatch start.
149    pub queue_ms: u32,
150    /// CDP Input.dispatch* round trip(s).
151    pub cdp_ms: u32,
152    /// CDP dispatch done → first changed frame available on the node.
153    pub to_frame_ms: u32,
154    /// Total node time (receipt → frame handed to the uplink).
155    pub node_ms: u32,
156    /// "screencast" or "poll".
157    #[serde(default)]
158    pub source: String,
159}
160
161/// Reply to a BrowserClientOp (correlated by op_id on the control channel).
162#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub struct BrowserOpReply {
164    pub ok: bool,
165    pub session: String,
166    #[serde(default)]
167    pub message: String,
168    /// JSON body for local_server list/start replies (opaque to C2).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub body: Option<serde_json::Value>,
171}
172
173mod serde_bytes_b64 {
174    use base64::Engine;
175    use serde::{Deserialize, Deserializer, Serializer};
176
177    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
178        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
179    }
180
181    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
182        let text = String::deserialize(d)?;
183        base64::engine::general_purpose::STANDARD
184            .decode(text.as_bytes())
185            .map_err(serde::de::Error::custom)
186    }
187}
188
189#[derive(Clone, Debug, PartialEq)]
190pub enum BrowserInput {
191    Click {
192        x: f64,
193        y: f64,
194        button: BrowserMouseButton,
195    },
196    /// Press / drag / release (text selection, sliders, drag and drop).
197    MouseDown {
198        x: f64,
199        y: f64,
200        button: BrowserMouseButton,
201    },
202    MouseMove {
203        x: f64,
204        y: f64,
205    },
206    MouseUp {
207        x: f64,
208        y: f64,
209        button: BrowserMouseButton,
210    },
211    Scroll {
212        x: f64,
213        y: f64,
214        dx: f64,
215        dy: f64,
216    },
217    Key {
218        key: String,
219    },
220    Text {
221        text: String,
222    },
223    Navigate {
224        url: String,
225    },
226}
227
228pub fn encode_click(x: f64, y: f64, button: BrowserMouseButton) -> Vec<u8> {
229    encode_mouse(MOUSE_CLICK, x, y, button)
230}
231
232pub fn encode_mouse_down(x: f64, y: f64, button: BrowserMouseButton) -> Vec<u8> {
233    encode_mouse(MOUSE_DOWN, x, y, button)
234}
235
236pub fn encode_mouse_move(x: f64, y: f64) -> Vec<u8> {
237    encode_mouse(MOUSE_MOVE, x, y, BrowserMouseButton::Left)
238}
239
240pub fn encode_mouse_up(x: f64, y: f64, button: BrowserMouseButton) -> Vec<u8> {
241    encode_mouse(MOUSE_UP, x, y, button)
242}
243
244fn encode_mouse(action: u8, x: f64, y: f64, button: BrowserMouseButton) -> Vec<u8> {
245    let mut out = vec![
246        WIRE_VERSION,
247        KIND_MOUSE,
248        action,
249        match button {
250            BrowserMouseButton::Left => 0,
251            BrowserMouseButton::Middle => 1,
252            BrowserMouseButton::Right => 2,
253        },
254    ];
255    out.extend_from_slice(&x.to_le_bytes());
256    out.extend_from_slice(&y.to_le_bytes());
257    out
258}
259
260pub fn encode_scroll(x: f64, y: f64, dx: f64, dy: f64) -> Vec<u8> {
261    let mut out = vec![WIRE_VERSION, KIND_SCROLL, 0];
262    for v in [x, y, dx, dy] {
263        out.extend_from_slice(&(v as f32).to_le_bytes());
264    }
265    out
266}
267
268pub fn encode_key(key: &str) -> Result<Vec<u8>, String> {
269    let bytes = key.as_bytes();
270    if bytes.is_empty() || bytes.len() > MAX_KEY {
271        return Err("key length must be 1..=64".into());
272    }
273    let mut out = vec![WIRE_VERSION, KIND_KEY, KEY_PRESS, bytes.len() as u8];
274    out.extend_from_slice(bytes);
275    Ok(out)
276}
277
278pub fn encode_navigate(url: &str) -> Result<Vec<u8>, String> {
279    encode_text_like(KIND_NAV, url)
280}
281
282pub fn encode_text(text: &str) -> Result<Vec<u8>, String> {
283    encode_text_like(KIND_TEXT, text)
284}
285
286fn encode_text_like(kind: u8, value: &str) -> Result<Vec<u8>, String> {
287    let bytes = value.as_bytes();
288    if bytes.is_empty() || bytes.len() > MAX_TEXT {
289        return Err("value length must be 1..=2048".into());
290    }
291    let mut out = vec![WIRE_VERSION, kind];
292    out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
293    out.extend_from_slice(bytes);
294    Ok(out)
295}
296
297pub fn decode_input(bytes: &[u8]) -> Result<BrowserInput, String> {
298    if bytes.len() < 2 || bytes[0] != WIRE_VERSION {
299        return Err("bad browser wire version".into());
300    }
301    match bytes[1] {
302        KIND_MOUSE => {
303            if bytes.len() < 20 {
304                return Err("short mouse event".into());
305            }
306            let button = match bytes[3] {
307                0 => BrowserMouseButton::Left,
308                1 => BrowserMouseButton::Middle,
309                2 => BrowserMouseButton::Right,
310                _ => return Err("bad mouse button".into()),
311            };
312            let x = f64::from_le_bytes(bytes[4..12].try_into().unwrap());
313            let y = f64::from_le_bytes(bytes[12..20].try_into().unwrap());
314            match bytes[2] {
315                MOUSE_DOWN => Ok(BrowserInput::MouseDown { x, y, button }),
316                MOUSE_MOVE => Ok(BrowserInput::MouseMove { x, y }),
317                MOUSE_UP => Ok(BrowserInput::MouseUp { x, y, button }),
318                _ => Ok(BrowserInput::Click { x, y, button }),
319            }
320        }
321        KIND_KEY => {
322            if bytes.len() < 4 {
323                return Err("short key event".into());
324            }
325            let n = bytes[3] as usize;
326            if bytes.len() < 4 + n || n == 0 || n > MAX_KEY {
327                return Err("bad key length".into());
328            }
329            let key = String::from_utf8(bytes[4..4 + n].to_vec()).map_err(|e| e.to_string())?;
330            Ok(BrowserInput::Key { key })
331        }
332        KIND_SCROLL => {
333            if bytes.len() < 19 {
334                return Err("short scroll event".into());
335            }
336            let mut vals = [0f64; 4];
337            for (i, slot) in vals.iter_mut().enumerate() {
338                let start = 3 + i * 4;
339                *slot = f32::from_le_bytes(bytes[start..start + 4].try_into().unwrap()) as f64;
340            }
341            Ok(BrowserInput::Scroll {
342                x: vals[0],
343                y: vals[1],
344                dx: vals[2],
345                dy: vals[3],
346            })
347        }
348        KIND_NAV | KIND_TEXT => {
349            if bytes.len() < 4 {
350                return Err("short text-like event".into());
351            }
352            let n = u16::from_le_bytes(bytes[2..4].try_into().unwrap()) as usize;
353            if n == 0 || n > MAX_TEXT || bytes.len() < 4 + n {
354                return Err("bad text-like length".into());
355            }
356            let value = String::from_utf8(bytes[4..4 + n].to_vec()).map_err(|e| e.to_string())?;
357            if bytes[1] == KIND_NAV {
358                Ok(BrowserInput::Navigate { url: value })
359            } else {
360                Ok(BrowserInput::Text { text: value })
361            }
362        }
363        other => Err(format!("unknown browser input kind {other}")),
364    }
365}
366
367pub fn validate_session_id(session: &str) -> Result<(), String> {
368    if session.is_empty()
369        || session.len() > 64
370        || session.starts_with('.')
371        || !session
372            .bytes()
373            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
374    {
375        return Err("bad browser session id".into());
376    }
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn round_trip_inputs() {
386        let click = encode_click(120.0, 90.0, BrowserMouseButton::Left);
387        assert!(matches!(
388            decode_input(&click).unwrap(),
389            BrowserInput::Click {
390                x,
391                y,
392                button: BrowserMouseButton::Left
393            } if (x - 120.0).abs() < 0.01 && (y - 90.0).abs() < 0.01
394        ));
395        let scroll = encode_scroll(10.0, 20.0, 0.0, 300.0);
396        assert!(matches!(decode_input(&scroll).unwrap(), BrowserInput::Scroll { dy, .. } if (dy - 300.0).abs() < 0.1));
397        let key = encode_key("Enter").unwrap();
398        assert!(matches!(decode_input(&key).unwrap(), BrowserInput::Key { key } if key == "Enter"));
399        let nav = encode_navigate("https://example.org/").unwrap();
400        assert!(matches!(decode_input(&nav).unwrap(), BrowserInput::Navigate { url } if url == "https://example.org/"));
401        assert!(matches!(decode_input(&encode_mouse_down(1.0, 2.0, BrowserMouseButton::Left)).unwrap(), BrowserInput::MouseDown { .. }));
402        assert!(matches!(decode_input(&encode_mouse_move(3.0, 4.0)).unwrap(), BrowserInput::MouseMove { x, y } if x == 3.0 && y == 4.0));
403        assert!(matches!(decode_input(&encode_mouse_up(1.0, 2.0, BrowserMouseButton::Right)).unwrap(), BrowserInput::MouseUp { button: BrowserMouseButton::Right, .. }));
404        let text = encode_text("hi").unwrap();
405        assert!(matches!(decode_input(&text).unwrap(), BrowserInput::Text { text } if text == "hi"));
406    }
407
408    #[test]
409    fn frame_event_json_round_trip() {
410        let ev = BrowserStreamEvent::Frame {
411            node_id: "n1".into(),
412            session: "s1".into(),
413            bytes: vec![0x89, b'P', b'N', b'G'],
414            width: 800,
415            height: 600,
416            url: "about:blank".into(),
417            title: "t".into(),
418            trace: None,
419            region: None,
420        };
421        let v = serde_json::to_value(&ev).unwrap();
422        let back: BrowserStreamEvent = serde_json::from_value(v).unwrap();
423        assert_eq!(back, ev);
424    }
425}