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 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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
94 trace: Option<BrowserFrameTrace>,
95 #[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 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#[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 pub src_w: u32,
131 pub src_h: u32,
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct BrowserFrameTrace {
137 pub input_n: u64,
139 pub queue_ms: u32,
141 pub cdp_ms: u32,
143 pub to_frame_ms: u32,
145 pub node_ms: u32,
147 #[serde(default)]
149 pub source: String,
150}
151
152#[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 #[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}