diff --git a/.claude/rules/objc-ffi.md b/.claude/rules/objc-ffi.md index da7069c80..6466c1cfc 100644 --- a/.claude/rules/objc-ffi.md +++ b/.claude/rules/objc-ffi.md @@ -9,6 +9,7 @@ paths: - "crates/openlogi-agent-core/src/watchers/camera.rs" - "crates/openlogi-hook/src/macos.rs" - "crates/openlogi-inject/src/inject/macos.rs" + - "crates/openlogi-gamepad/src/macos.rs" - "crates/openlogi-hid/src/permissions.rs" --- @@ -33,6 +34,7 @@ files; **keep this table in sync when you add or move one**: | `openlogi-hid/src/permissions.rs` | `IOHIDCheckAccess` / `IOHIDRequestAccess` (the prompting half of Input Monitoring) | | `openlogi-hook/src/macos.rs` | the CGEventTap (on `core-graphics`, see below), the off-tap `NSWorkspace` frontmost-app read and Safari PID snapshot, the Accessibility-trust check/prompt, and the HID sender-id lookup | | `openlogi-inject/src/inject/macos.rs` | CGEvent synthesis, media-key `NSEvent`s, off-thread `NSWorkspace` validation, typed `AXUIElement` navigation with `CFRetained` ownership, and the `dlopen`'d private SPIs | +| `openlogi-gamepad/src/macos.rs` | `IOHIDUserDevice` virtual gamepad create/emit/output-rumble callback (raw IOKit C API) | | `openlogi-overlay/src/platform.rs` | the Actions Ring helper's window policy: accessory activation, non-activating panel, the `NSEvent` global click-away monitor (`block2`), and `CGGetActiveDisplayList` / `CGDisplayBounds` | | `openlogi-permissions/src/macos.rs` | non-prompting permission reads + System-Settings deep links; `+[CBManager authorization]` via an `AnyClass` lookup | diff --git a/Cargo.lock b/Cargo.lock index 9280182a8..3ec8f5a27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5564,6 +5564,7 @@ dependencies = [ "futures-lite", "openlogi-core", "openlogi-fixture", + "openlogi-gamepad", "openlogi-hid", "openlogi-hook", "openlogi-inject", @@ -5753,6 +5754,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "openlogi-gamepad" +version = "0.8.3" +dependencies = [ + "core-foundation 0.10.0", + "evdev", + "openlogi-core", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "openlogi-hid" version = "0.8.5" diff --git a/Cargo.toml b/Cargo.toml index 233abf21d..c486fef40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/openlogi-device-registry", "crates/openlogi-fixture", "crates/openlogi-inject", + "crates/openlogi-gamepad", "crates/openlogi-hidpp", "crates/openlogi-hidpp-derive", "crates/openlogi-hid", diff --git a/crates/openlogi-agent-core/Cargo.toml b/crates/openlogi-agent-core/Cargo.toml index 34d2887b5..f622ce2a9 100644 --- a/crates/openlogi-agent-core/Cargo.toml +++ b/crates/openlogi-agent-core/Cargo.toml @@ -13,6 +13,7 @@ publish = false [dependencies] openlogi-core = { path = "../openlogi-core" } openlogi-inject = { path = "../openlogi-inject" } +openlogi-gamepad = { path = "../openlogi-gamepad" } openlogi-hid = { path = "../openlogi-hid" } openlogi-hook = { path = "../openlogi-hook" } openlogi-ipc = { path = "../openlogi-ipc" } diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index 3f0060b9e..ba3876487 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -11,7 +11,9 @@ use std::collections::BTreeMap; use std::sync::Arc; -use openlogi_core::binding::{Action, Binding, ButtonId, GestureDirection, default_binding}; +use openlogi_core::binding::{ + Action, Binding, ButtonId, GamepadMap, GestureDirection, default_binding, +}; use openlogi_core::bindings::{button_bindings_for, hidpp_gesture_maps_for, oshook_gestures_for}; use openlogi_core::config::{Config, ThumbwheelSensitivity}; use openlogi_core::device_order::PhysicalDeviceKey; @@ -64,6 +66,9 @@ pub struct DispatchPlan { /// This device's effective thumb-wheel sensitivity (device override or the /// app-wide default). pub thumbwheel_sensitivity: ThumbwheelSensitivity, + /// When set, mapped controls feed the auxiliary virtual gamepad instead of + /// productivity actions. + pub gamepad: Option, } /// One device's independently versioned hardware target and dispatch plan. @@ -95,6 +100,17 @@ pub(crate) fn hidpp_side_gesture_maps_for( .collect() } +/// Host/runtime facts that are not config but still shape diversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CapturePlanRuntime { + /// Whether the OS mouse hook can own side-button gesture presses. + pub os_mouse_hook_available: bool, + /// Whether [`crate::GamepadPads`] holds a created pad for this device — + /// diversion stays off when create failed so mapped controls keep + /// productivity / native behaviour (fail-open). + pub gamepad_live: bool, +} + /// Build one device's plan from the config (per-app effective for `app`). #[must_use] pub fn plan_for_device( @@ -104,7 +120,7 @@ pub fn plan_for_device( route: DeviceRoute, app: Option<&str>, rearm_generation: u64, - os_mouse_hook_available: bool, + runtime: CapturePlanRuntime, ) -> DeviceCapturePlan { let bindings = button_bindings_for(config, Some(config_key), app); // Gesture-mode OS-hook controls normally stay native so the hook sees the @@ -117,8 +133,10 @@ pub fn plan_for_device( // gesture at once, each armed with its own raw-XY divert (the capture // target below derives the CIDs to divert from this map's keys). let gesture_bindings = hidpp_gesture_maps_for(config, Some(config_key), app); + let gamepad_map = (config.gamepad(config_key).enabled && runtime.gamepad_live) + .then(GamepadMap::default_for_mouse); let mut divert_gesture_buttons = Vec::new(); - if os_mouse_hook_available { + if runtime.os_mouse_hook_available { divert_gesture_buttons.extend( DIVERTABLE_STANDARD_BUTTONS .into_iter() @@ -139,7 +157,7 @@ pub fn plan_for_device( let plain_sources = GESTURE_SOURCE_BUTTONS .into_iter() .filter(|(_, button)| !gesture_bindings.contains_key(button)); - let divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS + let mut divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS .into_iter() .chain(plain_sources) // These controls are owned by the OS-hook path. The capture opt-out @@ -150,6 +168,12 @@ pub fn plan_for_device( }) .filter(|(_, button)| !oshook.contains_key(button)) .filter(|(_, button)| { + if gamepad_map + .as_ref() + .is_some_and(|map| map.owns_button(*button)) + { + return true; + } bindings.get(button).is_some_and(|binding| { if matches!(binding, Binding::LongPress(_)) { return true; @@ -173,11 +197,24 @@ pub fn plan_for_device( ] .iter() .any(|button| { - bindings - .get(button) - .is_some_and(|binding| binding.click_action() != default_binding(*button)) + gamepad_map + .as_ref() + .is_some_and(|map| map.owns_button(*button)) + || bindings + .get(button) + .is_some_and(|binding| binding.click_action() != default_binding(*button)) }); let thumbwheel_sensitivity = config.thumbwheel_sensitivity(config_key); + let mut divert_gesture_sources: Vec = GESTURE_SOURCE_BUTTONS + .into_iter() + .filter(|(_, button)| gesture_bindings.contains_key(button)) + .map(|(cid, _)| cid) + .collect(); + merge_gamepad_diverts( + gamepad_map.as_ref(), + &mut divert_buttons, + &mut divert_gesture_sources, + ); DeviceCapturePlan { target: CaptureTarget { physical_key, @@ -185,11 +222,7 @@ pub fn plan_for_device( spec: CaptureSpec { capture_thumbwheel: thumbwheel_sensitivity != ThumbwheelSensitivity::DEFAULT || thumbwheel_bindings_nondefault, - divert_gesture_sources: GESTURE_SOURCE_BUTTONS - .into_iter() - .filter(|(_, button)| gesture_bindings.contains_key(button)) - .map(|(cid, _)| cid) - .collect(), + divert_gesture_sources, divert_gesture_buttons, divert_buttons, }, @@ -201,10 +234,34 @@ pub fn plan_for_device( gesture_bindings, side_gesture_bindings, thumbwheel_sensitivity, + gamepad: gamepad_map, }, } } +/// Force-divert gamepad-owned controls that the productivity path left native. +fn merge_gamepad_diverts( + gamepad_map: Option<&GamepadMap>, + divert_buttons: &mut Vec<(u16, ButtonId)>, + divert_gesture_sources: &mut Vec, +) { + let Some(map) = gamepad_map else { + return; + }; + // Gamepad-owned OS-hook buttons (Back/Forward) must be HID++-diverted even + // when the hook is up, otherwise they stay native mouse buttons. + for (cid, button) in DIVERTABLE_STANDARD_BUTTONS { + if map.owns_button(button) && !divert_buttons.iter().any(|(_, b)| *b == button) { + divert_buttons.push((cid, button)); + } + } + for (cid, button) in GESTURE_SOURCE_BUTTONS { + if map.owns_button(button) && !divert_gesture_sources.contains(&cid) { + divert_gesture_sources.push(cid); + } + } +} + #[cfg(test)] mod tests { use openlogi_core::binding::{Binding, LongPressBinding}; @@ -235,7 +292,10 @@ mod tests { route, app, rearm_generation, - os_mouse_hook_available, + CapturePlanRuntime { + os_mouse_hook_available, + gamepad_live: false, + }, ) } @@ -783,4 +843,56 @@ mod tests { assert!(plan.dispatch.side_gesture_bindings.is_empty()); } } + + #[test] + fn gamepad_map_requires_a_live_pad() { + // Enabled-but-failed create must fail open: no map, no gamepad divert. + let mut cfg = Config::default(); + cfg.devices + .entry("2b042".into()) + .or_default() + .gamepad + .enabled = true; + + let dead = plan_for_device(&cfg, "2b042", route(), None, 0, true); + assert!( + dead.dispatch.gamepad.is_none(), + "without a live pad the map must stay off" + ); + assert!( + !dead + .target + .spec + .divert_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "Back must keep native/productivity behaviour when create failed" + ); + + let live = super::plan_for_device( + &cfg, + PhysicalDeviceKey::parse("receiver:cafe:slot:2") + .expect("fixture should be a physical key"), + "2b042", + route(), + None, + 0, + CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: true, + }, + ); + assert!( + live.dispatch.gamepad.is_some(), + "a live pad publishes the default mouse map" + ); + assert!( + live.target + .spec + .divert_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "Back is on the default map and must divert when the pad is live" + ); + } } diff --git a/crates/openlogi-agent-core/src/gamepad.rs b/crates/openlogi-agent-core/src/gamepad.rs new file mode 100644 index 000000000..8b7a296d7 --- /dev/null +++ b/crates/openlogi-agent-core/src/gamepad.rs @@ -0,0 +1,348 @@ +//! Opt-in auxiliary virtual-gamepad runtime. +//! +//! The orchestrator publishes the desired set of pads; this handle creates and +//! destroys [`openlogi_gamepad::VirtualGamepad`] instances and applies mapped +//! input from the HID++ capture path. +//! +//! Diversion only engages for keys that currently have a **live** pad — if +//! create fails (no entitlement / no uinput / ViGEm missing), controls stay on +//! the productivity path. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use openlogi_core::binding::{ + ButtonId, DpadDirection, GamepadAxis, GamepadBinding, GamepadFaceButton, GamepadMap, + GestureDirection, +}; +use openlogi_core::device_order::PhysicalDeviceKey; +use openlogi_gamepad::{GamepadState, Rumble, VirtualGamepad, create as create_pad}; +use openlogi_hid::DeviceRoute; +use tracing::{debug, info, warn}; + +/// How long a thumb-wheel axis pulse stays deflected before returning to zero. +const AXIS_PULSE: Duration = Duration::from_millis(50); + +/// One online device that should own a virtual pad. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GamepadPadDesired { + /// Config namespace / display key. + pub config_key: String, + /// Stable physical identity used as the pad map key. + pub physical_key: PhysicalDeviceKey, + /// Product string shown to the OS / Gamepad API. + pub product_name: String, + /// HID++ route for rumble → haptic writes. + pub route: DeviceRoute, + /// Whether host rumble should play device haptics. + pub rumble: bool, + /// Whether the device reports `haptic_feedback`. + pub haptic_capable: bool, +} + +/// Cloneable apply/sync handle shared by the orchestrator and capture watchers. +#[derive(Clone, Default)] +pub struct GamepadPads { + inner: Arc>, +} + +#[derive(Default)] +struct GamepadPadsInner { + pads: HashMap, + /// Optional callback the agent registers for rumble → haptic. + rumble_sink: Option>, +} + +struct LivePad { + desired: GamepadPadDesired, + map: GamepadMap, + state: GamepadState, + device: Box, + /// Axes that should return to zero after [`AXIS_PULSE`]. + axis_hold_until: HashMap, +} + +impl GamepadPads { + /// Register a sink that receives host rumble for haptic-capable pads. + pub fn set_rumble_sink(&self, sink: F) + where + F: Fn(DeviceRoute, Rumble) + Send + Sync + 'static, + { + if let Ok(mut guard) = self.inner.lock() { + guard.rumble_sink = Some(Arc::new(sink)); + } + } + + /// Whether a virtual pad is currently live for `config_key`. + #[must_use] + pub fn is_active(&self, config_key: &str) -> bool { + self.inner + .lock() + .is_ok_and(|guard| guard.pads.contains_key(config_key)) + } + + /// Diff `desired` against live pads: create, update, destroy. + pub fn sync(&self, desired: &[GamepadPadDesired]) { + let Ok(mut guard) = self.inner.lock() else { + warn!("gamepad pads lock poisoned — sync skipped"); + return; + }; + let wanted: HashMap = desired + .iter() + .cloned() + .map(|pad| (pad.config_key.clone(), pad)) + .collect(); + + let stale: Vec = guard + .pads + .keys() + .filter(|key| !wanted.contains_key(*key)) + .cloned() + .collect(); + for key in stale { + destroy_pad(&mut guard, &key); + } + + for (key, want) in wanted { + let recreate = guard + .pads + .get(&key) + .is_some_and(|existing| existing.desired.product_name != want.product_name); + if recreate { + destroy_pad(&mut guard, &key); + } else if let Some(existing) = guard.pads.get_mut(&key) { + existing.desired = want; + continue; + } + match create_pad(&want.product_name) { + Ok(device) => { + info!(config_key = %key, name = %want.product_name, "created virtual gamepad"); + guard.pads.insert( + key, + LivePad { + desired: want, + map: GamepadMap::default_for_mouse(), + state: GamepadState::default(), + device, + axis_hold_until: HashMap::new(), + }, + ); + } + Err(error) => { + warn!( + config_key = %key, + %error, + "virtual gamepad create failed — feature inactive for this device" + ); + } + } + } + } + + /// Whether `config_key`'s live pad owns `button` (actions must not run). + #[must_use] + pub fn owns_button(&self, config_key: &str, button: ButtonId) -> bool { + self.inner + .lock() + .ok() + .and_then(|guard| { + guard + .pads + .get(config_key) + .map(|pad| pad.map.owns_button(button)) + }) + .unwrap_or(false) + } + + /// Apply a face-button edge. + pub fn set_button(&self, config_key: &str, button: GamepadFaceButton, pressed: bool) { + self.with_pad(config_key, |pad| { + pad.state.set_button(button, pressed); + emit(pad); + }); + } + + /// Apply a D-pad arm. + pub fn set_dpad(&self, config_key: &str, direction: DpadDirection, pressed: bool) { + self.with_pad(config_key, |pad| { + pad.state.set_dpad(direction, pressed); + emit(pad); + }); + } + + /// Apply a continuous axis sample. + pub fn set_axis(&self, config_key: &str, axis: GamepadAxis, value: f32) { + self.with_pad(config_key, |pad| { + pad.state.set_axis(axis, value); + emit(pad); + }); + } + + /// Map a physical button edge through the default map. + pub fn apply_button_edge(&self, config_key: &str, button: ButtonId, pressed: bool) { + let Some(binding) = self.binding_for(config_key, button) else { + return; + }; + match binding { + GamepadBinding::Button(face) => self.set_button(config_key, face, pressed), + GamepadBinding::Axis(axis) if !pressed => self.set_axis(config_key, axis, 0.0), + GamepadBinding::Axis(_) | GamepadBinding::Dpad => {} + } + } + + /// Map a gesture swipe/click through the default map. + pub fn apply_gesture(&self, config_key: &str, button: ButtonId, direction: GestureDirection) { + let Ok(guard) = self.inner.lock() else { + return; + }; + let Some(pad) = guard.pads.get(config_key) else { + return; + }; + if direction == GestureDirection::Click { + if let Some(face) = pad.map.gesture_click(button) { + drop(guard); + self.set_button(config_key, face, true); + self.set_button(config_key, face, false); + } + return; + } + if let Some(dpad) = pad.map.dpad_direction(button, direction) { + drop(guard); + self.set_dpad(config_key, dpad, true); + self.set_dpad(config_key, dpad, false); + } + } + + /// Pulse a thumb-wheel axis, then auto-neutralize after [`AXIS_PULSE`]. + pub fn apply_thumbwheel_axis(&self, config_key: &str, button: ButtonId, magnitude: f32) { + let Some(binding) = self.binding_for(config_key, button) else { + return; + }; + let GamepadBinding::Axis(axis) = binding else { + return; + }; + let sign = if button == ButtonId::ThumbwheelScrollDown { + -1.0 + } else { + 1.0 + }; + let value = (magnitude * sign).clamp(-1.0, 1.0); + self.with_pad(config_key, |pad| { + pad.state.set_axis(axis, value); + pad.axis_hold_until + .insert(axis, Instant::now() + AXIS_PULSE); + emit(pad); + }); + } + + /// Clear all buttons/axes for `config_key` and emit (capture teardown). + pub fn neutralize(&self, config_key: &str) { + self.with_pad(config_key, |pad| { + pad.state = GamepadState::default(); + pad.axis_hold_until.clear(); + emit(pad); + }); + } + + /// Expire thumb-wheel axis pulses and poll host rumble. + pub fn tick(&self) { + self.expire_axis_holds(); + self.poll_rumble(); + } + + /// Poll every live pad for host rumble and forward to the sink. + pub fn poll_rumble(&self) { + let Ok(mut guard) = self.inner.lock() else { + return; + }; + let sink = guard.rumble_sink.clone(); + for pad in guard.pads.values_mut() { + if !(pad.desired.rumble && pad.desired.haptic_capable) { + let _ = pad.device.poll_rumble(); + continue; + } + if let Some(rumble) = pad.device.poll_rumble() + && rumble.is_active() + && let Some(sink) = sink.as_ref() + { + sink(pad.desired.route.clone(), rumble); + } + } + } + + /// Spawn a background maintenance poller (axis decay + rumble). + pub fn spawn_rumble_poller(&self) { + let pads = self.clone(); + thread::Builder::new() + .name("openlogi-gamepad-tick".into()) + .spawn(move || { + loop { + pads.tick(); + thread::sleep(Duration::from_millis(30)); + } + }) + .ok(); + } + + fn expire_axis_holds(&self) { + let Ok(mut guard) = self.inner.lock() else { + return; + }; + let now = Instant::now(); + for pad in guard.pads.values_mut() { + let expired: Vec = pad + .axis_hold_until + .iter() + .filter_map(|(axis, until)| (*until <= now).then_some(*axis)) + .collect(); + if expired.is_empty() { + continue; + } + for axis in expired { + pad.axis_hold_until.remove(&axis); + pad.state.set_axis(axis, 0.0); + } + emit(pad); + } + } + + fn binding_for(&self, config_key: &str, button: ButtonId) -> Option { + self.inner + .lock() + .ok()? + .pads + .get(config_key)? + .map + .button_binding(button) + } + + fn with_pad(&self, config_key: &str, f: impl FnOnce(&mut LivePad)) { + let Ok(mut guard) = self.inner.lock() else { + return; + }; + if let Some(pad) = guard.pads.get_mut(config_key) { + f(pad); + } + } +} + +fn destroy_pad(guard: &mut GamepadPadsInner, key: &str) { + if let Some(pad) = guard.pads.remove(key) { + info!(config_key = %key, "destroying virtual gamepad"); + if let Err(error) = pad.device.shutdown() { + warn!(config_key = %key, %error, "virtual gamepad shutdown failed"); + } + } +} + +fn emit(pad: &mut LivePad) { + if let Err(error) = pad.device.set_state(&pad.state) { + debug!( + config_key = %pad.desired.config_key, + %error, + "virtual gamepad set_state failed" + ); + } +} diff --git a/crates/openlogi-agent-core/src/lib.rs b/crates/openlogi-agent-core/src/lib.rs index 6c4fbf071..072cea4e8 100644 --- a/crates/openlogi-agent-core/src/lib.rs +++ b/crates/openlogi-agent-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod action_ring; pub mod capture_plan; mod dpi; pub mod event_monitor; +pub mod gamepad; pub mod hardware; pub mod observable; pub mod orchestrator; @@ -22,3 +23,4 @@ pub mod runtime; pub mod watchers; pub use dpi::{DpiCycleState, DpiCycles}; +pub use gamepad::{GamepadPadDesired, GamepadPads}; diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 748e2d8f1..cd4a73c02 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -32,7 +32,8 @@ use tracing::{debug, info, warn}; use crate::action_ring::ActionRingSessionSpec; use crate::capture_plan::{ - DeviceCapturePlan, SharedCapturePlans, hidpp_side_gesture_maps_for, plan_for_device, + CapturePlanRuntime, DeviceCapturePlan, SharedCapturePlans, hidpp_side_gesture_maps_for, + plan_for_device, }; use crate::hardware::{DeviceOp, HardwareContext}; use crate::observable::ObservableState; @@ -111,6 +112,8 @@ pub struct SharedRuntime { pub receiver_access: ReceiverAccess, /// Keyboard → pointing-device routes resolved from `config.toml`. pub host_switch_links: HostSwitchLinks, + /// Opt-in auxiliary virtual gamepads for online devices. + pub gamepads: crate::GamepadPads, } impl SharedRuntime { @@ -254,6 +257,7 @@ impl Orchestrator { capture_rearm_generation: Arc::new(AtomicU64::new(0)), receiver_access: ReceiverAccess::default(), host_switch_links, + gamepads: crate::GamepadPads::default(), }; let orch = Self { config, @@ -313,6 +317,15 @@ impl Orchestrator { bindings.remove(button); gestures.remove(button); } + if self.config.gamepad(key).enabled && self.shared.gamepads.is_active(key) { + let map = openlogi_core::binding::GamepadMap::default_for_mouse(); + for button in map.divert_buttons() { + if button.is_os_hook_button() { + bindings.remove(&button); + gestures.remove(&button); + } + } + } } HookMaps { bindings, @@ -394,6 +407,9 @@ impl Orchestrator { /// forget the other — a waking device needs both its capture session and /// its DPI-cycle slot. fn publish_device_runtime(&self) { + // Pads must sync before capture plans so diversion only arms for live + // virtual devices (fail-open when create fails). + self.sync_gamepads(); self.publish_capture_plans(); self.rebuild_dpi_cycles(self.current_key()); // Keyboard F-key bindings are global (not per-device), so they key off @@ -412,6 +428,50 @@ impl Orchestrator { publish_optional_arc_if_changed(&self.keyboard_spec_tx, self.keyboard_spec_for()); } + fn sync_gamepads(&self) { + let desired: Vec = self + .devices + .iter() + .filter(|dev| { + dev.online + && self.config.device_enabled(&dev.config_key) + && self.config.gamepad(&dev.config_key).enabled + }) + .filter_map(|dev| { + let route = dev.route.clone()?; + let identity = DeviceIdentity::from_parts(dev.serial.as_deref(), dev.unit_id); + let physical_key = canonical_device_key(&stable_id(dev), Some(&identity)) + .or_else(|| PhysicalDeviceKey::parse(&dev.config_key))?; + let gamepad = self.config.gamepad(&dev.config_key); + let product_name = self + .config + .devices + .get(&dev.config_key) + .and_then(|entry| entry.custom_name.clone()) + .or_else(|| { + self.config + .devices + .get(&dev.config_key) + .and_then(|entry| entry.identity.as_ref()) + .map(|identity| identity.display_name.clone()) + }) + .unwrap_or_else(|| format!("OpenLogi ({})", dev.model_key)); + Some(crate::GamepadPadDesired { + config_key: dev.config_key.clone(), + physical_key, + product_name, + route, + rumble: gamepad.rumble, + haptic_capable: dev + .capabilities + .as_ref() + .is_some_and(|caps| caps.haptic_feedback), + }) + }) + .collect(); + self.shared.gamepads.sync(&desired); + } + fn publish_capture_plans(&self) { publish_arc_if_changed(&self.capture_plans_tx, self.capture_plans_for()); } @@ -471,7 +531,10 @@ impl Orchestrator { route, self.current_app.as_deref(), rearm_generation, - self.os_mouse_hook_available, + CapturePlanRuntime { + os_mouse_hook_available: self.os_mouse_hook_available, + gamepad_live: self.shared.gamepads.is_active(&dev.config_key), + }, )) }) .collect() diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index ed36954a8..1de50af27 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -41,6 +41,7 @@ use self::dispatch::InputDispatcher; use super::capture_session::{CaptureRecovery, CaptureSession, CaptureSlot, ReconcileAction}; use super::shutdown::{ManagerCompletion, WatcherHandle}; use crate::capture_plan::{CaptureTarget, DeviceCapturePlan, DispatchPlan, SharedCapturePlans}; +use crate::gamepad::GamepadPads; use crate::receiver_access::{ReceiverAccess, ReceiverRequestState, SessionReceiverLease}; use crate::runtime::hook::SharedHookMaps; use crate::runtime::scroll::ScrollInputHandle; @@ -54,6 +55,7 @@ pub struct GestureOutputs { actions: ActionDispatcher, scroll: ScrollInputHandle, hook_maps: SharedHookMaps, + gamepads: GamepadPads, } impl GestureOutputs { @@ -63,17 +65,22 @@ impl GestureOutputs { actions: ActionDispatcher, scroll: ScrollInputHandle, hook_maps: SharedHookMaps, + gamepads: GamepadPads, ) -> Self { Self { actions, scroll, hook_maps, + gamepads, } } fn cancel_session(&self, session: &HidppSessionId) { self.actions.cancel_hidpp_session(session); self.scroll.cancel_hidpp_session(session); + // A cancelled capture session can no longer deliver button-up; clear + // any held pad state so games do not see a stuck button. + self.gamepads.neutralize(session.device_key()); } fn post_scroll(&self, session: &HidppSessionId, delta: ScrollDelta) { diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs index 899a1612d..a53112787 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs @@ -146,6 +146,9 @@ impl InputDispatcher { if self.record_thumbwheel_direction(key, input) { return; } + if self.try_dispatch_gamepad(session, plan, input) { + return; + } match input { CapturedInput::Gesture(button, direction) => { let Some(press) = self.gesture_presses.get(session, button) else { @@ -239,6 +242,58 @@ impl InputDispatcher { } } } + + /// When the plan carries a gamepad map **and** the pad is live, divert owned + /// controls into it. Plans should only carry a map for live pads; this is + /// defense in depth if a create fails after publish. + fn try_dispatch_gamepad( + &mut self, + session: &HidppSessionId, + plan: &DispatchPlan, + input: CapturedInput, + ) -> bool { + let Some(map) = plan.gamepad.as_ref() else { + return false; + }; + let key = session.device_key(); + if !self.outputs.gamepads.is_active(key) { + return false; + } + match input { + CapturedInput::Gesture(button, direction) if map.owns_button(button) => { + self.outputs.gamepads.apply_gesture(key, button, direction); + true + } + CapturedInput::ButtonDown(button) if map.owns_button(button) => { + self.outputs.gamepads.apply_button_edge(key, button, true); + true + } + CapturedInput::ButtonUp(button) if map.owns_button(button) => { + self.outputs.gamepads.apply_button_edge(key, button, false); + true + } + CapturedInput::ButtonPulse(button) if map.owns_button(button) => { + self.outputs.gamepads.apply_button_edge(key, button, true); + self.outputs.gamepads.apply_button_edge(key, button, false); + true + } + CapturedInput::Scroll { increments, .. } => { + let Some(rotation) = WheelRotation::from_increments(increments) else { + return false; + }; + let button = rotation.button(); + if !map.owns_button(button) { + return false; + } + let magnitude = (f32::from(increments.unsigned_abs()) / 120.0).clamp(0.15, 1.0); + self.outputs + .gamepads + .apply_thumbwheel_axis(key, button, magnitude); + true + } + _ => false, + } + } } #[cfg(test)] diff --git a/crates/openlogi-agent-core/src/watchers/gesture/tests.rs b/crates/openlogi-agent-core/src/watchers/gesture/tests.rs index ea8c0454a..e4a056331 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture/tests.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture/tests.rs @@ -26,7 +26,10 @@ fn plan() -> DeviceCapturePlan { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ) } @@ -179,7 +182,12 @@ async fn recovery_manager_waits_for_control_events_and_shutdown_between_retries( receiver_access: access, channel_registry: registry, device_io, - outputs: GestureOutputs::new(actions.dispatcher(), scroll.input(), Arc::default()), + outputs: GestureOutputs::new( + actions.dispatcher(), + scroll.input(), + Arc::default(), + crate::GamepadPads::default(), + ), shutdown, })); @@ -406,7 +414,10 @@ fn capture_target_changes_schedule_the_old_session_for_retirement() { session.target().route.clone(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!(session.target(), &plan.target); @@ -460,7 +471,10 @@ fn active_session_adopts_action_only_plan_changes_without_rearming() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); let mut session = live_session_from_plan(7, first.clone()); @@ -476,7 +490,10 @@ fn active_session_adopts_action_only_plan_changes_without_rearming() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!(first.target, rebound.target); assert_eq!( @@ -500,7 +517,10 @@ fn active_session_adopts_gesture_and_per_app_dispatch_changes() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); let mut session = live_session_from_plan(7, first.clone()); @@ -517,7 +537,10 @@ fn active_session_adopts_gesture_and_per_app_dispatch_changes() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!(first.target, gestured.target); assert_eq!( @@ -545,7 +568,10 @@ fn active_session_adopts_gesture_and_per_app_dispatch_changes() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); let mut session = live_session_from_plan(8, base.clone()); config.set_per_app_binding( @@ -561,7 +587,10 @@ fn active_session_adopts_gesture_and_per_app_dispatch_changes() { route(), Some("com.example.Editor"), 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!(base.target, per_app.target); assert_eq!( @@ -589,7 +618,10 @@ fn wheel_configuration_changes_refresh_without_rearming_hardware() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); let mut session = live_session_from_plan(7, first.clone()); @@ -605,7 +637,10 @@ fn wheel_configuration_changes_refresh_without_rearming_hardware() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!( first.target, rebound.target, @@ -626,7 +661,10 @@ fn wheel_configuration_changes_refresh_without_rearming_hardware() { route(), None, 0, - true, + crate::capture_plan::CapturePlanRuntime { + os_mouse_hook_available: true, + gamepad_live: false, + }, ); assert_eq!(rebound.target, rescaled.target); assert_eq!( diff --git a/crates/openlogi-agent/bundle/OpenLogiAgent.entitlements b/crates/openlogi-agent/bundle/OpenLogiAgent.entitlements new file mode 100644 index 000000000..f91eff34c --- /dev/null +++ b/crates/openlogi-agent/bundle/OpenLogiAgent.entitlements @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/crates/openlogi-agent/src/server.rs b/crates/openlogi-agent/src/server.rs index a75d5b499..b1dba6881 100644 --- a/crates/openlogi-agent/src/server.rs +++ b/crates/openlogi-agent/src/server.rs @@ -70,6 +70,27 @@ impl AgentServer { dispatcher: ActionDispatcher, ) -> (Self, tokio::sync::mpsc::UnboundedReceiver) { let ring_haptics = RingHapticPlayer::spawn(shared.clone()); + // Forward host gamepad rumble onto MX Master–class haptics when capable. + { + let haptic_shared = shared.clone(); + shared.gamepads.set_rumble_sink(move |route, rumble| { + let waveform = if rumble.strong >= rumble.weak { + HapticWaveform::DampStateChange + } else { + HapticWaveform::SubtleCollision + }; + let shared = haptic_shared.clone(); + tokio::spawn(async move { + let _ = shared + .device(&route) + .run(HidppOperation::PlayHaptic, |c| async move { + openlogi_hid::play_haptic_on(&c, waveform).await + }) + .await; + }); + }); + shared.gamepads.spawn_rumble_poller(); + } let (demand, declarations) = tokio::sync::mpsc::unbounded_channel(); ( Self { diff --git a/crates/openlogi-agent/src/startup.rs b/crates/openlogi-agent/src/startup.rs index 99fdb4e03..99740b436 100644 --- a/crates/openlogi-agent/src/startup.rs +++ b/crates/openlogi-agent/src/startup.rs @@ -206,6 +206,7 @@ pub(crate) fn spawn_hidpp_watchers( inputs.dispatcher.clone(), inputs.scroll_input.clone(), shared.hook_maps.clone(), + shared.gamepads.clone(), ), ); let host_switch = watchers::host_switch::spawn( diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 23fc92fe5..89e2a1076 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -16,6 +16,7 @@ mod button; mod category; mod defaults; mod effect; +mod gamepad; mod gesture; mod key_combo; mod swipe; @@ -34,6 +35,10 @@ pub use button::ButtonId; pub use category::Category; pub use defaults::{default_binding, default_binding_for, default_gesture_binding}; pub use effect::{Effect, MediaKey, MouseButton, NativeAction, Script, Shortcut}; +pub use gamepad::{ + DpadDirection, GamepadAxis, GamepadBinding, GamepadConfig, GamepadFaceButton, + GamepadGestureMap, GamepadMap, +}; pub use gesture::GestureDirection; pub use key_combo::{KeyCombo, KeyComboParseError, KeyboardUsage, KeyboardUsageError}; pub use swipe::{ diff --git a/crates/openlogi-core/src/binding/gamepad.rs b/crates/openlogi-core/src/binding/gamepad.rs new file mode 100644 index 000000000..e39024f75 --- /dev/null +++ b/crates/openlogi-core/src/binding/gamepad.rs @@ -0,0 +1,305 @@ +//! Opt-in auxiliary virtual-gamepad mapping for pointing devices. +//! +//! The persisted config is intentionally tiny (`enabled` / `rumble`); the +//! default control → standard-layout map lives here so later override TOML can +//! land without another schema fight. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{ButtonId, GestureDirection}; + +/// Per-device opt-in for exposing remapped extras as a virtual gamepad. +/// +/// Disabled by default and omitted from `config.toml` when unset. Left/right +/// click and pointer motion are never claimed by the default map — the mouse +/// stays a mouse. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GamepadConfig { + /// Publish an OS-visible virtual gamepad for this device. + #[serde(default, skip_serializing_if = "is_false")] + pub enabled: bool, + /// Forward host dual-rumble to device haptics (`0x19b0`) when capable. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub rumble: bool, +} + +impl Default for GamepadConfig { + fn default() -> Self { + Self { + enabled: false, + rumble: true, + } + } +} + +impl GamepadConfig { + /// Whether this value is exactly the implicit default and can be omitted + /// from `config.toml`. + #[must_use] + pub fn is_default(&self) -> bool { + self == &Self::default() + } +} + +/// A button on the W3C / Xbox "standard" gamepad layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum GamepadFaceButton { + /// `buttons[0]` — bottom (A / South). + A, + /// `buttons[1]` — right (B / East). + B, + /// `buttons[2]` — left (X / West). + X, + /// `buttons[3]` — top (Y / North). + Y, + /// `buttons[4]` — left bumper. + LeftShoulder, + /// `buttons[5]` — right bumper. + RightShoulder, + /// `buttons[8]` — Select / Back. + Select, + /// `buttons[9]` — Start. + Start, +} + +impl GamepadFaceButton { + /// Index in the Gamepad API `buttons` array for the standard mapping. + #[must_use] + pub const fn standard_index(self) -> usize { + match self { + Self::A => 0, + Self::B => 1, + Self::X => 2, + Self::Y => 3, + Self::LeftShoulder => 4, + Self::RightShoulder => 5, + Self::Select => 8, + Self::Start => 9, + } + } +} + +/// Continuous axes on the standard gamepad layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum GamepadAxis { + /// `axes[0]` — left stick X. + LeftStickX, + /// `axes[1]` — left stick Y. + LeftStickY, + /// `axes[2]` — right stick X. + RightStickX, + /// `axes[3]` — right stick Y. + RightStickY, + /// Left trigger (`buttons[6]` analog). + LeftTrigger, + /// Right trigger (`buttons[7]` analog). + RightTrigger, +} + +/// How a physical mouse control feeds the virtual pad. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum GamepadBinding { + /// Discrete face / shoulder / menu button. + Button(GamepadFaceButton), + /// Continuous axis; for thumb-wheel scroll the sign follows rotation. + Axis(GamepadAxis), + /// Four swipe directions become the D-pad hat; click is separate. + Dpad, +} + +/// Resolved control → pad map used while gamepad mode is enabled. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct GamepadMap { + /// Plain button and thumb-wheel slots. + buttons: BTreeMap, + /// Gesture sources whose swipe map owns the D-pad (and optional click). + gestures: BTreeMap, +} + +/// Per-gesture-source pad bindings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GamepadGestureMap { + /// Swipe directions → D-pad. + pub dpad: bool, + /// Plain click without a swipe. + pub click: Option, +} + +impl GamepadMap { + /// Default auxiliary map for MX Master–class mice. + /// + /// Left/right/middle and the main wheel stay native so the pointer keeps + /// working while extras feed the pad. + #[must_use] + pub fn default_for_mouse() -> Self { + let mut buttons = BTreeMap::new(); + buttons.insert(ButtonId::Back, GamepadBinding::Button(GamepadFaceButton::B)); + buttons.insert( + ButtonId::Forward, + GamepadBinding::Button(GamepadFaceButton::A), + ); + buttons.insert( + ButtonId::DpiToggle, + GamepadBinding::Button(GamepadFaceButton::Select), + ); + buttons.insert( + ButtonId::Thumbwheel, + GamepadBinding::Button(GamepadFaceButton::Y), + ); + buttons.insert( + ButtonId::ThumbwheelScrollUp, + GamepadBinding::Axis(GamepadAxis::RightStickX), + ); + buttons.insert( + ButtonId::ThumbwheelScrollDown, + GamepadBinding::Axis(GamepadAxis::RightStickX), + ); + + let mut gestures = BTreeMap::new(); + gestures.insert( + ButtonId::GestureButton, + GamepadGestureMap { + dpad: true, + click: Some(GamepadFaceButton::Start), + }, + ); + gestures.insert( + ButtonId::HapticPanel, + GamepadGestureMap { + dpad: true, + click: Some(GamepadFaceButton::X), + }, + ); + + Self { buttons, gestures } + } + + /// Whether this physical control is owned by the pad (actions must not run). + #[must_use] + pub fn owns_button(&self, button: ButtonId) -> bool { + self.buttons.contains_key(&button) || self.gestures.contains_key(&button) + } + + /// Plain (non-gesture) binding for `button`, if any. + #[must_use] + pub fn button_binding(&self, button: ButtonId) -> Option { + self.buttons.get(&button).copied() + } + + /// Gesture-source map for `button`, if any. + #[must_use] + pub fn gesture_map(&self, button: ButtonId) -> Option<&GamepadGestureMap> { + self.gestures.get(&button) + } + + /// Every button id the capture path must divert while the pad is live. + pub fn divert_buttons(&self) -> impl Iterator + '_ { + self.buttons + .keys() + .copied() + .chain(self.gestures.keys().copied()) + } + + /// Face button for a gesture click, when the source maps one. + #[must_use] + pub fn gesture_click(&self, button: ButtonId) -> Option { + self.gestures.get(&button).and_then(|map| map.click) + } + + /// Whether swipes on `button` drive the D-pad. + #[must_use] + pub fn gesture_owns_dpad(&self, button: ButtonId) -> bool { + self.gestures.get(&button).is_some_and(|map| map.dpad) + } + + /// D-pad direction for a swipe, when this source owns the hat. + #[must_use] + pub fn dpad_direction( + &self, + button: ButtonId, + swipe: GestureDirection, + ) -> Option { + if !self.gesture_owns_dpad(button) { + return None; + } + match swipe { + GestureDirection::Up => Some(DpadDirection::Up), + GestureDirection::Down => Some(DpadDirection::Down), + GestureDirection::Left => Some(DpadDirection::Left), + GestureDirection::Right => Some(DpadDirection::Right), + GestureDirection::Click => None, + } + } +} + +/// One of the four D-pad arms. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DpadDirection { + /// Hat north. + Up, + /// Hat south. + Down, + /// Hat west. + Left, + /// Hat east. + Right, +} + +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature" +)] +fn is_false(b: &bool) -> bool { + !*b +} + +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature" +)] +fn is_true(b: &bool) -> bool { + *b +} + +const fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_omitted_from_toml() { + let cfg = GamepadConfig::default(); + assert!(cfg.is_default()); + assert!(!cfg.enabled); + assert!(cfg.rumble); + } + + #[test] + fn default_map_leaves_primary_mouse_native() { + let map = GamepadMap::default_for_mouse(); + assert!(!map.owns_button(ButtonId::LeftClick)); + assert!(!map.owns_button(ButtonId::RightClick)); + assert!(!map.owns_button(ButtonId::MiddleClick)); + assert!(map.owns_button(ButtonId::Back)); + assert!(map.owns_button(ButtonId::GestureButton)); + assert!(map.owns_button(ButtonId::HapticPanel)); + assert!(map.owns_button(ButtonId::ThumbwheelScrollUp)); + } + + #[test] + fn gamepad_config_round_trips() { + let raw = "enabled = true\nrumble = false\n"; + let cfg: GamepadConfig = toml::from_str(raw).expect("parse"); + assert!(cfg.enabled); + assert!(!cfg.rumble); + let out = toml::to_string(&cfg).expect("serialize"); + assert!(out.contains("enabled = true")); + assert!(out.contains("rumble = false")); + } +} diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index d4ba4ac55..1f7af20dc 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -38,8 +38,8 @@ pub use settings::{ }; use crate::binding::{ - Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection, - RingAction, default_binding, default_binding_for, default_gesture_binding, + Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GamepadConfig, + GestureDirection, RingAction, default_binding, default_binding_for, default_gesture_binding, }; use crate::device_order::PhysicalDeviceKey; use crate::hid::Dpi; @@ -49,6 +49,9 @@ use settings::GestureOwner; /// persisted shape or enum vocabulary changes; readers inspect this value /// before consuming the rest of the file. /// +/// v8 adds the optional per-device `[devices.*.gamepad]` section (auxiliary +/// virtual gamepad). Absent on older files; defaults keep the feature off. +/// /// v7 aligns the thumb-wheel scroll defaults with its normalised physical /// direction. Pre-v7 explicit default pairs are migrated in device and /// per-application profiles so they remain native rather than becoming a @@ -91,7 +94,7 @@ use settings::GestureOwner; /// next save; [`Config::load_from_path`] accepts supported versions `1` through /// [`SCHEMA_VERSION`] so an invalid or forward file fails loudly instead of /// silently losing bindings. -pub const SCHEMA_VERSION: u32 = 7; +pub const SCHEMA_VERSION: u32 = 8; /// Top-level config document. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -687,6 +690,15 @@ impl Config { .unwrap_or_default() } + /// Auxiliary virtual-gamepad settings for `device_key`. + #[must_use] + pub fn gamepad(&self, device_key: &str) -> GamepadConfig { + self.devices + .get(device_key) + .map(|device| device.gamepad.clone()) + .unwrap_or_default() + } + /// Enable or disable `device_key`'s Actions Ring. pub fn set_action_ring_enabled(&mut self, device_key: &str, enabled: bool) { self.devices diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs index e45d66de6..f0b7f3bed 100644 --- a/crates/openlogi-core/src/config/device.rs +++ b/crates/openlogi-core/src/config/device.rs @@ -10,7 +10,9 @@ use super::settings::{ CameraControls, GestureOwner, LightSettings, Lighting, ScrollResolution, SmartShift, ThumbwheelSensitivity, deserialize_gesture_owner, }; -use crate::binding::{Action, ActionRingConfig, Binding, ButtonId, GestureDirection}; +use crate::binding::{ + Action, ActionRingConfig, Binding, ButtonId, GamepadConfig, GestureDirection, +}; use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities}; use crate::hid::Dpi; @@ -198,6 +200,9 @@ pub struct DeviceConfig { /// Host-rendered Actions Ring settings and complete per-application layouts. #[serde(default, skip_serializing_if = "ActionRingConfig::is_default")] pub action_ring: ActionRingConfig, + /// Opt-in auxiliary virtual gamepad for this device. Disabled by default. + #[serde(default, skip_serializing_if = "GamepadConfig::is_default")] + pub gamepad: GamepadConfig, /// Ordered list of DPI presets cycled through by /// [`Action::CycleDpiPresets`] and indexed by /// [`Action::SetDpiPreset`]. Empty means "no presets configured" — @@ -357,6 +362,7 @@ impl Default for DeviceConfig { disabled_gestures: BTreeMap::new(), per_app_bindings: BTreeMap::new(), action_ring: ActionRingConfig::default(), + gamepad: GamepadConfig::default(), dpi_presets: Vec::new(), dpi: None, lighting: None, @@ -464,6 +470,8 @@ struct RawDeviceConfig { per_app_bindings: BTreeMap>, #[serde(default)] action_ring: ActionRingConfig, + #[serde(default)] + gamepad: GamepadConfig, #[serde(default, deserialize_with = "deserialize_dpi_presets")] dpi_presets: Vec, #[serde(default, deserialize_with = "deserialize_optional_dpi")] @@ -537,6 +545,7 @@ impl From for DeviceConfig { disabled_gestures: raw.disabled_gestures, per_app_bindings: raw.per_app_bindings, action_ring: raw.action_ring, + gamepad: raw.gamepad, dpi_presets: raw.dpi_presets, dpi: raw.dpi, lighting: raw.lighting, diff --git a/crates/openlogi-core/src/config/tests.rs b/crates/openlogi-core/src/config/tests.rs index f8d7b87c4..6573bc235 100644 --- a/crates/openlogi-core/src/config/tests.rs +++ b/crates/openlogi-core/src/config/tests.rs @@ -405,6 +405,30 @@ fn invert_scroll_roundtrips_per_device() { assert!(!restored.invert_scroll("absent")); } +#[test] +fn gamepad_roundtrips_per_device() { + let mut cfg = Config::default(); + cfg.devices + .entry("unit:deadbeef".into()) + .or_default() + .gamepad + .enabled = true; + cfg.devices + .entry("unit:deadbeef".into()) + .or_default() + .gamepad + .rumble = false; + + let parsed = write_and_read(&cfg); + let pad = parsed.gamepad("unit:deadbeef"); + assert!(pad.enabled); + assert!(!pad.rumble); + assert!(parsed.gamepad("missing").is_default()); + let body = toml::to_string_pretty(&parsed).expect("serialize"); + assert!(body.contains("[devices.\"unit:deadbeef\".gamepad]")); + assert!(body.contains("enabled = true")); +} + #[test] fn default_invert_scroll_is_omitted_from_toml() { // A device block with only the default (false) invert_scroll must not diff --git a/crates/openlogi-gamepad/Cargo.toml b/crates/openlogi-gamepad/Cargo.toml new file mode 100644 index 000000000..08f854546 --- /dev/null +++ b/crates/openlogi-gamepad/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openlogi-gamepad" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Virtual HID gamepad backends for OpenLogi's auxiliary mouse→controller mode." +# Bundled inside the agent; never published alone. +publish = false + +[dependencies] +openlogi-core = { path = "../openlogi-core", version = "0.8.3", default-features = false } +thiserror = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +evdev = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = { workspace = true } diff --git a/crates/openlogi-gamepad/src/descriptor.rs b/crates/openlogi-gamepad/src/descriptor.rs new file mode 100644 index 000000000..3c84aa8cf --- /dev/null +++ b/crates/openlogi-gamepad/src/descriptor.rs @@ -0,0 +1,67 @@ +//! Xbox-layout HID report descriptor for browser `mapping: "standard"`. +//! +//! Usages follow Generic Desktop Game Pad + Button page so Chromium assigns +//! the standard indices. Report layout matches [`crate::state::GamepadState::to_input_report`]. + +/// 8-byte input + 2-byte output (dual rumble) report descriptor. +pub const STANDARD_GAMEPAD_REPORT_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xa1, 0x01, // Collection (Application) + // Sticks + triggers (6 bytes) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x32, // Usage (Z) — right stick X + 0x09, 0x35, // Usage (Rz) — right stick Y + 0x09, 0x33, // Usage (Rx) — left trigger + 0x09, 0x34, // Usage (Ry) — right trigger + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xff, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x06, // Report Count (6) + 0x81, 0x02, // Input (Data,Var,Abs) + // Hat switch (4 bits) + padding into button low nibble + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x39, // Usage (Hat switch) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3b, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x42, // Input (Data,Var,Abs,Null) + // 12 buttons in the remaining bits of byte 6 + byte 7 + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (1) + 0x29, 0x0c, // Usage Maximum (12) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x0c, // Report Count (12) + 0x81, 0x02, // Input (Data,Var,Abs) + // Output: dual rumble (strong, weak) + 0x05, 0x0f, // Usage Page (Physical Interface) + 0x09, 0x97, // Usage (Vendor — dual motor magnitudes) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xff, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x91, 0x02, // Output (Data,Var,Abs) + 0xc0, // End Collection +]; + +/// OpenLogi virtual-pad USB IDs (pid.codes open-source block style). Not a +/// Microsoft Xbox VID — the report descriptor's usages drive standard mapping. +pub const OPENLOGI_GAMEPAD_VID: u32 = 0x1209; +pub const OPENLOGI_GAMEPAD_PID: u32 = 0x0C06; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn descriptor_is_nonempty() { + assert!(STANDARD_GAMEPAD_REPORT_DESCRIPTOR.len() > 40); + } +} diff --git a/crates/openlogi-gamepad/src/error.rs b/crates/openlogi-gamepad/src/error.rs new file mode 100644 index 000000000..8aa21161a --- /dev/null +++ b/crates/openlogi-gamepad/src/error.rs @@ -0,0 +1,27 @@ +//! Failure modes for virtual gamepad create/emit/destroy. + +use thiserror::Error; + +/// Why a virtual gamepad operation failed. +#[derive(Debug, Error)] +pub enum GamepadError { + /// macOS denied `IOHIDUserDeviceCreate` — needs + /// `com.apple.developer.hid.virtual.device` on the agent. + #[error( + "macOS Virtual HID entitlement required \ + (com.apple.developer.hid.virtual.device on the agent)" + )] + EntitlementRequired, + + /// Windows ViGEmBus is not installed or the client could not attach. + #[error("ViGEmBus driver missing or unavailable")] + DriverMissing, + + /// This OS has no backend yet. + #[error("virtual gamepad is not supported on this platform")] + UnsupportedPlatform, + + /// Underlying I/O or FFI failure. + #[error(transparent)] + Io(#[from] std::io::Error), +} diff --git a/crates/openlogi-gamepad/src/lib.rs b/crates/openlogi-gamepad/src/lib.rs new file mode 100644 index 000000000..153b791a1 --- /dev/null +++ b/crates/openlogi-gamepad/src/lib.rs @@ -0,0 +1,57 @@ +//! Virtual HID gamepad backends for OpenLogi's opt-in auxiliary controller mode. +//! +//! The agent owns create/destroy and feeds [`GamepadState`] snapshots; host +//! rumble is polled back for devices with haptic feedback. Platform backends +//! live behind [`create`]. + +#![deny(missing_docs)] + +mod descriptor; +mod error; +mod state; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +pub use error::GamepadError; +pub use state::{GamepadState, Rumble}; + +/// Long-lived OS-visible virtual gamepad. +pub trait VirtualGamepad: Send { + /// Replace the full pad state and emit an input report. + /// + /// # Errors + /// + /// Returns when the underlying virtual device rejects the report. + fn set_state(&mut self, state: &GamepadState) -> Result<(), GamepadError>; + + /// Drain the latest host rumble request, if any. + fn poll_rumble(&mut self) -> Option; + + /// Tear down the virtual device. + /// + /// # Errors + /// + /// Returns when the platform backend fails to destroy the device cleanly. + fn shutdown(self: Box) -> Result<(), GamepadError>; +} + +/// Create a platform virtual gamepad named `product_name`. +/// +/// # Errors +/// +/// - macOS: [`GamepadError::EntitlementRequired`] when Virtual HID is denied +/// - Windows: [`GamepadError::DriverMissing`] when ViGEmBus is absent +/// - Linux / any: I/O failures creating the node +pub fn create(product_name: &str) -> Result, GamepadError> { + cfg_select! { + target_os = "macos" => { macos::create(product_name) } + target_os = "linux" => { linux::create(product_name) } + target_os = "windows" => { windows::create(product_name) } + _ => { Err(GamepadError::UnsupportedPlatform) } + } +} diff --git a/crates/openlogi-gamepad/src/linux.rs b/crates/openlogi-gamepad/src/linux.rs new file mode 100644 index 000000000..a0c42aa19 --- /dev/null +++ b/crates/openlogi-gamepad/src/linux.rs @@ -0,0 +1,225 @@ +//! Linux uinput gamepad backend. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use evdev::{ + AbsInfo, AbsoluteAxisCode, AttributeSet, EventType, InputEvent, KeyCode, UinputAbsSetup, + uinput::VirtualDevice, +}; + +use crate::{GamepadError, GamepadState, Rumble, VirtualGamepad}; + +const DEVICE_NAME_PREFIX: &str = "OpenLogi Virtual Gamepad"; + +/// Create a uinput joystick that browsers/SDL see as a gamepad. +pub fn create(product_name: &str) -> Result, GamepadError> { + let name = format!("{DEVICE_NAME_PREFIX} ({product_name})"); + let device = build(&name)?; + Ok(Box::new(LinuxGamepad { device })) +} + +struct LinuxGamepad { + device: VirtualDevice, +} + +fn build(name: &str) -> Result { + let mut keys = AttributeSet::::default(); + for code in [ + KeyCode::BTN_SOUTH, + KeyCode::BTN_EAST, + KeyCode::BTN_NORTH, + KeyCode::BTN_WEST, + KeyCode::BTN_TL, + KeyCode::BTN_TR, + KeyCode::BTN_SELECT, + KeyCode::BTN_START, + KeyCode::BTN_MODE, + KeyCode::BTN_THUMBL, + KeyCode::BTN_THUMBR, + ] { + keys.insert(code); + } + + let stick = AbsInfo::new(0, -32767, 32767, 16, 128, 0); + let trigger = AbsInfo::new(0, 0, 255, 0, 0, 0); + let hat = AbsInfo::new(0, -1, 1, 0, 0, 0); + + // Do not advertise FF_RUMBLE until poll_rumble reads uinput force-feedback + // events — advertising without implementing leaves host rumble dead. + Ok(VirtualDevice::builder()? + .name(name) + .with_keys(&keys)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, stick))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, stick))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RX, stick))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RY, stick))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Z, trigger))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RZ, trigger))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_HAT0X, hat))? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_HAT0Y, hat))? + .build()?) +} + +impl VirtualGamepad for LinuxGamepad { + fn set_state(&mut self, state: &GamepadState) -> Result<(), GamepadError> { + let now = event_time(); + let mut events = Vec::with_capacity(20); + push_key( + &mut events, + now, + KeyCode::BTN_SOUTH, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::A), + ); + push_key( + &mut events, + now, + KeyCode::BTN_EAST, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::B), + ); + push_key( + &mut events, + now, + KeyCode::BTN_WEST, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::X), + ); + push_key( + &mut events, + now, + KeyCode::BTN_NORTH, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::Y), + ); + push_key( + &mut events, + now, + KeyCode::BTN_TL, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::LeftShoulder), + ); + push_key( + &mut events, + now, + KeyCode::BTN_TR, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::RightShoulder), + ); + push_key( + &mut events, + now, + KeyCode::BTN_SELECT, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::Select), + ); + push_key( + &mut events, + now, + KeyCode::BTN_START, + state.button_pressed(openlogi_core::binding::GamepadFaceButton::Start), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_X, + axis_i32(state.left_x), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_Y, + axis_i32(state.left_y), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_RX, + axis_i32(state.right_x), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_RY, + axis_i32(state.right_y), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_Z, + trigger_i32(state.left_trigger), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_RZ, + trigger_i32(state.right_trigger), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_HAT0X, + i32::from(state.dpad_x), + ); + push_abs( + &mut events, + now, + AbsoluteAxisCode::ABS_HAT0Y, + i32::from(state.dpad_y), + ); + events.push(InputEvent::new_now(EventType::SYNCHRONIZATION.0, 0, 0)); + self.device.emit(&events)?; + Ok(()) + } + + fn poll_rumble(&mut self) -> Option { + // Do not advertise FF until we read uinput force-feedback events. + None + } + + fn shutdown(self: Box) -> Result<(), GamepadError> { + drop(self.device); + Ok(()) + } +} + +fn push_key(events: &mut Vec, time: evdev::SystemTime, code: KeyCode, pressed: bool) { + events.push(InputEvent::new( + time, + EventType::KEY.0, + code.code(), + i32::from(pressed), + )); +} + +fn push_abs( + events: &mut Vec, + time: evdev::SystemTime, + code: AbsoluteAxisCode, + value: i32, +) { + events.push(InputEvent::new(time, EventType::ABSOLUTE.0, code.0, value)); +} + +fn axis_i32(value: f32) -> i32 { + let scaled = (value.clamp(-1.0, 1.0) * 32767.0).round(); + #[expect( + clippy::cast_possible_truncation, + reason = "scaled is within i16 after clamp" + )] + { + scaled as i32 + } +} + +fn trigger_i32(value: f32) -> i32 { + let scaled = (value.clamp(0.0, 1.0) * 255.0).round(); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "scaled is within 0..=255" + )] + { + scaled as i32 + } +} + +fn event_time() -> evdev::SystemTime { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .into() +} diff --git a/crates/openlogi-gamepad/src/macos.rs b/crates/openlogi-gamepad/src/macos.rs new file mode 100644 index 000000000..2d121399a --- /dev/null +++ b/crates/openlogi-gamepad/src/macos.rs @@ -0,0 +1,119 @@ +//! macOS `IOHIDUserDevice` backend. +//! +//! Requires the restricted entitlement `com.apple.developer.hid.virtual.device` +//! on the agent binary. Without it, [`create`] returns +//! [`GamepadError::EntitlementRequired`]. +//! +//! Host rumble callbacks are deferred: the public +//! `IOHIDUserDeviceRegisterOutputReportCallback` symbol is not linkable on +//! every SDK, so [`VirtualGamepad::poll_rumble`] is currently always empty on +//! macOS. Input reports still work for the Gamepad API. + +#![expect(unsafe_code, reason = "IOHIDUserDevice is a raw IOKit C API")] + +use std::ffi::c_void; +use std::ptr; + +use core_foundation::base::{CFAllocatorRef, CFType, TCFType}; +use core_foundation::data::CFData; +use core_foundation::dictionary::CFMutableDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; + +use crate::descriptor::{ + OPENLOGI_GAMEPAD_PID, OPENLOGI_GAMEPAD_VID, STANDARD_GAMEPAD_REPORT_DESCRIPTOR, +}; +use crate::{GamepadError, GamepadState, Rumble, VirtualGamepad}; + +type IoHidUserDeviceRef = *mut c_void; +type IoReturn = i32; + +const K_IO_RETURN_SUCCESS: IoReturn = 0; + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + fn IOHIDUserDeviceCreate( + allocator: CFAllocatorRef, + properties: *const c_void, + ) -> IoHidUserDeviceRef; + + fn IOHIDUserDeviceHandleReport( + device: IoHidUserDeviceRef, + report: *const u8, + report_length: usize, + ) -> IoReturn; + + fn CFRelease(cf: *const c_void); +} + +struct MacGamepad { + device: IoHidUserDeviceRef, +} + +// SAFETY: IOHIDUserDevice is used from a single owner thread at a time; the +// agent serializes set_state / poll_rumble / shutdown on one worker. +unsafe impl Send for MacGamepad {} + +/// Create a macOS virtual gamepad. +pub fn create(product_name: &str) -> Result, GamepadError> { + let properties = properties_dict(product_name); + // SAFETY: properties is a live CFDictionary; NULL allocator uses the default. + let device = unsafe { IOHIDUserDeviceCreate(ptr::null(), properties.as_CFTypeRef().cast()) }; + if device.is_null() { + return Err(GamepadError::EntitlementRequired); + } + + Ok(Box::new(MacGamepad { device })) +} + +fn properties_dict(product_name: &str) -> CFMutableDictionary { + let mut dict = CFMutableDictionary::::new(); + dict.set( + CFString::new("ReportDescriptor"), + CFData::from_buffer(STANDARD_GAMEPAD_REPORT_DESCRIPTOR).as_CFType(), + ); + dict.set( + CFString::new("VendorID"), + CFNumber::from(i32::try_from(OPENLOGI_GAMEPAD_VID).unwrap_or(0x1209)).as_CFType(), + ); + dict.set( + CFString::new("ProductID"), + CFNumber::from(i32::try_from(OPENLOGI_GAMEPAD_PID).unwrap_or(0x0C06)).as_CFType(), + ); + dict.set( + CFString::new("Product"), + CFString::new(product_name).as_CFType(), + ); + dict.set( + CFString::new("Transport"), + CFString::new("Virtual").as_CFType(), + ); + dict +} + +impl VirtualGamepad for MacGamepad { + fn set_state(&mut self, state: &GamepadState) -> Result<(), GamepadError> { + let report = state.to_input_report(); + // SAFETY: device is a live IOHIDUserDevice; report is stack-owned. + let status = + unsafe { IOHIDUserDeviceHandleReport(self.device, report.as_ptr(), report.len()) }; + if status != K_IO_RETURN_SUCCESS { + return Err(GamepadError::Io(std::io::Error::other( + "IOHIDUserDeviceHandleReport failed", + ))); + } + Ok(()) + } + + fn poll_rumble(&mut self) -> Option { + None + } + + fn shutdown(self: Box) -> Result<(), GamepadError> { + // SAFETY: balances IOHIDUserDeviceCreate. + unsafe { + CFRelease(self.device.cast()); + } + Ok(()) + } +} diff --git a/crates/openlogi-gamepad/src/state.rs b/crates/openlogi-gamepad/src/state.rs new file mode 100644 index 000000000..8b8965148 --- /dev/null +++ b/crates/openlogi-gamepad/src/state.rs @@ -0,0 +1,209 @@ +//! Snapshot of a standard-layout gamepad and host rumble. + +use openlogi_core::binding::{DpadDirection, GamepadAxis, GamepadFaceButton}; + +/// Dual-rumble magnitudes from the host (0.0–1.0). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Rumble { + /// Strong / low-frequency motor. + pub strong: f32, + /// Weak / high-frequency motor. + pub weak: f32, +} + +impl Rumble { + /// Whether either motor is above a quiet threshold. + #[must_use] + pub fn is_active(self) -> bool { + self.strong > 0.01 || self.weak > 0.01 + } + + /// Peak magnitude as a 0..=100 haptic intensity percentage. + #[must_use] + pub fn intensity_percent(self) -> u8 { + let peak = self.strong.max(self.weak).clamp(0.0, 1.0); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "peak is clamped to 0..=1 before scaling to u8" + )] + { + (peak * 100.0).round() as u8 + } + } +} + +/// Full standard-layout pad state submitted as one report. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GamepadState { + /// Face / shoulder / menu buttons by standard index bit. + buttons: u16, + /// D-pad: -1/0/1 on X and Y. + pub dpad_x: i8, + /// D-pad Y. + pub dpad_y: i8, + /// Left stick X in `-1.0..=1.0`. + pub left_x: f32, + /// Left stick Y in `-1.0..=1.0` (negative is up). + pub left_y: f32, + /// Right stick X. + pub right_x: f32, + /// Right stick Y. + pub right_y: f32, + /// Left trigger `0.0..=1.0`. + pub left_trigger: f32, + /// Right trigger `0.0..=1.0`. + pub right_trigger: f32, +} + +impl GamepadState { + /// Set a face/shoulder/menu button. + pub fn set_button(&mut self, button: GamepadFaceButton, pressed: bool) { + let bit = 1u16 << button.standard_index(); + if pressed { + self.buttons |= bit; + } else { + self.buttons &= !bit; + } + } + + /// Whether `button` is pressed. + #[must_use] + pub fn button_pressed(&self, button: GamepadFaceButton) -> bool { + self.buttons & (1u16 << button.standard_index()) != 0 + } + + /// Raw button bitmask (bit N = standard index N). + #[must_use] + pub const fn buttons_mask(&self) -> u16 { + self.buttons + } + + /// Apply a D-pad arm (clears other arms when `pressed`). + pub fn set_dpad(&mut self, direction: DpadDirection, pressed: bool) { + if !pressed { + match direction { + DpadDirection::Up if self.dpad_y < 0 => self.dpad_y = 0, + DpadDirection::Down if self.dpad_y > 0 => self.dpad_y = 0, + DpadDirection::Left if self.dpad_x < 0 => self.dpad_x = 0, + DpadDirection::Right if self.dpad_x > 0 => self.dpad_x = 0, + _ => {} + } + return; + } + match direction { + DpadDirection::Up => self.dpad_y = -1, + DpadDirection::Down => self.dpad_y = 1, + DpadDirection::Left => self.dpad_x = -1, + DpadDirection::Right => self.dpad_x = 1, + } + } + + /// Write a continuous axis value in `-1.0..=1.0` (triggers clamp to `0..=1`). + pub fn set_axis(&mut self, axis: GamepadAxis, value: f32) { + let clamped = value.clamp(-1.0, 1.0); + match axis { + GamepadAxis::LeftStickX => self.left_x = clamped, + GamepadAxis::LeftStickY => self.left_y = clamped, + GamepadAxis::RightStickX => self.right_x = clamped, + GamepadAxis::RightStickY => self.right_y = clamped, + GamepadAxis::LeftTrigger => self.left_trigger = clamped.clamp(0.0, 1.0), + GamepadAxis::RightTrigger => self.right_trigger = clamped.clamp(0.0, 1.0), + } + } + + /// Pack into the 8-byte input report matching [`super::descriptor`]. + #[must_use] + pub fn to_input_report(&self) -> [u8; 8] { + let lx = axis_to_u8(self.left_x); + let ly = axis_to_u8(self.left_y); + let rx = axis_to_u8(self.right_x); + let ry = axis_to_u8(self.right_y); + let lt = trigger_to_u8(self.left_trigger); + let rt = trigger_to_u8(self.right_trigger); + let hat = hat_nibble(self.dpad_x, self.dpad_y); + let btn = self.buttons; + #[expect( + clippy::cast_possible_truncation, + reason = "report packs the low 12 button bits into two bytes by design" + )] + { + [ + lx, + ly, + rx, + ry, + lt, + rt, + hat | (((btn & 0x0F) as u8) << 4), + (btn >> 4) as u8, + ] + } + } +} + +fn axis_to_u8(value: f32) -> u8 { + let scaled = ((value + 1.0) * 127.5).round().clamp(0.0, 255.0); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "scaled is clamped to 0..=255" + )] + { + scaled as u8 + } +} + +fn trigger_to_u8(value: f32) -> u8 { + let scaled = (value.clamp(0.0, 1.0) * 255.0).round(); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "scaled is clamped to 0..=255" + )] + { + scaled as u8 + } +} + +/// HID hat switch nibble: 0–7 directions, 8 = neutral. +fn hat_nibble(x: i8, y: i8) -> u8 { + match (x, y) { + (0, -1) => 0, + (1, -1) => 1, + (1, 0) => 2, + (1, 1) => 3, + (0, 1) => 4, + (-1, 1) => 5, + (-1, 0) => 6, + (-1, -1) => 7, + _ => 8, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn button_bits_follow_standard_indices() { + let mut state = GamepadState::default(); + state.set_button(GamepadFaceButton::A, true); + state.set_button(GamepadFaceButton::Start, true); + assert_eq!(state.buttons_mask() & 1, 1); + assert_eq!(state.buttons_mask() & (1 << 9), 1 << 9); + } + + #[test] + fn rumble_intensity_scales() { + assert_eq!( + Rumble { + strong: 0.5, + weak: 0.25 + } + .intensity_percent(), + 50 + ); + assert!(!Rumble::default().is_active()); + } +} diff --git a/crates/openlogi-gamepad/src/windows.rs b/crates/openlogi-gamepad/src/windows.rs new file mode 100644 index 000000000..8676fedaa --- /dev/null +++ b/crates/openlogi-gamepad/src/windows.rs @@ -0,0 +1,12 @@ +//! Windows virtual gamepad backend. +//! +//! ViGEmBus client wiring is intentionally deferred: without the bus this +//! returns [`GamepadError::DriverMissing`] so the agent fails soft and the +//! rest of the workspace still compiles on Windows CI. + +use crate::{GamepadError, VirtualGamepad}; + +/// Attempt to attach a ViGEm Xbox 360 target. +pub fn create(_product_name: &str) -> Result, GamepadError> { + Err(GamepadError::DriverMissing) +} diff --git a/docs/config.example.toml b/docs/config.example.toml index a506663de..f25113c63 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -1,5 +1,5 @@ # OpenLogi configuration example. Copy only the sections you need. -schema_version = 7 +schema_version = 8 selected_device = "receiver:aabbccdd:slot:1" [app_settings] @@ -52,6 +52,15 @@ Back = "Undo" [devices."receiver:aabbccdd:slot:1".per_app_bindings."exe:sharex.exe"] MiddleClick = { CustomShortcut = "F1" } +# Opt-in auxiliary virtual gamepad: pointer + L/R stay native; mapped extras +# (Back/Forward, gesture/haptic panel, thumb wheel, DPI) feed an OS-visible +# standard HID pad for browsers and games. Host rumble maps to device haptics +# (`0x19b0`) when the mouse supports it. macOS needs the agent signed with +# Apple's Virtual HID entitlement; Linux uses uinput; Windows needs ViGEmBus. +# [devices."receiver:aabbccdd:slot:1".gamepad] +# enabled = true +# rumble = true + [devices."receiver:aabbccdd:slot:1".action_ring] enabled = true haptics = true diff --git a/docs/gamepad.md b/docs/gamepad.md new file mode 100644 index 000000000..a198dbdfe --- /dev/null +++ b/docs/gamepad.md @@ -0,0 +1,35 @@ +# Virtual gamepad (auxiliary mouse → controller) + +OpenLogi can publish an opt-in OS-visible standard HID gamepad for pointing +devices that expose remappable extras (MX Master–class Back/Forward, gesture +button, haptic panel, thumb wheel). The physical pointer and primary clicks +stay native. + +## Enable + +```toml +[devices."unit:…".gamepad] +enabled = true +rumble = true +``` + +Reload config (GUI save or agent restart). Creation failures are logged; there +is no desktop toggle in this first cut. + +## Platform notes + +| OS | Backend | Runtime requirement | +|---|---|---| +| macOS | `IOHIDUserDevice` | Agent must be codesigned with `com.apple.developer.hid.virtual.device` (Apple-restricted). See [`OpenLogiAgent.entitlements`](../crates/openlogi-agent/bundle/OpenLogiAgent.entitlements). Without it, create returns a clear entitlement error. Host→device rumble is not wired on macOS yet (input still works). | +| Linux | `uinput` joystick (no FF yet) | User needs write access to `/dev/uinput` (same class of permission as OpenLogi's existing inject path). Host rumble via `FF_RUMBLE` is not advertised until the read path exists. | +| Windows | ViGEmBus (stub in this PR) | Install [ViGEmBus](https://github.com/nefarius/ViGEmBus); until the client is wired the agent soft-fails with "driver missing". | + +## Browser Gamepad API + +Chromium only exposes pads after a user gesture (press a mapped button). Expect +`mapping: "standard"` when the report descriptor usages are conventional. + +## Rumble → haptics + +When `rumble = true` and the device has HID++ `0x19b0`, host dual-rumble is +translated to `DampStateChange` / `SubtleCollision` waveforms.