1use 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#[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 Input {
40 session: String,
41 #[serde(with = "serde_bytes_b64")]
42 bytes: Vec<u8>,
43 },
44 Stop {
45 session: String,
46 },
47}
48
49#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
72 trace: Option<BrowserFrameTrace>,
73 #[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#[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 pub src_w: u32,
99 pub src_h: u32,
100}
101
102#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct BrowserFrameTrace {
105 pub input_n: u64,
107 pub queue_ms: u32,
109 pub cdp_ms: u32,
111 pub to_frame_ms: u32,
113 pub node_ms: u32,
115 #[serde(default)]
117 pub source: String,
118}
119
120#[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}