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_CLICK: u8 = 4;
15const KEY_PRESS: u8 = 3;
16const MAX_KEY: usize = 64;
17const MAX_TEXT: usize = 2048;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum BrowserMouseButton {
22    Left,
23    Middle,
24    Right,
25}
26
27/// HQ → node browser control (over the restream control channel).
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "op", rename_all = "snake_case")]
30pub enum BrowserClientOp {
31    Start {
32        session: String,
33        #[serde(default)]
34        url: String,
35        #[serde(default)]
36        profile_dir: Option<String>,
37    },
38    /// Dig2-compatible input bytes (click / scroll / key / text / nav).
39    Input {
40        session: String,
41        #[serde(with = "serde_bytes_b64")]
42        bytes: Vec<u8>,
43    },
44    Stop {
45        session: String,
46    },
47}
48
49/// Node → HQ browser events (frames and lifecycle).
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "op", rename_all = "snake_case")]
52pub enum BrowserStreamEvent {
53    Started {
54        node_id: String,
55        session: String,
56        url: String,
57    },
58    Frame {
59        node_id: String,
60        session: String,
61        /// PNG or JPEG bytes.
62        #[serde(with = "serde_bytes_b64")]
63        bytes: Vec<u8>,
64        width: u32,
65        height: u32,
66        #[serde(default)]
67        url: String,
68        #[serde(default)]
69        title: String,
70        /// Latency trace for the first frame after an input (node-side hops).
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        trace: Option<BrowserFrameTrace>,
73        /// Delta frame: `bytes` covers only this region of the full image.
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        region: Option<BrowserFrameRegion>,
76    },
77    Stopped {
78        node_id: String,
79        session: String,
80        #[serde(default)]
81        reason: String,
82    },
83    Error {
84        node_id: String,
85        session: String,
86        message: String,
87    },
88}
89
90/// Changed rectangle of a delta frame, in source-image pixels.
91#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
92pub struct BrowserFrameRegion {
93    pub x: u32,
94    pub y: u32,
95    pub w: u32,
96    pub h: u32,
97    /// Full source image size the region is relative to.
98    pub src_w: u32,
99    pub src_h: u32,
100}
101
102/// Node-side timings (milliseconds) for the frame that first reflects an input.
103#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct BrowserFrameTrace {
105    /// Count of inputs this browser session has received (monotonic).
106    pub input_n: u64,
107    /// Bridge receipt → CDP dispatch start.
108    pub queue_ms: u32,
109    /// CDP Input.dispatch* round trip(s).
110    pub cdp_ms: u32,
111    /// CDP dispatch done → first changed frame available on the node.
112    pub to_frame_ms: u32,
113    /// Total node time (receipt → frame handed to the uplink).
114    pub node_ms: u32,
115    /// "screencast" or "poll".
116    #[serde(default)]
117    pub source: String,
118}
119
120/// Reply to a BrowserClientOp (correlated by op_id on the control channel).
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct BrowserOpReply {
123    pub ok: bool,
124    pub session: String,
125    #[serde(default)]
126    pub message: String,
127}
128
129mod serde_bytes_b64 {
130    use base64::Engine;
131    use serde::{Deserialize, Deserializer, Serializer};
132
133    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
134        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
135    }
136
137    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
138        let text = String::deserialize(d)?;
139        base64::engine::general_purpose::STANDARD
140            .decode(text.as_bytes())
141            .map_err(serde::de::Error::custom)
142    }
143}
144
145#[derive(Clone, Debug, PartialEq)]
146pub enum BrowserInput {
147    Click {
148        x: f64,
149        y: f64,
150        button: BrowserMouseButton,
151    },
152    Scroll {
153        x: f64,
154        y: f64,
155        dx: f64,
156        dy: f64,
157    },
158    Key {
159        key: String,
160    },
161    Text {
162        text: String,
163    },
164    Navigate {
165        url: String,
166    },
167}
168
169pub fn encode_click(x: f64, y: f64, button: BrowserMouseButton) -> Vec<u8> {
170    let mut out = vec![
171        WIRE_VERSION,
172        KIND_MOUSE,
173        MOUSE_CLICK,
174        match button {
175            BrowserMouseButton::Left => 0,
176            BrowserMouseButton::Middle => 1,
177            BrowserMouseButton::Right => 2,
178        },
179    ];
180    out.extend_from_slice(&x.to_le_bytes());
181    out.extend_from_slice(&y.to_le_bytes());
182    out
183}
184
185pub fn encode_scroll(x: f64, y: f64, dx: f64, dy: f64) -> Vec<u8> {
186    let mut out = vec![WIRE_VERSION, KIND_SCROLL, 0];
187    for v in [x, y, dx, dy] {
188        out.extend_from_slice(&(v as f32).to_le_bytes());
189    }
190    out
191}
192
193pub fn encode_key(key: &str) -> Result<Vec<u8>, String> {
194    let bytes = key.as_bytes();
195    if bytes.is_empty() || bytes.len() > MAX_KEY {
196        return Err("key length must be 1..=64".into());
197    }
198    let mut out = vec![WIRE_VERSION, KIND_KEY, KEY_PRESS, bytes.len() as u8];
199    out.extend_from_slice(bytes);
200    Ok(out)
201}
202
203pub fn encode_navigate(url: &str) -> Result<Vec<u8>, String> {
204    encode_text_like(KIND_NAV, url)
205}
206
207pub fn encode_text(text: &str) -> Result<Vec<u8>, String> {
208    encode_text_like(KIND_TEXT, text)
209}
210
211fn encode_text_like(kind: u8, value: &str) -> Result<Vec<u8>, String> {
212    let bytes = value.as_bytes();
213    if bytes.is_empty() || bytes.len() > MAX_TEXT {
214        return Err("value length must be 1..=2048".into());
215    }
216    let mut out = vec![WIRE_VERSION, kind];
217    out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
218    out.extend_from_slice(bytes);
219    Ok(out)
220}
221
222pub fn decode_input(bytes: &[u8]) -> Result<BrowserInput, String> {
223    if bytes.len() < 2 || bytes[0] != WIRE_VERSION {
224        return Err("bad browser wire version".into());
225    }
226    match bytes[1] {
227        KIND_MOUSE => {
228            if bytes.len() < 20 {
229                return Err("short mouse event".into());
230            }
231            let button = match bytes[3] {
232                0 => BrowserMouseButton::Left,
233                1 => BrowserMouseButton::Middle,
234                2 => BrowserMouseButton::Right,
235                _ => return Err("bad mouse button".into()),
236            };
237            let x = f64::from_le_bytes(bytes[4..12].try_into().unwrap());
238            let y = f64::from_le_bytes(bytes[12..20].try_into().unwrap());
239            Ok(BrowserInput::Click { x, y, button })
240        }
241        KIND_KEY => {
242            if bytes.len() < 4 {
243                return Err("short key event".into());
244            }
245            let n = bytes[3] as usize;
246            if bytes.len() < 4 + n || n == 0 || n > MAX_KEY {
247                return Err("bad key length".into());
248            }
249            let key = String::from_utf8(bytes[4..4 + n].to_vec()).map_err(|e| e.to_string())?;
250            Ok(BrowserInput::Key { key })
251        }
252        KIND_SCROLL => {
253            if bytes.len() < 19 {
254                return Err("short scroll event".into());
255            }
256            let mut vals = [0f64; 4];
257            for (i, slot) in vals.iter_mut().enumerate() {
258                let start = 3 + i * 4;
259                *slot = f32::from_le_bytes(bytes[start..start + 4].try_into().unwrap()) as f64;
260            }
261            Ok(BrowserInput::Scroll {
262                x: vals[0],
263                y: vals[1],
264                dx: vals[2],
265                dy: vals[3],
266            })
267        }
268        KIND_NAV | KIND_TEXT => {
269            if bytes.len() < 4 {
270                return Err("short text-like event".into());
271            }
272            let n = u16::from_le_bytes(bytes[2..4].try_into().unwrap()) as usize;
273            if n == 0 || n > MAX_TEXT || bytes.len() < 4 + n {
274                return Err("bad text-like length".into());
275            }
276            let value = String::from_utf8(bytes[4..4 + n].to_vec()).map_err(|e| e.to_string())?;
277            if bytes[1] == KIND_NAV {
278                Ok(BrowserInput::Navigate { url: value })
279            } else {
280                Ok(BrowserInput::Text { text: value })
281            }
282        }
283        other => Err(format!("unknown browser input kind {other}")),
284    }
285}
286
287pub fn validate_session_id(session: &str) -> Result<(), String> {
288    if session.is_empty()
289        || session.len() > 64
290        || session.starts_with('.')
291        || !session
292            .bytes()
293            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
294    {
295        return Err("bad browser session id".into());
296    }
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn round_trip_inputs() {
306        let click = encode_click(120.0, 90.0, BrowserMouseButton::Left);
307        assert!(matches!(
308            decode_input(&click).unwrap(),
309            BrowserInput::Click {
310                x,
311                y,
312                button: BrowserMouseButton::Left
313            } if (x - 120.0).abs() < 0.01 && (y - 90.0).abs() < 0.01
314        ));
315        let scroll = encode_scroll(10.0, 20.0, 0.0, 300.0);
316        assert!(matches!(decode_input(&scroll).unwrap(), BrowserInput::Scroll { dy, .. } if (dy - 300.0).abs() < 0.1));
317        let key = encode_key("Enter").unwrap();
318        assert!(matches!(decode_input(&key).unwrap(), BrowserInput::Key { key } if key == "Enter"));
319        let nav = encode_navigate("https://example.org/").unwrap();
320        assert!(matches!(decode_input(&nav).unwrap(), BrowserInput::Navigate { url } if url == "https://example.org/"));
321        let text = encode_text("hi").unwrap();
322        assert!(matches!(decode_input(&text).unwrap(), BrowserInput::Text { text } if text == "hi"));
323    }
324
325    #[test]
326    fn frame_event_json_round_trip() {
327        let ev = BrowserStreamEvent::Frame {
328            node_id: "n1".into(),
329            session: "s1".into(),
330            bytes: vec![0x89, b'P', b'N', b'G'],
331            width: 800,
332            height: 600,
333            url: "about:blank".into(),
334            title: "t".into(),
335        };
336        let v = serde_json::to_value(&ev).unwrap();
337        let back: BrowserStreamEvent = serde_json::from_value(v).unwrap();
338        assert_eq!(back, ev);
339    }
340}