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