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_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#[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 Input {
43 session: String,
44 #[serde(with = "serde_bytes_b64")]
45 bytes: Vec<u8>,
46 },
47 Stop {
48 session: String,
49 },
50 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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
97 trace: Option<BrowserFrameTrace>,
98 #[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 Clipboard {
115 node_id: String,
116 session: String,
117 text: String,
118 },
119 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#[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 pub src_w: u32,
140 pub src_h: u32,
141}
142
143#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct BrowserFrameTrace {
146 pub input_n: u64,
148 pub queue_ms: u32,
150 pub cdp_ms: u32,
152 pub to_frame_ms: u32,
154 pub node_ms: u32,
156 #[serde(default)]
158 pub source: String,
159}
160
161#[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 #[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 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}