1use forge_primitives::browser::{BrowserInputEvent, BrowserNavState, InputModifiers, MouseAction};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16pub fn history_state(reply: &Value) -> Option<BrowserNavState> {
18 let (index, entries) = history_position(reply)?;
19 Some(BrowserNavState { can_back: index > 0, can_forward: index + 1 < entries.len() })
20}
21
22pub fn history_position(reply: &Value) -> Option<(usize, Vec<Value>)> {
24 let index = usize::try_from(reply.get("currentIndex")?.as_i64()?).ok()?;
25 let entries = reply.get("entries")?.as_array()?.clone();
26 (index < entries.len()).then_some((index, entries))
27}
28
29pub fn history_event(method: &str, event: &Value, main: Option<&str>) -> bool {
35 match method {
36 "Page.frameNavigated" => event.pointer("/params/frame/parentId").is_none(),
37 "Page.loadEventFired" => true,
38 "Page.navigatedWithinDocument" => event
42 .pointer("/params/frameId")
43 .and_then(Value::as_str)
44 .is_some_and(|frame| Some(frame) == main),
45 _ => false,
46 }
47}
48
49pub fn main_frame_id(reply: &Value) -> Option<&str> {
51 reply.pointer("/frameTree/frame/id").and_then(Value::as_str)
52}
53
54pub fn navigated_frame_id(event: &Value) -> Option<&str> {
56 if event.pointer("/params/frame/parentId").is_some() {
57 return None;
58 }
59 event.pointer("/params/frame/id").and_then(Value::as_str)
60}
61
62pub fn cdp_modifiers(modifiers: InputModifiers) -> i32 {
64 i32::from(modifiers.alt)
65 | (i32::from(modifiers.control) << 1)
66 | (i32::from(modifiers.meta) << 2)
67 | (i32::from(modifiers.shift) << 3)
68}
69
70pub fn cef_modifiers(modifiers: InputModifiers) -> u32 {
73 let mut bits = 0_u32;
74 if modifiers.shift {
75 bits |= 1 << 1;
76 }
77 if modifiers.control {
78 bits |= 1 << 2;
79 }
80 if modifiers.alt {
81 bits |= 1 << 3;
82 }
83 if modifiers.meta {
84 bits |= 1 << 7;
85 }
86 bits
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96pub enum HostInput {
97 Mouse {
98 tab: u16,
100 action: MouseAction,
101 x: i32,
102 y: i32,
103 button: i32,
105 clicks: i32,
106 modifiers: InputModifiers,
107 },
108 Wheel {
109 tab: u16,
111 x: i32,
112 y: i32,
113 delta_x: i32,
114 delta_y: i32,
115 modifiers: InputModifiers,
116 },
117 TabNew {
122 url: Option<String>,
123 #[serde(default)]
124 request: Option<u64>,
125 },
126 TabClose { tab: u16 },
128 TabResize { tab: u16, width: u32, height: u32, scale_milli: u32 },
131 File { id: u64, paths: Vec<String> },
134 Dialog { tab: u16, accept: bool, prompt: Option<String> },
138 RecordStart { tab: u16, path: String },
141 RecordStop,
143}
144
145#[derive(Clone, Debug, PartialEq)]
147pub enum Route {
148 Host(String),
150 Page(&'static str, Value),
152}
153
154pub fn route(event: &BrowserInputEvent, tab: u16) -> Route {
161 match event {
162 BrowserInputEvent::Mouse { action, x, y, button, clicks, modifiers } => {
163 Route::Host(host_line(&HostInput::Mouse {
164 tab,
165 action: *action,
166 x: rounded(*x),
167 y: rounded(*y),
168 button: *button,
169 clicks: *clicks,
170 modifiers: *modifiers,
171 }))
172 }
173 BrowserInputEvent::Wheel { x, y, delta_x, delta_y, modifiers } => {
174 Route::Host(host_line(&HostInput::Wheel {
180 tab,
181 x: rounded(*x),
182 y: rounded(*y),
183 delta_x: rounded(*delta_x).saturating_neg(),
184 delta_y: rounded(*delta_y).saturating_neg(),
185 modifiers: *modifiers,
186 }))
187 }
188 BrowserInputEvent::Key { down, key, code, windows_vk, modifiers } => {
189 let mut params = json!({
190 "type": if *down { "keyDown" } else { "keyUp" },
191 "key": key,
192 "code": code,
193 "windowsVirtualKeyCode": windows_vk,
194 "modifiers": cdp_modifiers(*modifiers),
195 });
196 if *down && modifiers.meta {
200 let command = match code.as_str() {
201 "KeyA" => Some("selectAll"),
202 "KeyC" => Some("copy"),
203 "KeyX" => Some("cut"),
204 "KeyZ" if modifiers.shift => Some("redo"),
205 "KeyZ" => Some("undo"),
206 _ => None,
207 };
208 if let Some(command) = command {
209 params["commands"] = json!([command]);
210 }
211 }
212 Route::Page("Input.dispatchKeyEvent", params)
213 }
214 BrowserInputEvent::Text { text } => {
215 Route::Page("Input.insertText", json!({ "text": text }))
216 }
217 }
218}
219
220pub fn host_line(input: &HostInput) -> String {
222 serde_json::to_string(input).unwrap_or_default()
223}
224
225pub fn parse_host_line(line: &str) -> Option<HostInput> {
227 serde_json::from_str(line).ok()
228}
229
230fn rounded(value: f64) -> i32 {
232 if !value.is_finite() {
233 return 0;
234 }
235 let value = value.round();
236 if value <= f64::from(i32::MIN) {
237 return i32::MIN;
238 }
239 if value >= f64::from(i32::MAX) {
240 return i32::MAX;
241 }
242 #[expect(clippy::cast_possible_truncation, reason = "rounded and checked in range")]
243 let at = value as i32;
244 at
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
257 fn a_click_goes_to_the_host_with_its_button_and_modifiers() {
258 let route = route(
259 &BrowserInputEvent::Mouse {
260 action: MouseAction::Press,
261 x: 12.5,
262 y: 30.4,
263 button: 1,
264 clicks: 2,
265 modifiers: InputModifiers { meta: true, shift: true, ..Default::default() },
266 },
267 1,
268 );
269 let Route::Host(line) = route else { panic!("a click is the host's: {route:?}") };
270 let HostInput::Mouse { tab, action, x, y, button, clicks, modifiers } =
271 parse_host_line(&line).expect("the host reads its own line")
272 else {
273 panic!("the line names a mouse event: {line}");
274 };
275 assert_eq!(tab, 1, "the click names the viewer's tab");
276 assert_eq!((x, y), (13, 30), "the host is given whole pixels");
277 assert_eq!((action, button, clicks), (MouseAction::Press, 1, 2));
278 assert_eq!(cef_modifiers(modifiers), 130, "Command 128 + shift 2");
279 }
280
281 #[test]
286 fn a_wheel_carries_both_deltas_to_the_host_with_cefs_sign() {
287 let route = route(
288 &BrowserInputEvent::Wheel {
289 x: 1.2,
290 y: 2.0,
291 delta_x: -3.0,
292 delta_y: 40.0,
293 modifiers: InputModifiers::default(),
294 },
295 1,
296 );
297 let Route::Host(line) = route else { panic!("a wheel is the host's: {route:?}") };
298 let HostInput::Wheel { x, y, delta_x, delta_y, .. } =
299 parse_host_line(&line).expect("the host reads its own line")
300 else {
301 panic!("the line names a wheel: {line}");
302 };
303 assert_eq!((x, y), (1, 2));
304 assert_eq!(
305 (delta_x, delta_y),
306 (3, -40),
307 "the wire's positive-down is negated for CEF, whose positive is up"
308 );
309 }
310
311 #[test]
315 fn a_recording_cue_round_trips_its_line() {
316 let start = HostInput::RecordStart { tab: 1, path: "/tmp/take.mp4".to_owned() };
317 assert_eq!(parse_host_line(&host_line(&start)), Some(start));
318 assert_eq!(
319 parse_host_line(&host_line(&HostInput::RecordStop)),
320 Some(HostInput::RecordStop)
321 );
322 }
323
324 #[test]
328 fn a_gesture_names_the_tab_it_belongs_to() {
329 let line = host_line(&HostInput::Mouse {
330 tab: 2,
331 action: MouseAction::Press,
332 x: 10,
333 y: 20,
334 button: 0,
335 clicks: 1,
336 modifiers: InputModifiers::default(),
337 });
338 let HostInput::Mouse { tab, .. } = parse_host_line(&line).expect("a gesture reads") else {
339 panic!("a mouse line reads as a mouse");
340 };
341 assert_eq!(tab, 2);
342 assert_eq!(
343 parse_host_line(r#"{"kind":"tab_new","url":null}"#),
344 Some(HostInput::TabNew { url: None, request: None })
345 );
346 assert_eq!(
347 parse_host_line(r#"{"kind":"tab_close","tab":4}"#),
348 Some(HostInput::TabClose { tab: 4 })
349 );
350 }
351
352 #[test]
354 fn an_unreadable_host_line_is_none() {
355 assert!(parse_host_line("").is_none());
356 assert!(parse_host_line("{\"kind\":\"mouse\"}").is_none());
357 assert!(parse_host_line("{\"kind\":\"tap\"}").is_none());
358 }
359
360 #[test]
363 fn the_modifier_mask_is_cefs_own_bit_order() {
364 assert_eq!(cef_modifiers(InputModifiers::default()), 0);
365 assert_eq!(cef_modifiers(InputModifiers { shift: true, ..Default::default() }), 2);
366 assert_eq!(cef_modifiers(InputModifiers { control: true, ..Default::default() }), 4);
367 assert_eq!(cef_modifiers(InputModifiers { alt: true, ..Default::default() }), 8);
368 assert_eq!(cef_modifiers(InputModifiers { meta: true, ..Default::default() }), 128);
369 assert_eq!(
370 cef_modifiers(InputModifiers { alt: true, control: true, meta: true, shift: true }),
371 142,
372 );
373 }
374
375 #[test]
379 fn a_link_navigation_asks_for_the_history_and_a_subframes_does_not() {
380 let main = serde_json::json!({ "params": { "frame": { "id": "A" } } });
381 let sub = serde_json::json!({ "params": { "frame": { "id": "B", "parentId": "A" } } });
382 assert!(
383 history_event("Page.frameNavigated", &main, Some("A")),
384 "a link's navigation moves the bar"
385 );
386 assert!(!history_event("Page.frameNavigated", &sub, Some("A")), "a subframe's does not");
387 assert!(history_event("Page.loadEventFired", &serde_json::json!({}), Some("A")));
388 assert!(
389 !history_event("Page.fileChooserOpened", &serde_json::json!({}), Some("A")),
390 "a chooser is not a move"
391 );
392 }
393
394 #[test]
398 fn a_same_document_move_asks_for_the_history() {
399 let within =
400 serde_json::json!({ "params": { "frameId": "A", "url": "https://example.com/" } });
401 assert!(history_event("Page.navigatedWithinDocument", &within, Some("A")));
402 assert!(
403 !history_event("Page.navigatedWithinDocument", &within, Some("B")),
404 "an iframe's move is not the page's",
405 );
406 assert!(
407 !history_event("Page.navigatedWithinDocument", &within, None),
408 "with no main frame known, no move is the page's yet",
409 );
410 }
411
412 #[test]
416 fn the_main_frame_is_named_by_the_tree_and_its_own_navigation() {
417 let tree = serde_json::json!({ "frameTree": { "frame": {
418 "id": "MAIN",
419 "children": [{ "frame": { "id": "AD", "parentId": "MAIN" } }],
420 } } });
421 assert_eq!(main_frame_id(&tree), Some("MAIN"));
422 assert_eq!(
423 navigated_frame_id(&serde_json::json!({ "params": { "frame": { "id": "MAIN" } } })),
424 Some("MAIN"),
425 );
426 assert_eq!(
427 navigated_frame_id(
428 &serde_json::json!({ "params": { "frame": { "id": "AD", "parentId": "MAIN" } } })
429 ),
430 None,
431 "a subframe's navigation does not name the main frame",
432 );
433 }
434
435 #[test]
439 fn an_editing_chord_carries_its_command() {
440 let key = |down, code: &str, shift| BrowserInputEvent::Key {
441 down,
442 key: code.to_owned(),
443 code: code.to_owned(),
444 windows_vk: 0,
445 modifiers: InputModifiers { meta: true, shift, ..Default::default() },
446 };
447 let page = |event: &BrowserInputEvent| match route(event, 1) {
448 Route::Page(_, params) => params,
449 Route::Host(line) => panic!("a key is the page's, not the host's: {line}"),
450 };
451 assert_eq!(page(&key(true, "KeyA", false))["commands"], json!(["selectAll"]));
452 assert_eq!(page(&key(true, "KeyZ", true))["commands"], json!(["redo"]));
453 assert_eq!(page(&key(true, "KeyZ", false))["commands"], json!(["undo"]));
454 assert!(
455 page(&key(true, "KeyB", false)).get("commands").is_none(),
456 "a printable key is not a command"
457 );
458 assert!(page(&key(false, "KeyA", false)).get("commands").is_none(), "an up has no command");
459 let arrow = page(&key(true, "ArrowLeft", false));
460 assert_eq!(arrow["type"], "keyDown");
461 assert!(arrow.get("commands").is_none(), "a special key is not a chord");
462 }
463
464 #[test]
467 fn text_is_inserted_rather_than_replayed() {
468 let route = route(&BrowserInputEvent::Text { text: "hello".to_owned() }, 1);
469 let Route::Page(method, params) = route else {
470 panic!("text is the page's: {route:?}");
471 };
472 assert_eq!(method, "Input.insertText");
473 assert_eq!(params["text"], "hello");
474 }
475
476 #[test]
477 fn the_modifier_mask_is_cdps_own_bit_order() {
478 assert_eq!(cdp_modifiers(InputModifiers::default()), 0);
479 assert_eq!(cdp_modifiers(InputModifiers { alt: true, ..Default::default() }), 1);
480 assert_eq!(cdp_modifiers(InputModifiers { control: true, ..Default::default() }), 2);
481 assert_eq!(cdp_modifiers(InputModifiers { meta: true, ..Default::default() }), 4);
482 assert_eq!(cdp_modifiers(InputModifiers { shift: true, ..Default::default() }), 8);
483 assert_eq!(
484 cdp_modifiers(InputModifiers { alt: true, control: true, meta: true, shift: true }),
485 15,
486 );
487 }
488}