Skip to main content

netget/server/usb/mouse/
actions.rs

1//! USB HID Mouse protocol actions implementation
2
3#[cfg(all(unix, feature = "libusb"))]
4use crate::llm::actions::{
5    protocol_trait::{ActionResult, Protocol, Server},
6    ActionDefinition, Parameter,
7};
8#[cfg(all(unix, feature = "libusb"))]
9use crate::protocol::log_template::LogTemplate;
10#[cfg(all(unix, feature = "libusb"))]
11use crate::protocol::EventType;
12#[cfg(all(unix, feature = "libusb"))]
13use crate::server::connection::ConnectionId;
14#[cfg(all(unix, feature = "libusb"))]
15use crate::server::usb::descriptors::{mouse_buttons, MouseReport};
16#[cfg(all(unix, feature = "libusb"))]
17use crate::state::app_state::AppState;
18#[cfg(all(unix, feature = "libusb"))]
19use anyhow::{Context, Result};
20#[cfg(all(unix, feature = "libusb"))]
21use serde_json::json;
22#[cfg(all(unix, feature = "libusb"))]
23use std::collections::HashMap;
24#[cfg(all(unix, feature = "libusb"))]
25use std::sync::{Arc, LazyLock};
26#[cfg(all(unix, feature = "libusb"))]
27use tokio::sync::Mutex;
28
29// Event type definitions (static for efficient reuse)
30#[cfg(all(unix, feature = "libusb"))]
31pub static USB_MOUSE_ATTACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
32    EventType::new(
33        "usb_mouse_attached",
34        "A USB/IP host attached to this virtual mouse and will now accept input reports. Move, \
35         click, scroll or drag to drive the host's pointer, or wait_for_more to stay idle.",
36        json!({"type": "move_relative", "x": 50, "y": -20}),
37    )
38    .with_parameters(vec![Parameter {
39        name: "connection_id".to_string(),
40        type_hint: "string".to_string(),
41        description: "Connection ID of the USB/IP session. Actions may quote it back, but it is \
42                      optional: with one host attached it is inferred."
43            .to_string(),
44        required: true,
45    }])
46    .with_actions(vec![
47        move_relative_action(),
48        move_absolute_action(),
49        click_action(),
50        scroll_action(),
51        drag_action(),
52        wait_for_more_action(),
53    ])
54    .with_alternative_example(json!({"type": "click", "button": "left"}))
55});
56
57#[cfg(all(unix, feature = "libusb"))]
58pub static USB_MOUSE_DETACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59    EventType::new(
60        "usb_mouse_detached",
61        "The host detached from this virtual mouse. Purely informational - the USB/IP session \
62         is gone, so an input report has nowhere to go.",
63        json!({"type": "show_message", "message": "USB mouse host detached"}),
64    )
65    .with_parameters(vec![Parameter {
66        name: "connection_id".to_string(),
67        type_hint: "string".to_string(),
68        description: "Connection ID of the USB/IP session. Actions may quote it back, but it is \
69                      optional: with one host attached it is inferred."
70            .to_string(),
71        required: true,
72    }])
73    .with_no_actions()
74});
75
76/// USB HID Mouse protocol action handler
77#[cfg(all(unix, feature = "libusb"))]
78pub struct UsbMouseProtocol {
79    /// Map of active connections (for async actions)
80    #[allow(dead_code)]
81    connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
82    /// Map of USB/IP mouse handlers for each connection.
83    ///
84    /// A `std::sync::Mutex`, not a tokio one: `execute_action` is synchronous and runs on a
85    /// runtime worker, so reaching an async lock from it would need
86    /// `Handle::current().block_on`, which panics there. No guard is held across an `.await`.
87    handlers: Arc<std::sync::Mutex<HashMap<ConnectionId, SharedHandler>>>,
88}
89
90/// The handler `usbip` holds for one attached host, as this module shares it.
91#[cfg(all(unix, feature = "libusb"))]
92type SharedHandler = Arc<std::sync::Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
93
94#[cfg(all(unix, feature = "libusb"))]
95pub struct ConnectionData {
96    // Placeholder for mouse-specific connection data
97}
98
99#[cfg(all(unix, feature = "libusb"))]
100impl Default for UsbMouseProtocol {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106#[cfg(all(unix, feature = "libusb"))]
107impl UsbMouseProtocol {
108    pub fn new() -> Self {
109        Self {
110            connections: Arc::new(Mutex::new(HashMap::new())),
111            handlers: Arc::new(std::sync::Mutex::new(HashMap::new())),
112        }
113    }
114
115    /// Store the USB/IP mouse handler for a connection.
116    pub async fn set_handler(&self, connection_id: ConnectionId, handler: SharedHandler) {
117        self.lock_handlers().insert(connection_id, handler);
118    }
119
120    /// Forget a connection's handler when its USB/IP session ends.
121    pub async fn remove_handler(&self, connection_id: ConnectionId) {
122        self.lock_handlers().remove(&connection_id);
123    }
124
125    fn lock_handlers(&self) -> std::sync::MutexGuard<'_, HashMap<ConnectionId, SharedHandler>> {
126        self.handlers
127            .lock()
128            .unwrap_or_else(|poisoned| poisoned.into_inner())
129    }
130
131    /// Find the handler an action refers to.
132    ///
133    /// `connection_id` is **optional**: with exactly one host attached it is inferred, and with
134    /// several, omitting it is an error that names the candidates. It used to be required *and*
135    /// string-typed, so a model that emitted the id as a number - which they routinely do -
136    /// got "USB mouse actions require 'connection_id'". Both forms are accepted.
137    fn resolve_handler(&self, action: &serde_json::Value) -> Result<SharedHandler> {
138        let handlers = self.lock_handlers();
139
140        // All three forms a model can produce: the number, the number as a string, and the
141        // `conn-N` form the events themselves carry. That last one matters — every event
142        // reports `connection_id.to_string()`, which is `"conn-2"`, not `2`.
143        let requested = action["connection_id"].as_u64().or_else(|| {
144            action["connection_id"].as_str().and_then(|s| {
145                let s = s.trim();
146                s.strip_prefix("conn-").unwrap_or(s).parse::<u64>().ok()
147            })
148        });
149
150        if let Some(id) = requested {
151            // `id as u32` silently aliased: `connection_id: 4294967298` resolved to
152            // connection 2 and moved a pointer in somebody else's session.
153            let connection_id = u32::try_from(id).map(ConnectionId::new).map_err(|_| {
154                anyhow::anyhow!("connection_id {} is not a valid connection number", id)
155            })?;
156            return handlers.get(&connection_id).cloned().ok_or_else(|| {
157                anyhow::anyhow!("No USB mouse attached on connection {}", connection_id)
158            });
159        }
160
161        match handlers.len() {
162            0 => Err(anyhow::anyhow!(
163                "No USB/IP host is attached to this mouse, so an input report has nowhere to go"
164            )),
165            1 => Ok(handlers.values().next().cloned().expect("len checked")),
166            _ => {
167                let mut ids: Vec<u32> = handlers.keys().map(|c| c.as_u32()).collect();
168                ids.sort_unstable();
169                Err(anyhow::anyhow!(
170                    "{} USB/IP hosts are attached ({:?}); the action must name one with \
171                     'connection_id'",
172                    ids.len(),
173                    ids
174                ))
175            }
176        }
177    }
178
179    /// Queue reports on the handler an action refers to.
180    ///
181    /// The host polls the interrupt IN endpoint every 10ms and takes one report per poll, so a
182    /// sequence of reports paces itself on the wire. Nothing here sleeps.
183    fn queue(&self, action: &serde_json::Value, reports: Vec<MouseReport>) -> Result<usize> {
184        if reports.is_empty() {
185            return Err(anyhow::anyhow!("action produced no mouse reports"));
186        }
187        let handler = self.resolve_handler(action)?;
188        let mut guard = handler
189            .lock()
190            .map_err(|poisoned| poisoned.into_inner())
191            .unwrap_or_else(|guard| guard);
192        let mouse = guard
193            .as_any()
194            .downcast_mut::<super::handler::UsbHidMouseHandler>()
195            .context("Handler is not a USB HID mouse handler")?;
196        let count = reports.len();
197        for report in reports {
198            mouse.queue(report);
199        }
200        Ok(count)
201    }
202}
203
204/// The largest movement, in HID report units, any one action may ask for on either axis.
205///
206/// A boot-protocol report carries one signed byte per axis, so `split_movement` turns a large
207/// movement into `magnitude / 127` reports -- and nothing bounded the magnitude. A model that
208/// answered `{"type": "move_relative", "x": 9223372036854775807}` asked for roughly 7.3e16
209/// four-byte reports: the executor never returns, the queue grows until the process is killed,
210/// and because `execute_action` is synchronous it takes a tokio worker with it. 65535 is far
211/// past the diagonal of any real display and still costs at most 517 reports.
212#[cfg(all(unix, feature = "libusb"))]
213const MAX_MOVEMENT: i64 = 65_535;
214
215/// Largest screen dimension `move_absolute` will accept.
216///
217/// It is used as `-screen_width - 127`, which overflows `i64` outright for a large value --
218/// a panic in debug and test builds, a wrap in release, since this crate declares no
219/// `[profile.dev]` and so gets `overflow-checks` in exactly the builds where it is least
220/// useful to find it.
221#[cfg(all(unix, feature = "libusb"))]
222const MAX_SCREEN_DIMENSION: i64 = 65_535;
223
224/// Read a signed coordinate and range-check it before anything does arithmetic on it.
225#[cfg(all(unix, feature = "libusb"))]
226fn movement_field(action: &serde_json::Value, field: &str, action_type: &str) -> Result<i64> {
227    let value = action[field]
228        .as_i64()
229        .with_context(|| format!("{action_type} requires '{field}' field"))?;
230    if !(-MAX_MOVEMENT..=MAX_MOVEMENT).contains(&value) {
231        return Err(anyhow::anyhow!(
232            "{} '{}' is {}, outside the +/-{} this device can move in one action",
233            action_type,
234            field,
235            value,
236            MAX_MOVEMENT
237        ));
238    }
239    Ok(value)
240}
241
242/// Read a screen dimension and range-check it before it is negated and offset.
243#[cfg(all(unix, feature = "libusb"))]
244fn screen_dimension(action: &serde_json::Value, field: &str, default: i64) -> Result<i64> {
245    let value = match action.get(field) {
246        Some(v) if !v.is_null() => v
247            .as_i64()
248            .with_context(|| format!("move_absolute '{field}' must be an integer"))?,
249        _ => return Ok(default),
250    };
251    if !(1..=MAX_SCREEN_DIMENSION).contains(&value) {
252        return Err(anyhow::anyhow!(
253            "move_absolute '{}' is {}, outside 1..={}",
254            field,
255            value,
256            MAX_SCREEN_DIMENSION
257        ));
258    }
259    Ok(value)
260}
261
262/// Split a movement into steps a single HID report can carry.
263///
264/// A boot-protocol report holds one signed byte per axis, so anything past +/-127 has to become
265/// several reports. The model is not asked to know that: it says "move 400 right" and gets four
266/// reports.
267#[cfg(all(unix, feature = "libusb"))]
268fn split_movement(mut x: i64, mut y: i64) -> Vec<MouseReport> {
269    let mut reports = Vec::new();
270    while x != 0 || y != 0 {
271        let dx = x.clamp(-127, 127);
272        let dy = y.clamp(-127, 127);
273        let mut report = MouseReport::new();
274        report.x = dx as i8;
275        report.y = dy as i8;
276        reports.push(report);
277        x -= dx;
278        y -= dy;
279    }
280    reports
281}
282
283/// Map a button name to its HID report bit.
284#[cfg(all(unix, feature = "libusb"))]
285fn button_bit(name: &str) -> Result<u8> {
286    match name.to_ascii_lowercase().as_str() {
287        "left" => Ok(mouse_buttons::LEFT),
288        "right" => Ok(mouse_buttons::RIGHT),
289        "middle" => Ok(mouse_buttons::MIDDLE),
290        other => Err(anyhow::anyhow!(
291            "unknown mouse button '{}'; expected left, right or middle",
292            other
293        )),
294    }
295}
296
297// Implement Protocol trait
298#[cfg(all(unix, feature = "libusb"))]
299impl Protocol for UsbMouseProtocol {
300    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
301        vec![
302            crate::llm::actions::ParameterDefinition {
303                name: "first_byte_timeout_secs".to_string(),
304                type_hint: "number".to_string(),
305                description: "Seconds a peer that has only connected may send no USB/IP message \
306                              at all before the server closes it. Default 30. USB/IP is \
307                              client-speaks-first and nothing here answers a peer that has not \
308                              asked - `usbip attach` and `usbip list -r` both write their \
309                              request as soon as the socket is up - so a short bound is the \
310                              honest one. Raise it only if your host is reached over a link \
311                              where a handshake can take that long."
312                    .to_string(),
313                required: false,
314                example: serde_json::json!(30),
315                default: Some(serde_json::json!(
316                    crate::server::usb::guard::DEFAULT_FIRST_MESSAGE_TIMEOUT.as_secs()
317                )),
318            },
319            crate::llm::actions::ParameterDefinition {
320                name: "idle_timeout_secs".to_string(),
321                type_hint: "number".to_string(),
322                description: "Seconds an attached host may go without sending a further USB/IP \
323                              message before the server closes the session. Default 1800. \
324                              USB/IP has no keepalive and nothing obliges an attached host to \
325                              speak - a mouse a host has imported and is asking nothing of \
326                              is idle by design - so this is deliberately long and exists to \
327                              reap a peer that is gone rather than to police one that is here. \
328                              Lower it if you are exporting to strangers."
329                    .to_string(),
330                required: false,
331                example: serde_json::json!(1800),
332                default: Some(serde_json::json!(
333                    crate::server::usb::guard::DEFAULT_IDLE_TIMEOUT.as_secs()
334                )),
335            },
336        ]
337    }
338
339    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
340        vec![]
341    }
342
343    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
344        vec![
345            move_relative_action(),
346            move_absolute_action(),
347            click_action(),
348            scroll_action(),
349            drag_action(),
350            wait_for_more_action(),
351        ]
352    }
353
354    fn protocol_name(&self) -> &'static str {
355        "USB-Mouse"
356    }
357
358    fn get_event_types(&self) -> Vec<EventType> {
359        vec![
360            USB_MOUSE_ATTACHED_EVENT.clone(),
361            USB_MOUSE_DETACHED_EVENT.clone(),
362        ]
363    }
364
365    fn stack_name(&self) -> &'static str {
366        "USB>HID>Mouse"
367    }
368
369    fn keywords(&self) -> Vec<&'static str> {
370        vec!["usb", "mouse", "hid", "pointer", "cursor"]
371    }
372
373    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
374        crate::protocol::metadata::ProtocolMetadataV2::builder()
375            .state(crate::protocol::metadata::DevelopmentState::Experimental)
376            // Deliberately silent: every HID input report asserts movement or a click. A mouse
377            // with nothing to report is NAKed on the interrupt endpoint, which is the normal state,
378            // and STALL would make the host reset the device. So a failure moves nothing.
379            .deliberately_silent()
380            .implementation("Virtual USB HID mouse device using USB/IP protocol")
381            .llm_control("LLM controls mouse movement, clicks, and scrolling")
382            .e2e_testing("E2E tests using Linux usbip client")
383            .privilege_requirement(crate::protocol::metadata::PrivilegeRequirement::None)
384            .notes("Virtual USB HID mouse over USB/IP. A hand-written UsbInterfaceHandler (handler.rs) supplies the report descriptor and 4-byte reports, because usbip 0.9 still ships no UsbHidMouseHandler. It was written but never wired: handle_connection took the accepted socket as _stream, dropped it, ran no USB/IP session, and parked on sleep(u64::MAX), while every action logged 'not yet implemented' and returned NoAction. All of that is live now, and usb_mouse_detached has an emit site for the first time. move_relative splits movement larger than a report can carry; click and drag emit their own release, so neither sticks; scroll sends one detent per report. move_absolute has no way to know where the pointer is -- a boot-protocol mouse is relative-only -- so it slams into the top-left corner first and moves out from there, which is visible on screen. Exercised against an in-test USB/IP client that decodes the HID reports; never against a real Linux usbip host, so nothing here has been seen by a kernel HID driver.")
385            // The USB/IP screen in `src/server/usb/guard.rs` refuses a `USBIP_CMD_SUBMIT`
386            // declaring more than this, before the crate allocates from the number.
387            .max_inbound_bytes(crate::server::usb::guard::MAX_TRANSFER_BUFFER_BYTES)
388            .build()
389    }
390
391    fn description(&self) -> &'static str {
392        "Virtual USB HID mouse device"
393    }
394
395    fn example_prompt(&self) -> &'static str {
396        "Create a USB mouse and move it in a circle when attached"
397    }
398
399    fn group_name(&self) -> &'static str {
400        "USB Devices"
401    }
402
403    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
404        use crate::llm::actions::StartupExamples;
405        use serde_json::json;
406
407        // Deterministic: nudge the pointer once the emulated mouse is attached,
408        // no LLM call.
409        let script = r#"import json, sys
410data = json.load(sys.stdin)
411event = data["event"]
412if data["event_type_id"] == "usb_mouse_attached":
413    actions = [{"type": "move_relative", "x": 10, "y": -5}]
414else:
415    actions = []
416print(json.dumps({"actions": actions}))"#;
417
418        StartupExamples::new(
419            // LLM mode: LLM handles USB mouse device
420            json!({
421                "type": "open_server",
422                "port": 3240,
423                "base_stack": "usb-mouse",
424                "instruction": "Create a USB mouse and move it in a circle pattern when attached"
425            }),
426            // Script mode: Code-based mouse handling
427            json!({
428                "type": "open_server",
429                "port": 3240,
430                "base_stack": "usb-mouse",
431                "event_handlers": [{
432                    "event_pattern": "usb_mouse_attached",
433                    "handler": {
434                        "type": "script",
435                        "language": "python",
436                        "code": script
437                    }
438                }]
439            }),
440            // Static mode: Fixed mouse action
441            json!({
442                "type": "open_server",
443                "port": 3240,
444                "base_stack": "usb-mouse",
445                "event_handlers": [{
446                    "event_pattern": "usb_mouse_attached",
447                    "handler": {
448                        "type": "static",
449                        "actions": [{
450                            "type": "move_relative",
451                            "x": 100,
452                            "y": 50
453                        }]
454                    }
455                }]
456            }),
457        )
458    }
459}
460
461// Implement Server trait
462#[cfg(all(unix, feature = "libusb"))]
463impl Server for UsbMouseProtocol {
464    fn spawn(
465        &self,
466        ctx: crate::protocol::SpawnContext,
467    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
468    {
469        Box::pin(async move {
470            // Read here rather than through a shared helper so the parameter names appear
471            // in this protocol's own source: `tests/startup_param_drift_test.rs` proves a
472            // declared knob is wired by finding its name outside the declaration, and a read
473            // one directory up is invisible to it — and to the next person reading this file.
474            // The numbers and the argument for them are in `src/server/usb/guard.rs`.
475            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
476                Ok(ctx
477                    .startup_params
478                    .as_ref()
479                    .map(|p| p.get_optional_u64(name))
480                    .transpose()?
481                    .flatten())
482            };
483            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
484            let idle_timeout_secs = secs("idle_timeout_secs")?;
485            crate::server::usb::mouse::UsbMouseServer::spawn_with_llm_actions(
486                ctx.legacy_listen_addr(),
487                ctx.llm_client,
488                ctx.state,
489                ctx.status_tx,
490                ctx.server_id,
491                first_byte_timeout_secs,
492                idle_timeout_secs,
493            )
494            .await
495        })
496    }
497
498    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
499        let action_type = action["type"]
500            .as_str()
501            .context("Action must have 'type' field")?;
502
503        // Every action below used to parse its parameters, log
504        // "not yet implemented - usbip crate lacks mouse support", and return NoAction. The
505        // model was handed a full pointer vocabulary that moved nothing: the server never even
506        // ran a USB/IP session (see mod.rs). They drive handler.rs now.
507        match action_type {
508            "move_relative" => {
509                let x = movement_field(&action, "x", "move_relative")?;
510                let y = movement_field(&action, "y", "move_relative")?;
511                if x == 0 && y == 0 {
512                    return Err(anyhow::anyhow!(
513                        "move_relative with x=0 and y=0 would move nothing"
514                    ));
515                }
516
517                let reports = split_movement(x, y);
518                let count = self.queue(&action, reports)?;
519                tracing::info!("USB mouse move ({}, {}) as {} report(s)", x, y, count);
520                Ok(ActionResult::NoAction)
521            }
522
523            "move_absolute" => {
524                let x = movement_field(&action, "x", "move_absolute")?;
525                let y = movement_field(&action, "y", "move_absolute")?;
526                let screen_width = screen_dimension(&action, "screen_width", 1920)?;
527                let screen_height = screen_dimension(&action, "screen_height", 1080)?;
528
529                if !(0..=screen_width).contains(&x) || !(0..=screen_height).contains(&y) {
530                    return Err(anyhow::anyhow!(
531                        "move_absolute target ({}, {}) is outside the {}x{} screen",
532                        x,
533                        y,
534                        screen_width,
535                        screen_height
536                    ));
537                }
538
539                // A boot-protocol mouse reports *relative* motion and nothing tells the device
540                // where the pointer currently is. The only way to reach an absolute position is
541                // the one every automation tool uses: slam into the top-left corner, which the
542                // host clamps, and move out from a known origin. It is not silent about the
543                // cost - this is more reports than a relative move, and it visibly moves the
544                // pointer to the corner first.
545                let mut reports = split_movement(-screen_width - 127, -screen_height - 127);
546                reports.extend(split_movement(x, y));
547                let count = self.queue(&action, reports)?;
548                tracing::info!(
549                    "USB mouse move_absolute to ({}, {}) on {}x{} as {} report(s)",
550                    x,
551                    y,
552                    screen_width,
553                    screen_height,
554                    count
555                );
556                Ok(ActionResult::NoAction)
557            }
558
559            "click" => {
560                let button = action["button"]
561                    .as_str()
562                    .context("click requires 'button' field")?;
563                let bit = button_bit(button)?;
564
565                // Press then release. Without the release report the host sees the button as
566                // still held, which turns a click into a stuck drag.
567                let mut press = MouseReport::new();
568                press.buttons = bit;
569                self.queue(&action, vec![press, MouseReport::new()])?;
570                tracing::info!("USB mouse {} click", button);
571                Ok(ActionResult::NoAction)
572            }
573
574            "scroll" => {
575                let direction = action["direction"]
576                    .as_str()
577                    .context("scroll requires 'direction' field")?;
578                let amount = action["amount"].as_i64().unwrap_or(1);
579                if amount <= 0 {
580                    return Err(anyhow::anyhow!("scroll 'amount' must be positive"));
581                }
582
583                let step: i8 = match direction.to_ascii_lowercase().as_str() {
584                    "up" => 1,
585                    "down" => -1,
586                    other => {
587                        return Err(anyhow::anyhow!(
588                            "unknown scroll direction '{}'; expected up or down",
589                            other
590                        ))
591                    }
592                };
593
594                // One detent per report: a wheel value of N is not N clicks to every host, and
595                // repeated single detents is what a real wheel produces.
596                let reports: Vec<MouseReport> = (0..amount.min(64))
597                    .map(|_| {
598                        let mut r = MouseReport::new();
599                        r.wheel = step;
600                        r
601                    })
602                    .collect();
603                let count = self.queue(&action, reports)?;
604                tracing::info!("USB mouse scroll {} x{}", direction, count);
605                Ok(ActionResult::NoAction)
606            }
607
608            "drag" => {
609                let start_x = movement_field(&action, "start_x", "drag")?;
610                let start_y = movement_field(&action, "start_y", "drag")?;
611                let end_x = movement_field(&action, "end_x", "drag")?;
612                let end_y = movement_field(&action, "end_y", "drag")?;
613                // Clamped, not just floored: `duration_ms / 10` is the step count and is
614                // itself clamped to 64 below, so an absurd value is harmless -- but reading it
615                // into a bounded range keeps that true if the step formula ever changes.
616                let duration_ms = action["duration_ms"]
617                    .as_i64()
618                    .unwrap_or(500)
619                    .clamp(0, 60_000);
620                let button = action["button"].as_str().unwrap_or("left");
621                let bit = button_bit(button)?;
622
623                // The host polls every 10ms and takes one report per poll, so the number of
624                // steps *is* the duration. Nothing sleeps: `execute_action` is synchronous and
625                // runs on a runtime worker, where a blocking sleep would stall it.
626                let steps = (duration_ms / 10).clamp(1, 64);
627                let (dx, dy) = (end_x - start_x, end_y - start_y);
628
629                let mut reports = Vec::new();
630                let mut press = MouseReport::new();
631                press.buttons = bit;
632                reports.push(press);
633
634                let mut moved_x = 0i64;
635                let mut moved_y = 0i64;
636                for step in 1..=steps {
637                    let target_x = dx * step / steps;
638                    let target_y = dy * step / steps;
639                    for mut report in split_movement(target_x - moved_x, target_y - moved_y) {
640                        // Keep the button held for every intermediate report, or the host sees
641                        // the drag end at the first movement.
642                        report.buttons = bit;
643                        reports.push(report);
644                    }
645                    moved_x = target_x;
646                    moved_y = target_y;
647                }
648                reports.push(MouseReport::new()); // release
649
650                let count = self.queue(&action, reports)?;
651                tracing::info!(
652                    "USB mouse drag ({}, {}) -> ({}, {}) with {} held, {} report(s)",
653                    start_x,
654                    start_y,
655                    end_x,
656                    end_y,
657                    button,
658                    count
659                );
660                Ok(ActionResult::NoAction)
661            }
662
663            "wait_for_more" => Ok(ActionResult::WaitForMore),
664            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
665        }
666    }
667}
668
669// Action definitions
670
671/// Optional connection selector shared by every action.
672///
673/// It was accepted by `resolve_handler` and demanded by name in the multi-host error
674/// ("the action must name one with 'connection_id'"), and this protocol's CLAUDE.md
675/// documented it — but no action declared it, so the model was never told it existed and
676/// could not address a specific host when more than one was attached.
677///
678/// Three forms are accepted: the number, the number as a string, and the `conn-N` form the
679/// events themselves carry. Declared "string" because that last one is what events emit.
680#[cfg(all(unix, feature = "libusb"))]
681fn connection_id_parameter() -> Parameter {
682    Parameter {
683        name: "connection_id".to_string(),
684        type_hint: "string".to_string(),
685        description: "Which attached host to act on, as the event reports it (e.g. \"conn-2\"). \
686            Omit it when only one host is attached; required when there are several."
687            .to_string(),
688        required: false,
689    }
690}
691
692#[cfg(all(unix, feature = "libusb"))]
693fn move_relative_action() -> ActionDefinition {
694    ActionDefinition {
695        name: "move_relative".to_string(),
696        description: "Move mouse cursor by relative offset".to_string(),
697        parameters: vec![
698            Parameter {
699                name: "x".to_string(),
700                type_hint: "number".to_string(),
701                description: "Horizontal movement in pixels (-127 to 127)".to_string(),
702                required: true,
703            },
704            Parameter {
705                name: "y".to_string(),
706                type_hint: "number".to_string(),
707                description: "Vertical movement in pixels (-127 to 127)".to_string(),
708                required: true,
709            },
710            connection_id_parameter(),
711        ],
712        example: json!({
713            "type": "move_relative",
714            "x": 10,
715            "y": -5
716        }),
717        log_template: Some(
718            LogTemplate::new()
719                .with_info("-> USB mouse move ({x}, {y})")
720                .with_debug("USB-Mouse move_relative: x={x} y={y}"),
721        ),
722    }
723}
724
725#[cfg(all(unix, feature = "libusb"))]
726fn move_absolute_action() -> ActionDefinition {
727    ActionDefinition {
728        name: "move_absolute".to_string(),
729        description: "Move mouse cursor to absolute screen position".to_string(),
730        parameters: vec![
731            Parameter {
732                name: "x".to_string(),
733                type_hint: "number".to_string(),
734                description: "Target X coordinate".to_string(),
735                required: true,
736            },
737            Parameter {
738                name: "y".to_string(),
739                type_hint: "number".to_string(),
740                description: "Target Y coordinate".to_string(),
741                required: true,
742            },
743            Parameter {
744                name: "screen_width".to_string(),
745                type_hint: "number".to_string(),
746                description: "Screen width in pixels (default: 1920)".to_string(),
747                required: false,
748            },
749            Parameter {
750                name: "screen_height".to_string(),
751                type_hint: "number".to_string(),
752                description: "Screen height in pixels (default: 1080)".to_string(),
753                required: false,
754            },
755            connection_id_parameter(),
756        ],
757        example: json!({
758            "type": "move_absolute",
759            "x": 960,
760            "y": 540,
761            "screen_width": 1920,
762            "screen_height": 1080
763        }),
764        log_template: Some(
765            LogTemplate::new()
766                .with_info("-> USB mouse move to ({x}, {y})")
767                .with_debug(
768                    "USB-Mouse move_absolute: x={x} y={y} screen={screen_width}x{screen_height}",
769                ),
770        ),
771    }
772}
773
774#[cfg(all(unix, feature = "libusb"))]
775fn click_action() -> ActionDefinition {
776    ActionDefinition {
777        name: "click".to_string(),
778        description: "Click a mouse button".to_string(),
779        parameters: vec![
780            Parameter {
781                name: "button".to_string(),
782                type_hint: "string".to_string(),
783                description: "Button to click: 'left', 'right', or 'middle'".to_string(),
784                required: true,
785            },
786            connection_id_parameter(),
787        ],
788        example: json!({
789            "type": "click",
790            "button": "left"
791        }),
792        log_template: Some(
793            LogTemplate::new()
794                .with_info("-> USB mouse click {button}")
795                .with_debug("USB-Mouse click: button={button}"),
796        ),
797    }
798}
799
800#[cfg(all(unix, feature = "libusb"))]
801fn scroll_action() -> ActionDefinition {
802    ActionDefinition {
803        name: "scroll".to_string(),
804        description: "Scroll the mouse wheel".to_string(),
805        parameters: vec![
806            Parameter {
807                name: "direction".to_string(),
808                type_hint: "string".to_string(),
809                description: "Scroll direction: 'up' or 'down'".to_string(),
810                required: true,
811            },
812            Parameter {
813                name: "amount".to_string(),
814                type_hint: "number".to_string(),
815                description: "Number of scroll steps (default: 1)".to_string(),
816                required: false,
817            },
818            connection_id_parameter(),
819        ],
820        example: json!({
821            "type": "scroll",
822            "direction": "up",
823            "amount": 3
824        }),
825        log_template: Some(
826            LogTemplate::new()
827                .with_info("-> USB mouse scroll {direction} ({amount})")
828                .with_debug("USB-Mouse scroll: direction={direction} amount={amount}"),
829        ),
830    }
831}
832
833#[cfg(all(unix, feature = "libusb"))]
834fn drag_action() -> ActionDefinition {
835    ActionDefinition {
836        name: "drag".to_string(),
837        description: "Drag from one position to another with a button held (left by default)"
838            .to_string(),
839        parameters: vec![
840            // `button` was read by the executor and validated by `button_bit`, but never
841            // declared - so right- and middle-button drags were implemented and unreachable,
842            // and the model was told the action was left-only.
843            Parameter {
844                name: "button".to_string(),
845                type_hint: "string".to_string(),
846                description: "Button to hold during the drag: 'left' (default), 'right' or \
847                    'middle'"
848                    .to_string(),
849                required: false,
850            },
851            Parameter {
852                name: "start_x".to_string(),
853                type_hint: "number".to_string(),
854                description: "Starting X coordinate".to_string(),
855                required: true,
856            },
857            Parameter {
858                name: "start_y".to_string(),
859                type_hint: "number".to_string(),
860                description: "Starting Y coordinate".to_string(),
861                required: true,
862            },
863            Parameter {
864                name: "end_x".to_string(),
865                type_hint: "number".to_string(),
866                description: "Ending X coordinate".to_string(),
867                required: true,
868            },
869            Parameter {
870                name: "end_y".to_string(),
871                type_hint: "number".to_string(),
872                description: "Ending Y coordinate".to_string(),
873                required: true,
874            },
875            Parameter {
876                name: "duration_ms".to_string(),
877                type_hint: "number".to_string(),
878                description: "Duration of drag in milliseconds (default: 500)".to_string(),
879                required: false,
880            },
881            connection_id_parameter(),
882        ],
883        example: json!({
884            "type": "drag",
885            "start_x": 100,
886            "start_y": 100,
887            "end_x": 200,
888            "end_y": 200,
889            "duration_ms": 500
890        }),
891        log_template: Some(
892            LogTemplate::new()
893                .with_info("-> USB mouse drag ({start_x},{start_y}) to ({end_x},{end_y})")
894                .with_debug("USB-Mouse drag: from=({start_x},{start_y}) to=({end_x},{end_y}) duration={duration_ms}ms"),
895        ),
896    }
897}
898
899#[cfg(all(unix, feature = "libusb"))]
900fn wait_for_more_action() -> ActionDefinition {
901    ActionDefinition {
902        name: "wait_for_more".to_string(),
903        description: "Wait for more input from the host (do nothing for now)".to_string(),
904        parameters: vec![],
905        example: json!({
906            "type": "wait_for_more"
907        }),
908        log_template: Some(
909            LogTemplate::new()
910                .with_info("-> USB mouse wait for more")
911                .with_debug("USB-Mouse wait_for_more"),
912        ),
913    }
914}