Skip to main content

netget/server/usb/keyboard/
actions.rs

1//! USB HID Keyboard 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::state::app_state::AppState;
16#[cfg(all(unix, feature = "libusb"))]
17use anyhow::{Context, Result};
18#[cfg(all(unix, feature = "libusb"))]
19use serde_json::json;
20#[cfg(all(unix, feature = "libusb"))]
21use std::collections::HashMap;
22#[cfg(all(unix, feature = "libusb"))]
23use std::sync::{Arc, LazyLock};
24#[cfg(all(unix, feature = "libusb"))]
25use tokio::sync::Mutex;
26
27// Event type definitions (static for efficient reuse)
28#[cfg(all(unix, feature = "libusb"))]
29pub static USB_KEYBOARD_ATTACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
30    EventType::new(
31        "usb_keyboard_attached",
32        "A USB/IP host attached to this virtual keyboard and will now accept HID reports. Type \
33         text, press a key or a combination, or wait_for_more to stay idle.",
34        json!({
35            "type": "type_text",
36            "text": "Hello, World!",
37            "typing_speed_ms": 50
38        }),
39    )
40    .with_parameters(vec![Parameter {
41        name: "connection_id".to_string(),
42        type_hint: "string".to_string(),
43        description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
44                      is optional: with one host attached it is inferred."
45            .to_string(),
46        required: true,
47    }])
48    .with_actions(vec![
49        type_text_action(),
50        press_key_action(),
51        press_key_combo_action(),
52        release_all_keys_action(),
53        wait_for_more_action(),
54    ])
55    .with_alternative_example(json!({"type": "wait_for_more"}))
56});
57
58#[cfg(all(unix, feature = "libusb"))]
59pub static USB_KEYBOARD_DETACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
60    EventType::new(
61        "usb_keyboard_detached",
62        "The host detached from this virtual keyboard. Purely informational - the USB/IP \
63         session is gone, so a HID report has nowhere to go.",
64        json!({"type": "show_message", "message": "USB keyboard host detached"}),
65    )
66    .with_parameters(vec![Parameter {
67        name: "connection_id".to_string(),
68        type_hint: "string".to_string(),
69        description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
70                      is optional: with one host attached it is inferred."
71            .to_string(),
72        required: true,
73    }])
74    .with_no_actions()
75});
76
77#[cfg(all(unix, feature = "libusb"))]
78pub static USB_KEYBOARD_LED_STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
79    EventType::new(
80        "usb_keyboard_led_status",
81        "The host changed the keyboard LEDs (Num Lock, Caps Lock, Scroll Lock). This is how the \
82         host reports lock state back to a keyboard - use it to tell whether Caps Lock is on \
83         before typing, and correct it with press_key if it is not what you want.",
84        json!({
85            "type": "wait_for_more"
86        }),
87    )
88    .with_parameters(vec![
89        Parameter {
90            name: "connection_id".to_string(),
91            type_hint: "string".to_string(),
92            description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
93                      is optional: with one host attached it is inferred."
94                .to_string(),
95            required: true,
96        },
97        Parameter {
98            name: "num_lock".to_string(),
99            type_hint: "boolean".to_string(),
100            description: "Num Lock LED state".to_string(),
101            required: true,
102        },
103        Parameter {
104            name: "caps_lock".to_string(),
105            type_hint: "boolean".to_string(),
106            description: "Caps Lock LED state".to_string(),
107            required: true,
108        },
109        Parameter {
110            name: "scroll_lock".to_string(),
111            type_hint: "boolean".to_string(),
112            description: "Scroll Lock LED state".to_string(),
113            required: true,
114        },
115    ])
116    .with_actions(vec![
117        type_text_action(),
118        press_key_action(),
119        press_key_combo_action(),
120        release_all_keys_action(),
121        wait_for_more_action(),
122    ])
123    .with_alternative_example(json!({"type": "press_key", "key": "capslock"}))
124});
125
126/// USB HID Keyboard protocol action handler
127#[cfg(all(unix, feature = "libusb"))]
128pub struct UsbKeyboardProtocol {
129    /// Map of active connections (for async actions)
130    #[allow(dead_code)]
131    connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
132    /// Map of USB/IP keyboard handlers for each connection
133    ///
134    /// A `std::sync::Mutex`, deliberately: `execute_action` is a *sync* trait method that is
135    /// called from inside the tokio runtime, and `tokio::sync::Mutex::blocking_lock` panics
136    /// with "Cannot block the current thread from within a runtime" when it is. The guard is
137    /// only ever held across a `HashMap` lookup, never across an `.await`.
138    handlers: Arc<std::sync::Mutex<HashMap<ConnectionId, SharedHandler>>>,
139}
140
141/// The handler `usbip` holds for one attached host, as this module shares it.
142#[cfg(all(unix, feature = "libusb"))]
143type SharedHandler = Arc<std::sync::Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
144
145#[cfg(all(unix, feature = "libusb"))]
146#[derive(Clone)]
147pub struct ConnectionData {
148    // Placeholder for keyboard-specific connection data
149    // Will be populated during USB/IP implementation
150}
151
152#[cfg(all(unix, feature = "libusb"))]
153impl Default for UsbKeyboardProtocol {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(all(unix, feature = "libusb"))]
160impl UsbKeyboardProtocol {
161    pub fn new() -> Self {
162        Self {
163            connections: Arc::new(Mutex::new(HashMap::new())),
164            handlers: Arc::new(std::sync::Mutex::new(HashMap::new())),
165        }
166    }
167
168    /// Store the USB/IP keyboard handler for a connection
169    pub fn set_handler(&self, connection_id: ConnectionId, handler: SharedHandler) {
170        if let Ok(mut handlers) = self.handlers.lock() {
171            handlers.insert(connection_id, handler);
172        }
173    }
174
175    /// Drop the USB/IP keyboard handler for a connection that has detached
176    pub fn remove_handler(&self, connection_id: ConnectionId) {
177        if let Ok(mut handlers) = self.handlers.lock() {
178            handlers.remove(&connection_id);
179        }
180    }
181
182    /// Find the handler an action refers to.
183    ///
184    /// `connection_id` is **optional**, and all three forms a model can produce are accepted:
185    /// the number, the number as a string, and the `conn-N` form the events themselves carry.
186    ///
187    /// That last one is why this exists. Actions used to demand
188    /// `action["connection_id"].as_u64()`, while every event reports
189    /// `connection_id.to_string()` — which is `"conn-2"`, not `2`. A model quoting the event's
190    /// own field back therefore *always* failed with "USB keyboard actions require
191    /// connection_id field in action", and there was no value it could have sent instead.
192    /// Every keystroke this protocol was asked for was dropped.
193    fn resolve_handler(&self, action: &serde_json::Value) -> Result<SharedHandler> {
194        let handlers = self
195            .handlers
196            .lock()
197            .unwrap_or_else(|poisoned| poisoned.into_inner());
198
199        let requested = action["connection_id"].as_u64().or_else(|| {
200            action["connection_id"].as_str().and_then(|s| {
201                let s = s.trim();
202                s.strip_prefix("conn-").unwrap_or(s).parse::<u64>().ok()
203            })
204        });
205
206        if let Some(id) = requested {
207            // `id as u32` silently aliased: `connection_id: 4294967298` resolved to connection
208            // 2 and typed into somebody else's session.
209            let connection_id = u32::try_from(id).map(ConnectionId::new).map_err(|_| {
210                anyhow::anyhow!("connection_id {} is not a valid connection number", id)
211            })?;
212            return handlers.get(&connection_id).cloned().ok_or_else(|| {
213                anyhow::anyhow!("No USB keyboard attached on connection {}", connection_id)
214            });
215        }
216
217        match handlers.len() {
218            0 => Err(anyhow::anyhow!(
219                "No USB/IP host is attached to this keyboard, so a keystroke has nowhere to go"
220            )),
221            1 => Ok(handlers.values().next().cloned().expect("len checked")),
222            _ => {
223                let mut ids: Vec<u32> = handlers.keys().map(|c| c.as_u32()).collect();
224                ids.sort_unstable();
225                Err(anyhow::anyhow!(
226                    "{} USB/IP hosts are attached ({:?}); the action must name one with \
227                     'connection_id'",
228                    ids.len(),
229                    ids
230                ))
231            }
232        }
233    }
234
235    /// Queue a HID report on the connection's keyboard handler.
236    ///
237    /// Returns an error if the connection has no live USB/IP session.
238    fn queue_reports(
239        &self,
240        handler: SharedHandler,
241        reports: Vec<usbip::hid::UsbHidKeyboardReport>,
242    ) -> Result<()> {
243        let mut handler_guard = handler
244            .lock()
245            .map_err(|_| anyhow::anyhow!("USB keyboard handler mutex poisoned"))?;
246        let hid = handler_guard
247            .as_any()
248            .downcast_mut::<crate::server::usb::keyboard::handler::NetGetKeyboardHandler>()
249            .context("Handler is not a USB HID keyboard handler")?;
250        for report in reports {
251            hid.queue(report);
252        }
253        Ok(())
254    }
255}
256
257// Implement Protocol trait
258#[cfg(all(unix, feature = "libusb"))]
259impl Protocol for UsbKeyboardProtocol {
260    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
261        vec![
262            crate::llm::actions::ParameterDefinition {
263                name: "first_byte_timeout_secs".to_string(),
264                type_hint: "number".to_string(),
265                description: "Seconds a peer that has only connected may send no USB/IP message \
266                              at all before the server closes it. Default 30. USB/IP is \
267                              client-speaks-first and nothing here answers a peer that has not \
268                              asked - `usbip attach` and `usbip list -r` both write their \
269                              request as soon as the socket is up - so a short bound is the \
270                              honest one. Raise it only if your host is reached over a link \
271                              where a handshake can take that long."
272                    .to_string(),
273                required: false,
274                example: serde_json::json!(30),
275                default: Some(serde_json::json!(
276                    crate::server::usb::guard::DEFAULT_FIRST_MESSAGE_TIMEOUT.as_secs()
277                )),
278            },
279            crate::llm::actions::ParameterDefinition {
280                name: "idle_timeout_secs".to_string(),
281                type_hint: "number".to_string(),
282                description: "Seconds an attached host may go without sending a further USB/IP \
283                              message before the server closes the session. Default 1800. \
284                              USB/IP has no keepalive and nothing obliges an attached host to \
285                              speak - a keyboard a host has imported and is asking nothing of \
286                              is idle by design - so this is deliberately long and exists to \
287                              reap a peer that is gone rather than to police one that is here. \
288                              Lower it if you are exporting to strangers."
289                    .to_string(),
290                required: false,
291                example: serde_json::json!(1800),
292                default: Some(serde_json::json!(
293                    crate::server::usb::guard::DEFAULT_IDLE_TIMEOUT.as_secs()
294                )),
295            },
296        ]
297    }
298
299    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
300        vec![]
301    }
302
303    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
304        vec![
305            type_text_action(),
306            press_key_action(),
307            press_key_combo_action(),
308            release_all_keys_action(),
309            wait_for_more_action(),
310        ]
311    }
312
313    fn protocol_name(&self) -> &'static str {
314        "USB-Keyboard"
315    }
316
317    fn get_event_types(&self) -> Vec<EventType> {
318        vec![
319            USB_KEYBOARD_ATTACHED_EVENT.clone(),
320            USB_KEYBOARD_DETACHED_EVENT.clone(),
321            USB_KEYBOARD_LED_STATUS_EVENT.clone(),
322        ]
323    }
324
325    fn stack_name(&self) -> &'static str {
326        "USB>HID>Keyboard"
327    }
328
329    fn keywords(&self) -> Vec<&'static str> {
330        vec!["usb", "keyboard", "hid", "input", "typing"]
331    }
332
333    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
334        crate::protocol::metadata::ProtocolMetadataV2::builder()
335            .state(crate::protocol::metadata::DevelopmentState::Experimental)
336            // Deliberately silent: every HID input report asserts a key press. A keyboard with
337            // nothing to report is NAKed on the interrupt endpoint, which is the normal state, and
338            // STALL would make the host reset the device. So a failure types nothing.
339            .deliberately_silent()
340            .implementation("Virtual USB HID keyboard device using USB/IP protocol")
341            .llm_control("LLM controls keyboard input (typing, key presses, combinations)")
342            .e2e_testing("Mocked E2E over the USB/IP socket; real HID typing needs a Linux usbip client")
343            .privilege_requirement(crate::protocol::metadata::PrivilegeRequirement::None)
344            .notes("USB/IP runs on the accepted socket via usbip::handler (usbip 0.9, tokio 1.x), so the listen port is whatever the caller asks for and multiple instances can coexist. All three events are emitted: attached, detached, and led_status. led_status became reachable when the crate's UsbHidKeyboardHandler was wrapped by handler.rs -- the crate discards HID output reports, so a declared event could never fire; the wrapper also stops a control request the crate does not know (GET_PROTOCOL, GET_REPORT, and SET_REPORT itself) from hitting its unimplemented!() and panicking the session task. press_key_combo folds modifiers and up to six keys into one report. type_text/press_key cover the characters key_name_to_hid maps and refuse the rest rather than typing a different string. Exercised against an in-test USB/IP client that reads HID reports off the interrupt endpoint and writes LED reports; never against a real Linux usbip host, so nothing here has been seen by a kernel HID driver.")
345            // The USB/IP screen in `src/server/usb/guard.rs` refuses a `USBIP_CMD_SUBMIT`
346            // declaring more than this, before the crate allocates from the number.
347            .max_inbound_bytes(crate::server::usb::guard::MAX_TRANSFER_BUFFER_BYTES)
348            .build()
349    }
350
351    fn description(&self) -> &'static str {
352        "Virtual USB HID keyboard device"
353    }
354
355    fn example_prompt(&self) -> &'static str {
356        "Create a USB keyboard device and type 'hello world' when attached"
357    }
358
359    fn group_name(&self) -> &'static str {
360        "USB Devices"
361    }
362
363    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
364        use crate::llm::actions::StartupExamples;
365        use serde_json::json;
366
367        // Deterministic: type a fixed string once the emulated keyboard is
368        // attached, no LLM call.
369        let script = r#"import json, sys
370data = json.load(sys.stdin)
371event = data["event"]
372if data["event_type_id"] == "usb_keyboard_attached":
373    actions = [{"type": "type_text", "text": "hello from netget"}]
374else:
375    actions = []
376print(json.dumps({"actions": actions}))"#;
377
378        StartupExamples::new(
379            // LLM mode: LLM handles USB keyboard device
380            json!({
381                "type": "open_server",
382                "port": 3240,
383                "base_stack": "usb-keyboard",
384                "instruction": "Create a USB keyboard device and type 'hello world' when attached"
385            }),
386            // Script mode: Code-based keyboard handling
387            json!({
388                "type": "open_server",
389                "port": 3240,
390                "base_stack": "usb-keyboard",
391                "event_handlers": [{
392                    "event_pattern": "usb_keyboard_attached",
393                    "handler": {
394                        "type": "script",
395                        "language": "python",
396                        "code": script
397                    }
398                }]
399            }),
400            // Static mode: Fixed keyboard action
401            json!({
402                "type": "open_server",
403                "port": 3240,
404                "base_stack": "usb-keyboard",
405                "event_handlers": [{
406                    "event_pattern": "usb_keyboard_attached",
407                    "handler": {
408                        "type": "static",
409                        "actions": [{
410                            "type": "type_text",
411                            "text": "Hello from NetGet!",
412                            "typing_speed_ms": 50
413                        }]
414                    }
415                }]
416            }),
417        )
418    }
419}
420
421// Implement Server trait
422#[cfg(all(unix, feature = "libusb"))]
423impl Server for UsbKeyboardProtocol {
424    fn spawn(
425        &self,
426        ctx: crate::protocol::SpawnContext,
427    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
428    {
429        Box::pin(async move {
430            // Read here rather than through a shared helper so the parameter names appear
431            // in this protocol's own source: `tests/startup_param_drift_test.rs` proves a
432            // declared knob is wired by finding its name outside the declaration, and a read
433            // one directory up is invisible to it — and to the next person reading this file.
434            // The numbers and the argument for them are in `src/server/usb/guard.rs`.
435            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
436                Ok(ctx
437                    .startup_params
438                    .as_ref()
439                    .map(|p| p.get_optional_u64(name))
440                    .transpose()?
441                    .flatten())
442            };
443            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
444            let idle_timeout_secs = secs("idle_timeout_secs")?;
445            crate::server::usb::keyboard::UsbKeyboardServer::spawn_with_llm_actions(
446                ctx.legacy_listen_addr(),
447                ctx.llm_client,
448                ctx.state,
449                ctx.status_tx,
450                ctx.server_id,
451                first_byte_timeout_secs,
452                idle_timeout_secs,
453            )
454            .await
455        })
456    }
457
458    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
459        let action_type = action["type"]
460            .as_str()
461            .context("Action must have 'type' field")?;
462
463        if action_type == "wait_for_more" {
464            return Ok(ActionResult::WaitForMore);
465        }
466
467        // Resolve the device once, up front, so a detached host is one clear error rather
468        // than a per-action surprise. `connection_id` is optional; see `resolve_handler`.
469        let handler = self.resolve_handler(&action)?;
470
471        match action_type {
472            "type_text" => {
473                let text = action["text"]
474                    .as_str()
475                    .context("type_text requires 'text' field")?;
476
477                let mut reports = Vec::with_capacity(text.len());
478                let mut unsupported: Vec<char> = Vec::new();
479                for ch in text.chars() {
480                    match ascii_to_hid(ch) {
481                        Some((modifier, key)) => reports.push(usbip::hid::UsbHidKeyboardReport {
482                            modifier,
483                            keys: [key, 0, 0, 0, 0, 0],
484                        }),
485                        None => unsupported.push(ch),
486                    }
487                }
488
489                if !unsupported.is_empty() {
490                    // Refuse rather than silently type a different string. The model asked
491                    // for characters this HID report layout (US, unshifted+shift only)
492                    // cannot produce, and a partial send is indistinguishable from success.
493                    return Err(anyhow::anyhow!(
494                        "type_text cannot produce these characters on a US HID keyboard: {:?}",
495                        unsupported
496                    ));
497                }
498
499                let count = reports.len();
500                // Bounded: the pacing task below sleeps `typing_speed_ms` between every
501                // report and is not registered with `register_server_task`, so an
502                // unbounded value left a task alive long past the server it belongs to.
503                // 5 seconds a keystroke is already slower than any human.
504                let typing_speed_ms = action["typing_speed_ms"].as_u64().unwrap_or(0).min(5_000);
505
506                if count == 0 {
507                    return Err(anyhow::anyhow!("type_text requires non-empty 'text'"));
508                }
509
510                if typing_speed_ms == 0 {
511                    self.queue_reports(handler, reports)?;
512                } else {
513                    // Pace the reports on a task rather than sleeping here: `execute_action`
514                    // is sync and runs on a runtime worker, so a `thread::sleep` (what this
515                    // used to do) blocks that worker for text.len() * typing_speed_ms.
516                    // Queue the first report synchronously so a missing handler is still
517                    // reported as an error to the model.
518                    let mut rest = reports;
519                    let first = rest.remove(0);
520                    self.queue_reports(handler.clone(), vec![first])?;
521
522                    tokio::runtime::Handle::current().spawn(async move {
523                        for report in rest {
524                            tokio::time::sleep(std::time::Duration::from_millis(typing_speed_ms))
525                                .await;
526                            let mut guard = handler
527                                .lock()
528                                .unwrap_or_else(|poisoned| poisoned.into_inner());
529                            if let Some(hid) = guard.as_any().downcast_mut::<
530                                crate::server::usb::keyboard::handler::NetGetKeyboardHandler,
531                            >() {
532                                hid.queue(report);
533                            }
534                        }
535                    });
536                }
537
538                tracing::info!(
539                    "Queued {} keyboard reports (speed {}ms)",
540                    count,
541                    typing_speed_ms
542                );
543                Ok(ActionResult::NoAction)
544            }
545            "press_key" => {
546                let key = action["key"]
547                    .as_str()
548                    .context("press_key requires 'key' field")?;
549
550                let (mut modifier, keycode) =
551                    key_name_to_hid(key).with_context(|| format!("Unsupported key: {}", key))?;
552
553                if let Some(arr) = action["modifiers"].as_array() {
554                    for m in arr.iter().filter_map(|v| v.as_str()) {
555                        modifier |= modifier_name_to_bit(m)
556                            .with_context(|| format!("Unsupported modifier: {}", m))?;
557                    }
558                }
559
560                self.queue_reports(
561                    handler,
562                    vec![usbip::hid::UsbHidKeyboardReport {
563                        modifier,
564                        keys: [keycode, 0, 0, 0, 0, 0],
565                    }],
566                )?;
567                tracing::info!("Queued key press '{}' (modifier={:#04x})", key, modifier);
568                Ok(ActionResult::NoAction)
569            }
570            "press_key_combo" => {
571                let names = action["keys"]
572                    .as_array()
573                    .context("press_key_combo requires 'keys' array")?
574                    .iter()
575                    .filter_map(|v| v.as_str())
576                    .collect::<Vec<_>>();
577
578                // Split the list into modifiers (folded into the report's modifier byte) and
579                // ordinary keys (up to the 6 slots a boot-protocol report carries).
580                let mut modifier = 0u8;
581                let mut keys = [0u8; 6];
582                let mut n = 0usize;
583                for name in &names {
584                    if let Some(bit) = modifier_name_to_bit(name) {
585                        modifier |= bit;
586                        continue;
587                    }
588                    let (extra_mod, keycode) = key_name_to_hid(name)
589                        .with_context(|| format!("Unsupported key in combo: {}", name))?;
590                    if n >= keys.len() {
591                        return Err(anyhow::anyhow!(
592                            "press_key_combo supports at most {} non-modifier keys, got {}",
593                            keys.len(),
594                            names.len()
595                        ));
596                    }
597                    modifier |= extra_mod;
598                    keys[n] = keycode;
599                    n += 1;
600                }
601
602                if modifier == 0 && n == 0 {
603                    return Err(anyhow::anyhow!("press_key_combo requires at least one key"));
604                }
605
606                self.queue_reports(
607                    handler,
608                    vec![usbip::hid::UsbHidKeyboardReport { modifier, keys }],
609                )?;
610                tracing::info!("Queued key combo {:?} (modifier={:#04x})", names, modifier);
611                Ok(ActionResult::NoAction)
612            }
613            "release_all_keys" => {
614                // An all-zero report is the HID "nothing is pressed" state.
615                self.queue_reports(
616                    handler,
617                    vec![usbip::hid::UsbHidKeyboardReport {
618                        modifier: 0,
619                        keys: [0; 6],
620                    }],
621                )?;
622                tracing::info!("Released all keys");
623                Ok(ActionResult::NoAction)
624            }
625            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
626        }
627    }
628}
629
630// HID keycode mapping (USB HID Usage Tables 1.12, Keyboard/Keypad page 0x07)
631//
632// This deliberately does not use `usbip::hid::UsbHidKeyboardReport::from_ascii`: that helper
633// calls `unimplemented!()` for any character outside a-z/A-Z/0-9/space/newline, so a single
634// '!' in `type_text` -- or `release_all_keys`, which used to pass 0 -- panicked the action
635// executor.
636
637/// Modifier bits in byte 0 of a boot-protocol keyboard report.
638#[cfg(all(unix, feature = "libusb"))]
639mod modifier_bit {
640    pub const LEFT_CTRL: u8 = 0x01;
641    pub const LEFT_SHIFT: u8 = 0x02;
642    pub const LEFT_ALT: u8 = 0x04;
643    pub const LEFT_GUI: u8 = 0x08;
644}
645
646/// Map a modifier name the LLM may use to its report bit.
647#[cfg(all(unix, feature = "libusb"))]
648fn modifier_name_to_bit(name: &str) -> Option<u8> {
649    match name.to_ascii_lowercase().as_str() {
650        "ctrl" | "control" => Some(modifier_bit::LEFT_CTRL),
651        "shift" => Some(modifier_bit::LEFT_SHIFT),
652        "alt" | "option" => Some(modifier_bit::LEFT_ALT),
653        "gui" | "meta" | "super" | "win" | "windows" | "cmd" | "command" => {
654            Some(modifier_bit::LEFT_GUI)
655        }
656        _ => None,
657    }
658}
659
660/// Map a printable ASCII character to `(modifier, keycode)`.
661#[cfg(all(unix, feature = "libusb"))]
662fn ascii_to_hid(c: char) -> Option<(u8, u8)> {
663    const SHIFT: u8 = modifier_bit::LEFT_SHIFT;
664    Some(match c {
665        'a'..='z' => (0, c as u8 - b'a' + 0x04),
666        'A'..='Z' => (SHIFT, c as u8 - b'A' + 0x04),
667        '1'..='9' => (0, c as u8 - b'1' + 0x1E),
668        '0' => (0, 0x27),
669        '\n' | '\r' => (0, 0x28),
670        '\t' => (0, 0x2B),
671        ' ' => (0, 0x2C),
672        '-' => (0, 0x2D),
673        '=' => (0, 0x2E),
674        '[' => (0, 0x2F),
675        ']' => (0, 0x30),
676        '\\' => (0, 0x31),
677        ';' => (0, 0x33),
678        '\'' => (0, 0x34),
679        '`' => (0, 0x35),
680        ',' => (0, 0x36),
681        '.' => (0, 0x37),
682        '/' => (0, 0x38),
683        '!' => (SHIFT, 0x1E),
684        '@' => (SHIFT, 0x1F),
685        '#' => (SHIFT, 0x20),
686        '$' => (SHIFT, 0x21),
687        '%' => (SHIFT, 0x22),
688        '^' => (SHIFT, 0x23),
689        '&' => (SHIFT, 0x24),
690        '*' => (SHIFT, 0x25),
691        '(' => (SHIFT, 0x26),
692        ')' => (SHIFT, 0x27),
693        '_' => (SHIFT, 0x2D),
694        '+' => (SHIFT, 0x2E),
695        '{' => (SHIFT, 0x2F),
696        '}' => (SHIFT, 0x30),
697        '|' => (SHIFT, 0x31),
698        ':' => (SHIFT, 0x33),
699        '"' => (SHIFT, 0x34),
700        '~' => (SHIFT, 0x35),
701        '<' => (SHIFT, 0x36),
702        '>' => (SHIFT, 0x37),
703        '?' => (SHIFT, 0x38),
704        _ => return None,
705    })
706}
707
708/// Map a key name (a single character, or a named key like `enter` / `f5` / `up`) to
709/// `(modifier, keycode)`.
710#[cfg(all(unix, feature = "libusb"))]
711fn key_name_to_hid(name: &str) -> Option<(u8, u8)> {
712    let mut chars = name.chars();
713    if let (Some(c), None) = (chars.next(), chars.next()) {
714        if let Some(mapped) = ascii_to_hid(c) {
715            return Some(mapped);
716        }
717    }
718
719    let keycode = match name.to_ascii_lowercase().as_str() {
720        "enter" | "return" => 0x28,
721        "esc" | "escape" => 0x29,
722        "backspace" => 0x2A,
723        "tab" => 0x2B,
724        "space" | "spacebar" => 0x2C,
725        "capslock" | "caps_lock" => 0x39,
726        "f1" => 0x3A,
727        "f2" => 0x3B,
728        "f3" => 0x3C,
729        "f4" => 0x3D,
730        "f5" => 0x3E,
731        "f6" => 0x3F,
732        "f7" => 0x40,
733        "f8" => 0x41,
734        "f9" => 0x42,
735        "f10" => 0x43,
736        "f11" => 0x44,
737        "f12" => 0x45,
738        "printscreen" | "print_screen" => 0x46,
739        "scrolllock" | "scroll_lock" => 0x47,
740        "pause" => 0x48,
741        "insert" => 0x49,
742        "home" => 0x4A,
743        "pageup" | "page_up" => 0x4B,
744        "delete" | "del" => 0x4C,
745        "end" => 0x4D,
746        "pagedown" | "page_down" => 0x4E,
747        "right" | "rightarrow" | "arrow_right" => 0x4F,
748        "left" | "leftarrow" | "arrow_left" => 0x50,
749        "down" | "downarrow" | "arrow_down" => 0x51,
750        "up" | "uparrow" | "arrow_up" => 0x52,
751        "numlock" | "num_lock" => 0x53,
752        _ => return None,
753    };
754    Some((0, keycode))
755}
756
757// Action definitions
758
759/// Optional connection selector shared by every action.
760///
761/// It was accepted by `resolve_handler` and demanded by name in the multi-host error
762/// ("the action must name one with 'connection_id'"), and this protocol's CLAUDE.md
763/// documented it — but no action declared it, so the model was never told it existed and
764/// could not address a specific host when more than one was attached.
765///
766/// Three forms are accepted: the number, the number as a string, and the `conn-N` form the
767/// events themselves carry. Declared "string" because that last one is what events emit.
768#[cfg(all(unix, feature = "libusb"))]
769fn connection_id_parameter() -> Parameter {
770    Parameter {
771        name: "connection_id".to_string(),
772        type_hint: "string".to_string(),
773        description: "Which attached host to act on, as the event reports it (e.g. \"conn-2\"). \
774            Omit it when only one host is attached; required when there are several."
775            .to_string(),
776        required: false,
777    }
778}
779
780#[cfg(all(unix, feature = "libusb"))]
781fn type_text_action() -> ActionDefinition {
782    ActionDefinition {
783        name: "type_text".to_string(),
784        description: "Type text on the USB keyboard as if a user typed it".to_string(),
785        parameters: vec![
786            Parameter {
787                name: "text".to_string(),
788                type_hint: "string".to_string(),
789                description: "Text to type".to_string(),
790                required: true,
791            },
792            Parameter {
793                name: "typing_speed_ms".to_string(),
794                type_hint: "number".to_string(),
795                description: "Delay between keypresses in milliseconds (default: 0, meaning all \
796                              keystrokes are queued at once)"
797                    .to_string(),
798                required: false,
799            },
800            connection_id_parameter(),
801        ],
802        example: json!({
803            "type": "type_text",
804            "text": "Hello, World!",
805            "typing_speed_ms": 50
806        }),
807        log_template: Some(
808            LogTemplate::new()
809                .with_info("-> USB keyboard type '{text}'")
810                .with_debug("USB-Keyboard type_text: text='{text}' speed={typing_speed_ms}ms"),
811        ),
812    }
813}
814
815#[cfg(all(unix, feature = "libusb"))]
816fn press_key_action() -> ActionDefinition {
817    ActionDefinition {
818        name: "press_key".to_string(),
819        description: "Press a single key with optional modifier keys (Ctrl, Shift, Alt, GUI)"
820            .to_string(),
821        parameters: vec![
822            Parameter {
823                name: "key".to_string(),
824                type_hint: "string".to_string(),
825                description: "Key to press (e.g., 'a', 'enter', 'f1')".to_string(),
826                required: true,
827            },
828            Parameter {
829                name: "modifiers".to_string(),
830                type_hint: "array".to_string(),
831                description: "Modifier keys: 'ctrl', 'shift', 'alt', 'gui' (Windows/Command key)"
832                    .to_string(),
833                required: false,
834            },
835            connection_id_parameter(),
836        ],
837        example: json!({
838            "type": "press_key",
839            "key": "c",
840            "modifiers": ["ctrl"]
841        }),
842        log_template: Some(
843            LogTemplate::new()
844                .with_info("-> USB keyboard press '{key}'")
845                .with_debug("USB-Keyboard press_key: key='{key}' modifiers={modifiers}"),
846        ),
847    }
848}
849
850#[cfg(all(unix, feature = "libusb"))]
851fn press_key_combo_action() -> ActionDefinition {
852    ActionDefinition {
853        name: "press_key_combo".to_string(),
854        description: "Press multiple keys simultaneously (e.g., Ctrl+Alt+Delete)".to_string(),
855        parameters: vec![
856            Parameter {
857                name: "keys".to_string(),
858                type_hint: "array".to_string(),
859                description: "Keys to press together: 'ctrl', 'alt', 'delete', etc.".to_string(),
860                required: true,
861            },
862            connection_id_parameter(),
863        ],
864        example: json!({
865            "type": "press_key_combo",
866            "keys": ["ctrl", "alt", "delete"]
867        }),
868        log_template: Some(
869            LogTemplate::new()
870                .with_info("-> USB keyboard combo {keys}")
871                .with_debug("USB-Keyboard press_key_combo: keys={keys}"),
872        ),
873    }
874}
875
876#[cfg(all(unix, feature = "libusb"))]
877fn release_all_keys_action() -> ActionDefinition {
878    ActionDefinition {
879        name: "release_all_keys".to_string(),
880        description: "Release all currently pressed keys (useful if stuck)".to_string(),
881        parameters: vec![connection_id_parameter()],
882        example: json!({
883            "type": "release_all_keys"
884        }),
885        log_template: Some(
886            LogTemplate::new()
887                .with_info("-> USB keyboard release all keys")
888                .with_debug("USB-Keyboard release_all_keys"),
889        ),
890    }
891}
892
893#[cfg(all(unix, feature = "libusb"))]
894fn wait_for_more_action() -> ActionDefinition {
895    ActionDefinition {
896        name: "wait_for_more".to_string(),
897        description: "Wait for more input from the host (do nothing for now)".to_string(),
898        parameters: vec![],
899        example: json!({
900            "type": "wait_for_more"
901        }),
902        log_template: Some(
903            LogTemplate::new()
904                .with_info("-> USB keyboard wait for more")
905                .with_debug("USB-Keyboard wait_for_more"),
906        ),
907    }
908}