diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index cf150623e..69c7b5ba0 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -239,6 +239,7 @@ impl Headless { let mods = packet_mods(e)?.unwrap_or(base); let kind = s(e, "kind").unwrap_or(""); let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + PointerEvent::json_check(e)?; if s(e, "space") == Some("screen") { return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 86cc6228a..9a64c688b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1474,6 +1474,11 @@ fn headless_methods_refuse_params_of_the_wrong_type() { // A whole rectangle comes before the malformed event, and none of it is drawn. ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"), ] { let e = h.call(method, params.clone()).unwrap_err(); assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index ece5231bd..9b53d2404 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -109,6 +109,19 @@ impl PointerEvent { pub fn json_backquote(v: &Value, default: bool) -> Result { v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned())) } + /// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`, + /// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or + /// `screen`. A key that is absent or null keeps its default. + pub fn json_check(e: &Value) -> Result<(), String> { + let given = |k: &str| e.get(k).filter(|v| !v.is_null()); + if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) { + return Err(format!("`{k}` must be a number")); + } + if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) { + return Err("`space` must be doc or screen".to_owned()); + } + Ok(()) + } } #[cfg(test)] @@ -135,6 +148,27 @@ mod tests { assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}"); } } + + #[test] + fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() { + use serde_json::json; + for ok in [ + json!({}), + json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}), + json!({"pressure": null, "space": null}), + json!({"space": "doc"}), + ] { + assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}"); + } + for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] { + for bad in [json!("1"), json!(true), json!([1])] { + assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}"); + } + } + for bad in [json!("screeen"), json!(true), json!(1)] { + assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}"); + } + } } /// Keys tools care about. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 34eaa7944..65489be0d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -984,6 +984,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "doubleclick" | "dblclick" => PointerKind::DoubleClick, other => return Err(format!("unknown pointer kind `{other}`")), }; + PointerEvent::json_check(e)?; let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) }) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 021365d81..1763fd679 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -457,8 +457,8 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } -/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any -/// of the packet's events runs. +/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, or a +/// screen-space event with no canvas, before any of the packet's events runs. #[test] fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -471,6 +471,12 @@ fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { for (last, error) in [ (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + (json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"), + (json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"), ] { let events = moves.iter().cloned().chain([last]).collect::>(); let r = input_request(&mut app, "ui.pointer", json!({"events": events})); diff --git a/docs/mcp.md b/docs/mcp.md index 078488e9d..bce8a5efc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1272,8 +1272,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (` control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, `ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s -`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one -malformed event sends none of its events: +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer event's `pressure`, +`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet +with one malformed event sends none of its events: ```json {"id": "view.setZoom", "params": {"zoom": "200"}}