Skip to main content

forge_browser/
input.rs

1//! Input as the page receives it: a controlling viewer's gesture, routed to
2//! the part of the browser that can deliver it as a person's own.
3//!
4//! **A pointer gesture is the host's, a keystroke is the page's.** CEF
5//! routes its own mouse calls into a windowless popup - a select's menu is a
6//! separate view - and only `CefBrowserHost::SendMouse*Event` reaches that
7//! routing; the same click sent to the page target over CDP dismisses the
8//! menu instead of choosing from it. Keys and text stay on CDP, where a
9//! page's trusted input is already right. The mapping is pure, so the shapes
10//! are tested without a browser.
11
12use forge_primitives::browser::{BrowserInputEvent, BrowserNavState, InputModifiers, MouseAction};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16/// Where the page's history sits, from a `Page.getNavigationHistory` reply.
17pub 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
22/// The current index and the entries, when the reply holds both.
23pub 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
29/// Whether a page event asks the bar's history again. **Only the main
30/// frame's navigation moves it**: a subframe's `frameNavigated` fires too,
31/// and a read per ad iframe is nobody's time. A same-document entry moves no
32/// frame and fires no load, so it names itself - and names the frame it
33/// happened in, which is why `main` is checked for it too.
34pub 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        // **An iframe's pushState is not the page's history.** The event
39        // carries the frame it happened in; without this check a fragment
40        // change inside an ad re-read the bar and overwrote the carry.
41        "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
49/// The main frame's id, from a `Page.getFrameTree` reply.
50pub fn main_frame_id(reply: &Value) -> Option<&str> {
51    reply.pointer("/frameTree/frame/id").and_then(Value::as_str)
52}
53
54/// The main frame's id, from a parentless `Page.frameNavigated`.
55pub 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
62/// The modifier mask CDP wants: Alt 1, Control 2, Meta 4, Shift 8.
63pub 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
70/// The modifier bits CEF's mouse events carry, from `cef_types.h`: shift 2,
71/// control 4, alt 8, and Command 128 on macOS.
72pub 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/// One pointer gesture as the host delivers it, in whole page pixels.
90///
91/// **CEF's mouse calls take view coordinates as integers**, so the fraction
92/// is rounded here rather than in the host, and the line is one shape the
93/// service and the host are both tested against.
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96pub enum HostInput {
97    Mouse {
98        /// The tab whose page takes the gesture.
99        tab: u16,
100        action: MouseAction,
101        x: i32,
102        y: i32,
103        /// 0 left, 1 right, 2 middle - AppKit's own numbering.
104        button: i32,
105        clicks: i32,
106        modifiers: InputModifiers,
107    },
108    Wheel {
109        /// The tab whose page takes the gesture.
110        tab: u16,
111        x: i32,
112        y: i32,
113        delta_x: i32,
114        delta_y: i32,
115        modifiers: InputModifiers,
116    },
117    /// Open a new tab at `url`, or blank. **A trusted server command**: unlike
118    /// a page's popup it accepts any address the server names. `request` is
119    /// echoed on the tab's birth so a caller can tell its tab from a popup
120    /// that was queued beside it.
121    TabNew {
122        url: Option<String>,
123        #[serde(default)]
124        request: Option<u64>,
125    },
126    /// Close one tab.
127    TabClose { tab: u16 },
128    /// Lay one tab out apart from the profile's request: a take records at
129    /// its own size without resizing every other tab.
130    TabResize { tab: u16, width: u32, height: u32, scale_milli: u32 },
131    /// The files a person chose for a page's file dialog: server-local paths
132    /// already written for CEF, or none when the dialog was dismissed.
133    File { id: u64, paths: Vec<String> },
134    /// Answer a page's JavaScript dialog: `accept` is the button, and `prompt`
135    /// the text a prompt asks for. **The host owns the CEF callback**, so an
136    /// answer can only arrive this way (#2732).
137    Dialog { tab: u16, accept: bool, prompt: Option<String> },
138    /// Begin a recording of `tab`'s viewport into `path`, a server-local file
139    /// the service has already laid the page out for.
140    RecordStart { tab: u16, path: String },
141    /// Finish the running recording; the host answers with its outcome.
142    RecordStop,
143}
144
145/// What carries one input event.
146#[derive(Clone, Debug, PartialEq)]
147pub enum Route {
148    /// The host process, as one line on its own input pipe.
149    Host(String),
150    /// The page target over DevTools.
151    Page(&'static str, Value),
152}
153
154/// The route one event takes, into the tab whose page the viewer is on.
155///
156/// **A pointer gesture belongs to the host, a keystroke to the page.** CEF
157/// routes its own mouse calls into a windowless popup - a select's menu is
158/// its own view - and a click sent to the page target instead reaches only
159/// the page's widget, so the menu treats it as an outside click and closes.
160pub 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            // **The wire carries the page's wheel sign; CEF's mouse call
175            // takes the native one.** Every producer puts the page's sign on
176            // the wire - a browser tab's own WheelEvent, the desktop's
177            // negated AppKit delta, a finger's inverted drag - so it is
178            // turned back here or every wheel runs the wrong way.
179            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            // **Cmd+A/X/C/Z are editing commands, not printable keys.** A
197            // keyDown without `commands` moves no selection and touches no
198            // clipboard; Playwright sends them the same way.
199            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
220/// One input as the line the host reads.
221pub fn host_line(input: &HostInput) -> String {
222    serde_json::to_string(input).unwrap_or_default()
223}
224
225/// The input one host line names, or `None` for a line it cannot read.
226pub fn parse_host_line(line: &str) -> Option<HostInput> {
227    serde_json::from_str(line).ok()
228}
229
230/// A page coordinate rounded to the whole pixel CEF's calls take.
231fn 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    /// **A click is the host's, never the page's.** The host's own mouse call
252    /// is the only one CEF routes into a windowless popup; a click sent to
253    /// the page target closes a select's menu instead of choosing from it.
254    /// The line carries AppKit's button, the click count, the modifiers and
255    /// whole pixels.
256    #[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    /// **A wheel is the host's too, with its sign turned back.** The wire
282    /// carries the page's convention (positive scrolls down) and CEF's mouse
283    /// call takes the native one, so a wheel that skipped the flip scrolled
284    /// the wrong way on every platform.
285    #[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    /// **A recording's two cues are the host's, and they round-trip.** The
312    /// start carries the file the service prepared; a stop carries nothing,
313    /// because the recording it names is the one running.
314    #[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    /// **A gesture names the tab it belongs to, and the tab commands
325    /// round-trip their lines.** One host serves several pages, so a click
326    /// with no tab is a click into whichever page was created first.
327    #[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    /// **A line the host cannot read is refused, not guessed at.**
353    #[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    /// **CEF's mask is not CDP's.** Command is bit 7 here where CDP has Meta
361    /// at 4; a mask crossed over turns Shift+A into a selection shortcut.
362    #[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    /// **Only the main frame's navigation asks the arrows again.** A link
376    /// click commits the main frame; a subframe's navigation is the same
377    /// event and must not pay a history read.
378    #[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    /// **A same-document move is news even though no frame navigates.** A
395    /// fragment change or a pushState adds a history entry and fires only
396    /// this event, so without it the Back arrow stays grey through it.
397    #[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    /// **The main frame's id is read from the tree and from its own
413    /// navigation**, so a same-document event that names another frame can
414    /// be told apart from the page's own move.
415    #[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    /// **An editing chord is a command, not a printable key.** Without the
436    /// command a keyDown moves no selection and touches no clipboard; the
437    /// keyUp carries none. Keys stay on the page target.
438    #[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    /// **Text is inserted as text**, not replayed as keycodes: a layout the
465    /// server does not know still types what the person typed.
466    #[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}