From 60faa7f6e905f9ff011b52fdd06d14b82b934ebc Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 28 Oct 2026 09:20:25 +0000 Subject: [PATCH 1/4] Learn gamepad buttons 1 to 8 as switch keys Switch interfaces in gamepad mode (Joycable, Pretorian USB Switch, Crick USB Switch Box, Ultimate Switchbox) and game controllers such as the Xbox Adaptive Controller present as HID game controllers and send numbered buttons. Buttons 1 to 8 on any connected controller are now switch keys named Gamepad1 to Gamepad8, learned and used like keyboard keys. Windows registers the joystick and gamepad usages with Raw Input in the capture worker, so presses arrive in the background, and reads button state through the HID parser. macOS reads the same reports through IOHIDManager on the capture run loop; the GameController framework's background flag is not relied on. Buttons are never suppressed, a held button blocks re-enabling until released, and a removed controller releases its buttons. The synced settings schema moves to 6 so an older Switchify PC asks for an update instead of refusing the switches. Closes #1222 Co-Authored-By: Claude Fable 5.1 --- .../content/docs/get-started/add-a-switch.md | 10 +- site/src/content/docs/troubleshooting.md | 3 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/portable_settings.rs | 59 +++- src-tauri/src/switch_input/gamepad.rs | 78 +++++ src-tauri/src/switch_input/keys.rs | 45 ++- src-tauri/src/switch_input/macos.rs | 4 + src-tauri/src/switch_input/macos_gamepad.rs | 196 ++++++++++++ src-tauri/src/switch_input/mod.rs | 8 +- src-tauri/src/switch_input/windows.rs | 280 ++++++++++++++---- src-tauri/src/switch_input/windows_hook.rs | 86 +++++- src-tauri/src/switches.rs | 16 + src/help/demonstrations.ts | 4 +- src/help/keyAvailability.test.ts | 15 +- src/help/keyAvailability.ts | 11 +- src/settings/SwitchesSection.tsx | 6 +- 16 files changed, 745 insertions(+), 78 deletions(-) create mode 100644 src-tauri/src/switch_input/gamepad.rs create mode 100644 src-tauri/src/switch_input/macos_gamepad.rs diff --git a/site/src/content/docs/get-started/add-a-switch.md b/site/src/content/docs/get-started/add-a-switch.md index ac74973e..e311df42 100644 --- a/site/src/content/docs/get-started/add-a-switch.md +++ b/site/src/content/docs/get-started/add-a-switch.md @@ -1,9 +1,9 @@ --- title: Add a switch -description: Connect a jack or USB switch, let Switchify PC learn its key, and choose what a press and a hold do. +description: Connect a jack or USB switch, let Switchify PC learn its key or gamepad button, and choose what a press and a hold do. --- -Switchify PC sees a switch as a key press. Anything that sends a key when you press it will work: a switch interface with a 3.5 mm socket, a USB switch, or a keyboard key. +Switchify PC sees a switch as a key press or a gamepad button press. Anything that sends a key or a button when you press it will work: a switch interface with a 3.5 mm socket, a USB switch, a keyboard key, or a game controller. ## Connect the switch @@ -11,12 +11,14 @@ Switchify PC sees a switch as a key press. Anything that sends a key when you pr **A USB switch.** Connect it to a USB port. Plugging it in does not configure anything by itself; you still add it below. -**Choose the keyboard mode on your interface.** Most interfaces have a mode setting. Mouse-click and gamepad modes cannot be learned, so Switchify PC sees nothing when you press. In keyboard mode most interfaces send Space or Enter, and Switchify PC learns those. If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). Nothing else on the computer types those keys, so they never clash. F12 is reserved on Windows and cannot be used. +**Choose a keyboard or gamepad mode on your interface.** Most interfaces have a mode setting. Mouse-click modes cannot be learned, so Switchify PC sees nothing when you press. In keyboard mode most interfaces send Space or Enter, and Switchify PC learns those. If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). Nothing else on the computer types those keys, so they never clash. F12 is reserved on Windows and cannot be used. + +**Gamepad mode.** Interfaces such as the Smartbox Joycable, the Pretorian USB Switch and the Crick USB Switch Box can send gamepad buttons instead of keys, and so can a game controller such as the Xbox Adaptive Controller. Switchify PC learns buttons 1 to 8 and shows them as *Gamepad 1* to *Gamepad 8*. Button 1 on any connected controller counts as the same switch. Gamepad buttons never type anything, so they cannot clash with the keyboard. Sticks, triggers and the direction pad are not switches. ## Learn the key 1. Choose **Switches** in the sidebar, then **Add switch**. -2. Press and release your switch once. The key it sends appears as a badge. +2. Press and release your switch once. The key or gamepad button it sends appears as a badge. ![The learning dialog: Press and release your switch, with a Cancel capture button.](../../../assets/screenshots/add-switch-waiting.png) diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index 4025db56..97f288a5 100644 --- a/site/src/content/docs/troubleshooting.md +++ b/site/src/content/docs/troubleshooting.md @@ -7,7 +7,8 @@ Switchify PC's Help page has **Setup** and **Troubleshooting** tabs with the sam ## Nothing happens when I press the switch -- Check the mode on your switch interface. Mouse-click and gamepad modes cannot be learned; choose the keyboard mode. +- Check the mode on your switch interface. Mouse-click modes cannot be learned; choose a keyboard or gamepad mode. +- In gamepad mode only buttons 1 to 8 are learned. If the interface is set to a higher button, a stick or a direction, choose a lower button or the keyboard mode. - Make sure you have added the switch in Settings and it has an action. Automatic scanning needs Select; manual scanning also needs Next and Previous. - If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). F12 is reserved on Windows. - On Windows, if a switch was unplugged while held, plug it back in (or use the same key on another keyboard), then press and release it once. That press clears the stuck state and does nothing else; press Select to start again. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 414ad338..dbe85b28 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ objc2-core-graphics = { version = "0.3.2", default-features = false, features = objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_Devices_HumanInterfaceDevice"] } windows = { version = "0.62.2", features = [ "Devices_Bluetooth", "Devices_Bluetooth_GenericAttributeProfile", diff --git a/src-tauri/src/portable_settings.rs b/src-tauri/src/portable_settings.rs index d861aa71..92305975 100644 --- a/src-tauri/src/portable_settings.rs +++ b/src-tauri/src/portable_settings.rs @@ -16,7 +16,7 @@ use serde_json::Value; use std::collections::HashSet; use tauri::{AppHandle, Manager}; -pub const SCHEMA_VERSION: u32 = 5; +pub const SCHEMA_VERSION: u32 = 6; const MAX_CUSTOM_PROFILES: usize = 32; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -195,10 +195,11 @@ pub fn parse(mut value: Value, schema_version: u32) -> Result value = upgrade_v4(upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?)?, - 2 => value = upgrade_v4(upgrade_v3(upgrade_v2(value)?)?)?, - 3 => value = upgrade_v4(upgrade_v3(value)?)?, - 4 => value = upgrade_v4(value)?, + 1 => value = upgrade_v5(upgrade_v4(upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?)?)?, + 2 => value = upgrade_v5(upgrade_v4(upgrade_v3(upgrade_v2(value)?)?)?)?, + 3 => value = upgrade_v5(upgrade_v4(upgrade_v3(value)?)?)?, + 4 => value = upgrade_v5(upgrade_v4(value)?)?, + 5 => value = upgrade_v5(value)?, SCHEMA_VERSION => {} _ => { return Err(ParseError::Invalid(format!( @@ -292,6 +293,17 @@ fn upgrade_v4(mut value: Value) -> Result { .filter(|preferences| !preferences.contains_key("panelTheme")) .ok_or_else(|| ParseError::Invalid("it does not match version 4 settings".into()))? .insert("panelTheme".into(), Value::from("system")); + value["schemaVersion"] = Value::from(5); + Ok(value) +} + +/// Version 5 documents have the same shape, but a version 5 install rejects +/// gamepad buttons as switch keys. The bump makes such an install report +/// that it must update instead of refusing the synced switches. +fn upgrade_v5(mut value: Value) -> Result { + if value.get("schemaVersion") != Some(&Value::from(5)) { + return Err(ParseError::Invalid("version mismatch".into())); + } value["schemaVersion"] = Value::from(SCHEMA_VERSION); Ok(value) } @@ -665,6 +677,33 @@ mod tests { written_by(serde_json::to_value(local().document()).unwrap(), 1) } + #[test] + fn gamepad_switch_keys_sync_as_version_six() { + // Version 5 installs reject these keys, which is why the schema moved + // to 6: they see a newer document and ask for an update instead. + let local = local(); + let mut cloud = local.clone(); + cloud.switches.bindings = vec![ + binding("pad", "Gamepad1", Action::Select), + binding("next", "Gamepad8", Action::Next), + ]; + let value = serde_json::to_value(cloud.document()).unwrap(); + assert_eq!(value["schemaVersion"], json!(6)); + assert_eq!(value["switches"]["bindings"][0]["key"], json!("Gamepad1")); + let parsed = parse(value, SCHEMA_VERSION).unwrap(); + let switches = plan(&local, parsed).unwrap().switches.unwrap(); + assert_eq!(switches, cloud.switches); + // Button 9 is not a switch, so a document naming it is refused. + let mut bad = cloud.clone(); + bad.switches.bindings[0].key = "Gamepad9".into(); + let parsed = parse( + serde_json::to_value(bad.document()).unwrap(), + SCHEMA_VERSION, + ) + .unwrap(); + assert!(plan(&local, parsed).is_err()); + } + #[test] fn version_four_documents_read_with_panels_matching_the_system() { use crate::scan_preferences::PanelTheme; @@ -716,6 +755,16 @@ mod tests { PanelTheme::System ); + // A version 5 document climbs unchanged: nothing was added in 6. + let version_five = written_by(serde_json::to_value(cloud.document()).unwrap(), 5); + let parsed = parse(version_five.clone(), 5).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert_eq!(parsed.switches, cloud.switches); + assert!(matches!( + parse(version_five, SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + // A chosen theme syncs, and an unknown one is refused rather than reset. let mut chosen = serde_json::to_value(cloud.document()).unwrap(); chosen["pointScan"]["scanPreferences"]["panelTheme"] = json!("dark"); diff --git a/src-tauri/src/switch_input/gamepad.rs b/src-tauri/src/switch_input/gamepad.rs new file mode 100644 index 00000000..21f5d1a6 --- /dev/null +++ b/src-tauri/src/switch_input/gamepad.rs @@ -0,0 +1,78 @@ +//! Buttons 1 to 8 across every connected controller, so button 1 on any of +//! them is one switch. A button is down while any controller holds it. +use super::GAMEPAD_BUTTONS; +use std::collections::HashMap; + +#[derive(Default)] +pub(super) struct Gamepads { + devices: HashMap, + held: u8, +} +impl Gamepads { + /// A whole-state report: bit `n - 1` of `buttons` is button `n`. + /// Returns the button edges it produces, as (button, pressed). + pub fn report(&mut self, device: isize, buttons: u8) -> Vec<(u8, bool)> { + if buttons == 0 { + self.devices.remove(&device); + } else { + self.devices.insert(device, buttons); + } + self.settle() + } + /// One button of one controller changed. + #[cfg_attr(target_os = "windows", allow(dead_code))] + pub fn button(&mut self, device: isize, button: u8, pressed: bool) -> Vec<(u8, bool)> { + if !(1..=GAMEPAD_BUTTONS).contains(&button) { + return Vec::new(); + } + let bit = 1 << (button - 1); + let current = self.devices.get(&device).copied().unwrap_or(0); + let buttons = if pressed { + current | bit + } else { + current & !bit + }; + self.report(device, buttons) + } + /// A controller went away: its held buttons are released. + pub fn removed(&mut self, device: isize) -> Vec<(u8, bool)> { + self.devices.remove(&device); + self.settle() + } + fn settle(&mut self) -> Vec<(u8, bool)> { + let held = self.devices.values().fold(0u8, |acc, bits| acc | bits); + let changed = held ^ self.held; + self.held = held; + (1..=GAMEPAD_BUTTONS) + .filter(|button| changed & (1 << (button - 1)) != 0) + .map(|button| (button, held & (1 << (button - 1)) != 0)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn controllers_merge_and_release_on_removal() { + let mut pads = Gamepads::default(); + assert_eq!(pads.report(1, 0b0000_0001), vec![(1, true)]); + // A second controller holding the same button changes nothing. + assert_eq!(pads.report(2, 0b0000_0001), vec![]); + assert_eq!(pads.report(2, 0b1000_0001), vec![(8, true)]); + assert_eq!(pads.report(1, 0), vec![]); + assert_eq!(pads.removed(2), vec![(1, false), (8, false)]); + assert_eq!(pads.removed(2), vec![]); + } + #[test] + fn single_button_changes_build_the_same_state() { + let mut pads = Gamepads::default(); + assert_eq!(pads.button(7, 2, true), vec![(2, true)]); + assert_eq!(pads.button(7, 2, true), vec![]); + assert_eq!(pads.button(7, 5, true), vec![(5, true)]); + assert_eq!(pads.button(7, 2, false), vec![(2, false)]); + assert_eq!(pads.button(7, 9, true), vec![]); + assert_eq!(pads.button(7, 0, true), vec![]); + assert_eq!(pads.removed(7), vec![(5, false)]); + } +} diff --git a/src-tauri/src/switch_input/keys.rs b/src-tauri/src/switch_input/keys.rs index 8ebbc958..1a6c2922 100644 --- a/src-tauri/src/switch_input/keys.rs +++ b/src-tauri/src/switch_input/keys.rs @@ -1,4 +1,5 @@ -/// Canonical keyboard names used by embedded capture and persisted mappings. +/// Canonical key names used by embedded capture and persisted mappings: +/// keyboard keys and the first eight numbered buttons of any game controller. pub fn normalize(code: &str) -> Option { let value = match code { "Return" => "Enter", @@ -34,9 +35,51 @@ pub fn normalize(code: &str) -> Option { .strip_prefix('F') .and_then(|n| n.parse::().ok()) .is_some_and(|n| (1..=24).contains(&n) && value == format!("F{n}")) + || gamepad_button(value).is_some() { Some(value.into()) } else { None } } + +/// How many numbered controller buttons can be switches. Switch interfaces in +/// gamepad mode send buttons 1 to 4; eight covers every box found so far. +pub const GAMEPAD_BUTTONS: u8 = 8; + +/// The key name for controller button `button` (1-based). +pub fn gamepad_name(button: u8) -> String { + format!("Gamepad{button}") +} + +/// The 1-based button number of a gamepad key name, if it is one. +pub fn gamepad_button(name: &str) -> Option { + let n = name.strip_prefix("Gamepad")?.parse::().ok()?; + ((1..=GAMEPAD_BUTTONS).contains(&n) && name == gamepad_name(n)).then_some(n) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn gamepad_buttons_one_to_eight_are_keys_and_nothing_else_is() { + for n in 1..=GAMEPAD_BUTTONS { + let name = gamepad_name(n); + assert_eq!(normalize(&name).as_deref(), Some(name.as_str())); + assert_eq!(gamepad_button(&name), Some(n)); + } + for bad in [ + "Gamepad0", + "Gamepad9", + "Gamepad01", + "Gamepad", + "gamepad1", + "Gamepad1 ", + ] { + assert_eq!(normalize(bad), None, "{bad}"); + assert_eq!(gamepad_button(bad), None, "{bad}"); + } + assert_eq!(normalize("Return").as_deref(), Some("Enter")); + assert_eq!(normalize("F24").as_deref(), Some("F24")); + } +} diff --git a/src-tauri/src/switch_input/macos.rs b/src-tauri/src/switch_input/macos.rs index 1be89320..d0d9b2fb 100644 --- a/src-tauri/src/switch_input/macos.rs +++ b/src-tauri/src/switch_input/macos.rs @@ -133,6 +133,9 @@ impl Capture { }, || { driver.ready(pressed_keys()); + // Controller buttons share this run loop. Without HID + // access the keyboard tap still runs on its own. + let gamepads = super::macos_gamepad::Monitor::start(driver.clone()); if tx.send(Ok(())).is_err() { return; } @@ -146,6 +149,7 @@ impl Capture { } driver.tick(); } + drop(gamepads); }, ); if installed.is_err() { diff --git a/src-tauri/src/switch_input/macos_gamepad.rs b/src-tauri/src/switch_input/macos_gamepad.rs new file mode 100644 index 00000000..38b6f8e4 --- /dev/null +++ b/src-tauri/src/switch_input/macos_gamepad.rs @@ -0,0 +1,196 @@ +//! Controller buttons on macOS, read as HID reports through IOHIDManager on +//! the capture thread's run loop. The GameController framework needs a flag +//! for background delivery that stopped working in macOS 15.4; HID reports +//! arrive whichever app is in front and need no window. +use super::{gamepad::Gamepads, gamepad_name, Driver, GAMEPAD_BUTTONS}; +use core_foundation::{ + array::{CFArray, CFArrayRef}, + base::{kCFAllocatorDefault, CFAllocatorRef, CFRelease, CFType, TCFType}, + dictionary::{CFDictionary, CFDictionaryRef}, + number::CFNumber, + runloop::{kCFRunLoopDefaultMode, CFRunLoop, CFRunLoopRef}, + string::{CFString, CFStringRef}, +}; +use std::{cell::RefCell, ffi::c_void}; + +type IOHIDManagerRef = *mut c_void; +type IOHIDValueRef = *mut c_void; +type IOHIDElementRef = *mut c_void; +type IOHIDDeviceRef = *mut c_void; +type IOReturn = i32; +type ValueCallback = unsafe extern "C" fn(*mut c_void, IOReturn, *mut c_void, IOHIDValueRef); +type DeviceCallback = unsafe extern "C" fn(*mut c_void, IOReturn, *mut c_void, IOHIDDeviceRef); + +const USAGE_PAGE_GENERIC_DESKTOP: i32 = 1; +const USAGE_JOYSTICK: i32 = 4; +const USAGE_GAMEPAD: i32 = 5; +const USAGE_PAGE_BUTTON: u32 = 9; +const IO_RETURN_SUCCESS: IOReturn = 0; + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + fn IOHIDManagerCreate(allocator: CFAllocatorRef, options: u32) -> IOHIDManagerRef; + fn IOHIDManagerSetDeviceMatchingMultiple(manager: IOHIDManagerRef, multiple: CFArrayRef); + fn IOHIDManagerSetInputValueMatching(manager: IOHIDManagerRef, matching: CFDictionaryRef); + fn IOHIDManagerRegisterInputValueCallback( + manager: IOHIDManagerRef, + callback: Option, + context: *mut c_void, + ); + fn IOHIDManagerRegisterDeviceRemovalCallback( + manager: IOHIDManagerRef, + callback: Option, + context: *mut c_void, + ); + fn IOHIDManagerScheduleWithRunLoop( + manager: IOHIDManagerRef, + run_loop: CFRunLoopRef, + mode: CFStringRef, + ); + fn IOHIDManagerUnscheduleFromRunLoop( + manager: IOHIDManagerRef, + run_loop: CFRunLoopRef, + mode: CFStringRef, + ); + fn IOHIDManagerOpen(manager: IOHIDManagerRef, options: u32) -> IOReturn; + fn IOHIDManagerClose(manager: IOHIDManagerRef, options: u32) -> IOReturn; + fn IOHIDValueGetElement(value: IOHIDValueRef) -> IOHIDElementRef; + fn IOHIDValueGetIntegerValue(value: IOHIDValueRef) -> isize; + fn IOHIDElementGetUsagePage(element: IOHIDElementRef) -> u32; + fn IOHIDElementGetUsage(element: IOHIDElementRef) -> u32; + fn IOHIDElementGetDevice(element: IOHIDElementRef) -> IOHIDDeviceRef; +} + +struct Context { + driver: Driver, + pads: RefCell, +} +impl Context { + fn deliver(&self, edges: Vec<(u8, bool)>) { + for (button, pressed) in edges { + self.driver.key(&gamepad_name(button), pressed); + } + } +} + +unsafe extern "C" fn value_changed( + context: *mut c_void, + _result: IOReturn, + _sender: *mut c_void, + value: IOHIDValueRef, +) { + let context = &*(context as *const Context); + let element = IOHIDValueGetElement(value); + if element.is_null() || IOHIDElementGetUsagePage(element) != USAGE_PAGE_BUTTON { + return; + } + let usage = IOHIDElementGetUsage(element); + if !(1..=u32::from(GAMEPAD_BUTTONS)).contains(&usage) { + return; + } + let device = IOHIDElementGetDevice(element) as isize; + let pressed = IOHIDValueGetIntegerValue(value) != 0; + let edges = context + .pads + .borrow_mut() + .button(device, usage as u8, pressed); + context.deliver(edges); +} + +unsafe extern "C" fn device_removed( + context: *mut c_void, + _result: IOReturn, + _sender: *mut c_void, + device: IOHIDDeviceRef, +) { + let context = &*(context as *const Context); + let edges = context.pads.borrow_mut().removed(device as isize); + context.deliver(edges); +} + +fn usage_matching(usage: i32) -> CFType { + CFDictionary::from_CFType_pairs(&[ + ( + CFString::from_static_string("DeviceUsagePage"), + CFNumber::from(USAGE_PAGE_GENERIC_DESKTOP), + ), + ( + CFString::from_static_string("DeviceUsage"), + CFNumber::from(usage), + ), + ]) + .as_CFType() +} + +/// Controller monitoring scheduled on the calling thread's run loop. Drop it +/// on that thread; the callbacks are detached before the state is freed. +pub(super) struct Monitor { + manager: IOHIDManagerRef, + context: *mut Context, + run_loop: CFRunLoop, +} +impl Monitor { + /// `None` when HID access is unavailable; keyboard capture carries on. + pub fn start(driver: Driver) -> Option { + unsafe { + let manager = IOHIDManagerCreate(kCFAllocatorDefault, 0); + if manager.is_null() { + return None; + } + let devices = CFArray::from_CFTypes(&[ + usage_matching(USAGE_JOYSTICK), + usage_matching(USAGE_GAMEPAD), + ]); + IOHIDManagerSetDeviceMatchingMultiple(manager, devices.as_concrete_TypeRef()); + // Only button elements call back; sticks and triggers never reach us. + let buttons = CFDictionary::from_CFType_pairs(&[( + CFString::from_static_string("UsagePage"), + CFNumber::from(USAGE_PAGE_BUTTON as i32), + )]); + IOHIDManagerSetInputValueMatching(manager, buttons.as_concrete_TypeRef()); + let context = Box::into_raw(Box::new(Context { + driver, + pads: RefCell::new(Gamepads::default()), + })); + IOHIDManagerRegisterInputValueCallback(manager, Some(value_changed), context.cast()); + IOHIDManagerRegisterDeviceRemovalCallback( + manager, + Some(device_removed), + context.cast(), + ); + let run_loop = CFRunLoop::get_current(); + IOHIDManagerScheduleWithRunLoop( + manager, + run_loop.as_concrete_TypeRef(), + kCFRunLoopDefaultMode, + ); + let monitor = Self { + manager, + context, + run_loop, + }; + if IOHIDManagerOpen(manager, 0) != IO_RETURN_SUCCESS { + // Drop unschedules, closes and frees in the right order. + drop(monitor); + return None; + } + Some(monitor) + } + } +} +impl Drop for Monitor { + fn drop(&mut self) { + unsafe { + IOHIDManagerUnscheduleFromRunLoop( + self.manager, + self.run_loop.as_concrete_TypeRef(), + kCFRunLoopDefaultMode, + ); + IOHIDManagerRegisterInputValueCallback(self.manager, None, std::ptr::null_mut()); + IOHIDManagerRegisterDeviceRemovalCallback(self.manager, None, std::ptr::null_mut()); + IOHIDManagerClose(self.manager, 0); + CFRelease(self.manager); + drop(Box::from_raw(self.context)); + } + } +} diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 22009672..a97fe8e8 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -1,9 +1,12 @@ //! App-owned capture and generation-tagged switch edges. //! State and macOS adapter adapted from usahp-core under LICENSE-MIT in this directory. //! Native callbacks do bounded state work only; consumers drain typed events. +mod gamepad; mod keys; #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "macos")] +mod macos_gamepad; #[cfg(target_os = "windows")] mod windows; #[cfg(any(target_os = "windows", test))] @@ -15,7 +18,7 @@ mod windows_worker; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] mod windows_worker_protocol; use anyhow::{bail, Result}; -pub use keys::normalize as normalize_key; +pub use keys::{gamepad_button, gamepad_name, normalize as normalize_key, GAMEPAD_BUTTONS}; pub fn prediction_key_code(name: &str) -> Option { #[cfg(target_os = "windows")] { @@ -601,6 +604,9 @@ impl Drop for Capture { } } pub fn supported_key(code: &str) -> bool { + if gamepad_button(code).is_some() { + return cfg!(any(target_os = "windows", target_os = "macos")); + } #[cfg(target_os = "windows")] { code != "F12" && windows::code_for_name(code).is_some() diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs index d3ad0b0f..b3afc207 100644 --- a/src-tauri/src/switch_input/windows.rs +++ b/src-tauri/src/switch_input/windows.rs @@ -1,14 +1,22 @@ use super::{ - windows_hook::{Installation, NativeResources, Resource, Shared, State}, - Driver, Mode, StopReason, + gamepad::Gamepads, + windows_hook::{ + gamepad_code, is_gamepad_code, Installation, NativeResources, Resource, Shared, State, + }, + Driver, Mode, StopReason, GAMEPAD_BUTTONS, }; use anyhow::{bail, Result}; use std::{ cell::RefCell, + collections::HashMap, sync::{atomic::Ordering::*, mpsc, Arc}, time::Duration, }; use windows_sys::Win32::{ + Devices::HumanInterfaceDevice::{ + HidP_GetUsages, HidP_Input, HIDP_STATUS_SUCCESS, HID_USAGE_PAGE_BUTTON, + HID_USAGE_PAGE_GENERIC, + }, Foundation::*, System::{LibraryLoader::GetModuleHandleW, Threading::GetCurrentThreadId}, UI::{ @@ -17,6 +25,65 @@ use windows_sys::Win32::{ }, }; +/// HID top-level collections Raw Input delivers: keyboards, and the two +/// usages a game controller or a switch interface in gamepad mode declares. +const USAGE_KEYBOARD: u16 = 6; +const USAGE_JOYSTICK: u16 = 4; +const USAGE_GAMEPAD: u16 = 5; +/// Button usages beyond this cannot be switches, so no larger list is read. +const MAX_BUTTON_USAGES: usize = 64; + +/// The HID parser input for one controller, fetched once per device handle. +struct HidDevice { + preparsed: Vec, +} +impl HidDevice { + unsafe fn load(device: HANDLE) -> Option { + let mut size = 0u32; + if GetRawInputDeviceInfoW(device, RIDI_PREPARSEDDATA, std::ptr::null_mut(), &mut size) != 0 + || size == 0 + { + return None; + } + let mut preparsed = vec![0u8; size as usize]; + let read = GetRawInputDeviceInfoW( + device, + RIDI_PREPARSEDDATA, + preparsed.as_mut_ptr().cast(), + &mut size, + ); + (read != u32::MAX && read as usize == preparsed.len()).then_some(Self { preparsed }) + } + /// Bit `n - 1` set for each pressed button `n` in 1 to 8. + unsafe fn buttons(&self, report: *const u8, len: u32) -> Option { + let mut usages = [0u16; MAX_BUTTON_USAGES]; + let mut count = MAX_BUTTON_USAGES as u32; + let status = HidP_GetUsages( + HidP_Input, + HID_USAGE_PAGE_BUTTON, + 0, + usages.as_mut_ptr(), + &mut count, + self.preparsed.as_ptr() as isize, + report as *mut u8, + len, + ); + if status != HIDP_STATUS_SUCCESS { + return None; + } + Some(button_mask( + &usages[..(count as usize).min(MAX_BUTTON_USAGES)], + )) + } +} +/// The pressed-button bits for a list of pressed Button-page usages. +fn button_mask(usages: &[u16]) -> u8 { + usages + .iter() + .filter(|usage| (1..=u16::from(GAMEPAD_BUTTONS)).contains(usage)) + .fold(0u8, |mask, usage| mask | 1 << (usage - 1)) +} + pub fn code_for_name(name: &str) -> Option { Some(match name { "Space" => 32, @@ -72,16 +139,75 @@ fn known_keys() -> Vec<(u32, String)> { .collect(); names.extend(('A'..='Z').chain('0'..='9').map(|c| c.to_string())); names.extend((1..=24).filter(|f| *f != 12).map(|f| format!("F{f}"))); - names + let mut keys: Vec<(u32, String)> = names .into_iter() .map(|name| (code_for_name(&name).unwrap(), name)) - .collect() + .collect(); + keys.extend( + (1..=GAMEPAD_BUTTONS) + .map(|button| (gamepad_code(button) as u32, super::gamepad_name(button))), + ); + keys +} +/// Whether a key is physically down now. Controller buttons are only known +/// from their reports, so none counts as down at startup. +unsafe fn key_down(code: usize) -> bool { + !is_gamepad_code(code) && GetAsyncKeyState(code as i32) < 0 } struct Input { state: State, shared: Arc, started: std::time::Instant, + gamepads: Gamepads, + /// Controllers seen so far; `None` once a device's HID data could not be read. + devices: HashMap>, + /// Raw Input message storage, in `u64` so a `RAWINPUT` view is aligned. + raw: Vec, +} +impl Input { + fn now(&self) -> u64 { + self.started.elapsed().as_millis().min(u64::MAX as u128) as u64 + } + /// `len` is how many bytes of `raw` Windows filled in. + unsafe fn hid_report(&mut self, raw: *const RAWINPUT, len: usize) { + let device = (*raw).header.hDevice as isize; + let hid = &(*raw).data.hid; + let now = self.now(); + let entry = self + .devices + .entry(device) + .or_insert_with(|| HidDevice::load(device as HANDLE)); + let Some(hid_device) = entry else { + return; + }; + let offset = std::mem::offset_of!(RAWINPUT, data) + std::mem::offset_of!(RAWHID, bRawData); + let data = raw.cast::().add(offset); + let size = hid.dwSizeHid as usize; + if size == 0 || len < offset { + return; + } + // Never read past what was delivered, whatever the counts claim. + let count = (hid.dwCount as usize).min((len - offset) / size); + for index in 0..count { + let Some(mask) = hid_device.buttons(data.add(index * size), hid.dwSizeHid) else { + continue; + }; + for (button, pressed) in self.gamepads.report(device, mask) { + self.state.gamepad(&self.shared, button, pressed, now); + } + } + } + fn device_removed(&mut self, device: isize) { + let now = self.now(); + if self.devices.remove(&device).is_some() { + for (button, pressed) in self.gamepads.removed(device) { + self.state.gamepad(&self.shared, button, pressed, now); + } + } else { + self.state.device_removed(&self.shared, now); + } + } } thread_local! { static INPUT: RefCell> = const { RefCell::new(None) }; } unsafe extern "system" fn keyboard(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT { @@ -119,42 +245,67 @@ unsafe extern "system" fn keyboard(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT } unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { if msg == WM_INPUT { - let mut raw: RAWINPUT = std::mem::zeroed(); - let mut size = std::mem::size_of::() as u32; - let read = GetRawInputData( - lp as HRAWINPUT, - RID_INPUT, - &mut raw as *mut _ as *mut _, - &mut size, - std::mem::size_of::() as u32, - ); - if read != u32::MAX - && read >= std::mem::size_of::() as u32 - && raw.header.dwType == RIM_TYPEKEYBOARD - { - let key = raw.data.keyboard; - INPUT.with(|slot| { - if let Ok(mut slot) = slot.try_borrow_mut() { - if let Some(input) = slot.as_mut() { - let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64; - input.state.raw( - &input.shared, - key.VKey as u32, - key.Flags & RI_KEY_BREAK as u16 == 0, - crate::input::own_input(key.ExtraInformation as i64), - now, - ); - } + INPUT.with(|slot| { + let Ok(mut slot) = slot.try_borrow_mut() else { + return; + }; + let Some(input) = slot.as_mut() else { + return; + }; + let header = std::mem::size_of::() as u32; + // A controller report is variable-size, so the buffer is sized per message. + let mut size = 0u32; + if GetRawInputData( + lp as HRAWINPUT, + RID_INPUT, + std::ptr::null_mut(), + &mut size, + header, + ) == u32::MAX + || size < header + { + return; + } + let wanted = (size as usize) + .max(std::mem::size_of::()) + .div_ceil(std::mem::size_of::()); + if input.raw.len() < wanted { + input.raw.resize(wanted, 0); + } + let mut size = (input.raw.len() * std::mem::size_of::()) as u32; + let read = GetRawInputData( + lp as HRAWINPUT, + RID_INPUT, + input.raw.as_mut_ptr().cast(), + &mut size, + header, + ); + if read == u32::MAX || read < header { + return; + } + let raw = input.raw.as_ptr().cast::(); + match (*raw).header.dwType { + RIM_TYPEKEYBOARD => { + let key = (*raw).data.keyboard; + let now = input.now(); + input.state.raw( + &input.shared, + key.VKey as u32, + key.Flags & RI_KEY_BREAK as u16 == 0, + crate::input::own_input(key.ExtraInformation as i64), + now, + ); } - }); - } + RIM_TYPEHID => input.hid_report(raw, read as usize), + _ => {} + } + }); } if msg == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize { INPUT.with(|slot| { if let Ok(mut slot) = slot.try_borrow_mut() { if let Some(input) = slot.as_mut() { - let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64; - input.state.device_removed(&input.shared, now); + input.device_removed(lp); } } }); @@ -169,6 +320,24 @@ struct Resources { hwnd: HWND, module: HINSTANCE, hook: HHOOK, + /// Controller input is best effort: keyboard capture never depends on it. + gamepads: bool, +} +unsafe fn register_raw(hwnd: HWND, usages: &[u16], flags: u32) -> bool { + let devices: Vec = usages + .iter() + .map(|usage| RAWINPUTDEVICE { + usUsagePage: HID_USAGE_PAGE_GENERIC, + usUsage: *usage, + dwFlags: flags, + hwndTarget: hwnd, + }) + .collect(); + RegisterRawInputDevices( + devices.as_ptr(), + devices.len() as u32, + std::mem::size_of::() as u32, + ) != 0 } impl NativeResources for Resources { fn replace_hook(&mut self) -> bool { @@ -192,16 +361,13 @@ impl NativeResources for Resources { unsafe { match resource { Resource::RawInput => { - RegisterRawInputDevices( - &RAWINPUTDEVICE { - usUsagePage: 1, - usUsage: 6, - dwFlags: RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, - hwndTarget: self.hwnd, - }, - 1, - std::mem::size_of::() as u32, - ) != 0 + let flags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; + if !register_raw(self.hwnd, &[USAGE_KEYBOARD], flags) { + return false; + } + self.gamepads = + register_raw(self.hwnd, &[USAGE_JOYSTICK, USAGE_GAMEPAD], flags); + true } Resource::Hook => { self.hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard), self.module, 0); @@ -217,16 +383,14 @@ impl NativeResources for Resources { Resource::Timer => KillTimer(self.hwnd, 1) != 0, Resource::Hook => UnhookWindowsHookEx(self.hook) != 0, Resource::RawInput => { - RegisterRawInputDevices( - &RAWINPUTDEVICE { - usUsagePage: 1, - usUsage: 6, - dwFlags: RIDEV_REMOVE, - hwndTarget: std::ptr::null_mut(), - }, - 1, - std::mem::size_of::() as u32, - ) != 0 + let gamepads = !self.gamepads + || register_raw( + std::ptr::null_mut(), + &[USAGE_JOYSTICK, USAGE_GAMEPAD], + RIDEV_REMOVE, + ); + self.gamepads = false; + register_raw(std::ptr::null_mut(), &[USAGE_KEYBOARD], RIDEV_REMOVE) && gamepads } } } @@ -243,7 +407,7 @@ impl Capture { let mut names: [Option; 256] = std::array::from_fn(|_| None); let mut down = [false; 256]; for (vk, name) in known_keys() { - down[vk as usize] = unsafe { GetAsyncKeyState(vk as i32) < 0 }; + down[vk as usize] = unsafe { key_down(vk as usize) }; names[vk as usize] = Some(name); } let pressed = names @@ -380,12 +544,16 @@ unsafe fn run( state: State::new(mode, active, down), shared: shared.clone(), started: driver.started, + gamepads: Gamepads::default(), + devices: HashMap::new(), + raw: vec![0; std::mem::size_of::().div_ceil(std::mem::size_of::())], }) }); let mut resources = Resources { hwnd, module, hook: std::ptr::null_mut(), + gamepads: false, }; let mut installation = Installation::start(&mut resources); let startup_ready = installation.is_some() @@ -394,7 +562,7 @@ unsafe fn run( let input = slot.as_mut().unwrap(); let mut pressed = std::collections::HashSet::new(); for (code, name) in names.iter().enumerate() { - let down = GetAsyncKeyState(code as i32) < 0; + let down = key_down(code); input.state.refresh_key(code, down); if let Some(name) = name { if down { diff --git a/src-tauri/src/switch_input/windows_hook.rs b/src-tauri/src/switch_input/windows_hook.rs index b6bcb3cf..c36335ce 100644 --- a/src-tauri/src/switch_input/windows_hook.rs +++ b/src-tauri/src/switch_input/windows_hook.rs @@ -66,6 +66,15 @@ pub(super) fn await_shutdown( const CAPACITY: usize = 256; const ESCAPE: usize = 27; +/// Controller buttons travel the same edge queue as keys, at virtual-key +/// codes Windows leaves unassigned (0x97 to 0x9E). Button 1 is the first. +pub(super) const GAMEPAD_BASE: usize = 0x97; +pub(super) fn gamepad_code(button: u8) -> usize { + GAMEPAD_BASE + usize::from(button) - 1 +} +pub(super) fn is_gamepad_code(code: usize) -> bool { + (GAMEPAD_BASE..GAMEPAD_BASE + usize::from(super::GAMEPAD_BUTTONS)).contains(&code) +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct Edge { pub generation: u64, @@ -112,6 +121,13 @@ impl Shared { pub fn has_owned(&self) -> bool { self.owned.iter().any(|key| key.load(Acquire)) } + /// A key the hook is suppressing; controller buttons are never suppressed. + pub fn has_owned_key(&self) -> bool { + self.owned + .iter() + .enumerate() + .any(|(code, key)| !is_gamepad_code(code) && key.load(Acquire)) + } pub fn drained(&self) -> bool { !self.enabled.load(Acquire) && !self.recovering() && !self.has_owned() } @@ -294,10 +310,16 @@ impl State { /// suppressed. Other removals, such as an unrelated Bluetooth keyboard /// reconnecting, leave the hook and every switch intact. pub fn device_removed(&mut self, shared: &Shared, ms: u64) { - if shared.has_owned() { + if shared.has_owned_key() { self.interrupted(shared, ms); } } + /// A controller button edge from Raw Input. Nothing is suppressed and + /// there is no hook to cross-check, so it skips the hook bookkeeping. + pub fn gamepad(&mut self, shared: &Shared, button: u8, pressed: bool, ms: u64) { + let code = gamepad_code(button) as u32; + self.physical_key(shared, code, pressed, ms); + } pub fn interrupted(&mut self, shared: &Shared, ms: u64) { self.observations.clear(); self.quarantine = false; @@ -459,6 +481,68 @@ mod tests { ) } #[test] + fn gamepad_codes_sit_in_the_unassigned_virtual_key_range() { + assert_eq!(gamepad_code(1), 0x97); + assert!(is_gamepad_code(gamepad_code(super::super::GAMEPAD_BUTTONS))); + assert!(!is_gamepad_code( + gamepad_code(super::super::GAMEPAD_BUTTONS) + 1 + )); + assert!(!is_gamepad_code(32)); + } + #[test] + fn gamepad_buttons_learn_and_act_like_keys_without_hook_checks() { + for mode in [Mode::Learning, Mode::Active] { + let (mut state, shared, mut core, mut names) = setup(mode); + let code = gamepad_code(3); + names[code] = Some("Gamepad3".into()); + state.active[code] = true; + core.mappings.insert("Gamepad3".into(), "pad".into()); + state.gamepad(&shared, 3, true, 1); + assert!(shared.owned[code].load(Acquire)); + // No raw keyboard edge ever arrives for a button: that is not a lost hook. + assert!(!state.maintenance(&shared, 1400, 4000)); + assert!(!shared.recovering.load(Acquire)); + state.gamepad(&shared, 3, false, 1401); + core.last_heartbeat = 1401; + shared.drain_into(&mut core, &names, 1402); + match mode { + Mode::Learning => { + assert!(core + .events + .iter() + .any(|e| matches!(e, super::super::Event::Learned { code, .. } if code == "Gamepad3"))); + } + _ => { + assert_eq!( + core.events + .iter() + .filter(|e| matches!(e, super::super::Event::Switch { switch_id, .. } if switch_id == "pad")) + .count(), + 2 + ); + } + } + } + } + #[test] + fn a_held_button_does_not_turn_a_keyboard_removal_into_recovery() { + let (mut state, shared, mut core, mut names) = setup(Mode::Active); + let code = gamepad_code(1); + names[code] = Some("Gamepad1".into()); + state.active[code] = true; + core.mappings.insert("Gamepad1".into(), "pad".into()); + state.gamepad(&shared, 1, true, 1); + state.device_removed(&shared, 2); + shared.drain_into(&mut core, &names, 3); + assert_eq!(core.status.mode, Mode::Active); + assert!(!shared.recovering.load(Acquire)); + // A held button still blocks the next session until it is released. + shared.cancel(); + assert!(!shared.drained()); + state.gamepad(&shared, 1, false, 4); + assert!(shared.drained()); + } + #[test] fn device_removal_interrupts_only_while_a_switch_is_held() { let (mut state, shared, mut core, names) = setup(Mode::Active); state.device_removed(&shared, 1); diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs index d642ca3e..024fa768 100644 --- a/src-tauri/src/switches.rs +++ b/src-tauri/src/switches.rs @@ -174,4 +174,20 @@ mod tests { s.bindings[1].key = "Escape".into(); assert!(s.validate().is_err()); } + #[test] + fn gamepad_buttons_are_switch_keys_within_schema_one() { + let mut s = Settings::migrate(&crate::point_scan::Config::default()); + s.bindings[0].key = "Gamepad1".into(); + s.bindings[1].key = "Gamepad8".into(); + s.validate().unwrap(); + assert_eq!(s.schema_version, 1); + let value = serde_json::to_value(&s).unwrap(); + assert_eq!(value["bindings"][0]["key"], "Gamepad1"); + assert_eq!(serde_json::from_value::(value).unwrap(), s); + // One button, one switch, like keys. + s.bindings[1].key = "Gamepad1".into(); + assert!(s.validate().is_err()); + s.bindings[1].key = "Gamepad9".into(); + assert!(s.validate().is_err()); + } } diff --git a/src/help/demonstrations.ts b/src/help/demonstrations.ts index 63580725..91800e8f 100644 --- a/src/help/demonstrations.ts +++ b/src/help/demonstrations.ts @@ -7,7 +7,7 @@ export function demonstration(kind: DemonstrationKind, platform: DemoPlatform) { const highKeys = highKeyRange(platform); const examples = { jack: { title: "Connect a jack switch", steps: ["Check which connector and socket your switch interface requires. This illustration uses socket 1 as an example.", "Insert the compatible switch plug fully into the interface.", "Connect the interface to your computer, then use Add switch to learn the key it sends."] }, - interface: { title: "Choose a keyboard mode on your interface", steps: ["Most switch interfaces have a mode switch or setting. Mouse-click and gamepad modes cannot be learned as switches, so Switchify sees nothing when you press.", "Choose the keyboard mode. Most interfaces send Space and Enter in this mode, and Switchify learns those.", "Choose Add switch, then press and release your switch. The key it sends appears as a badge.", `Assigned keys stay reserved while Switchify runs. If your interface can be programmed, set it to ${highKeys}, which nothing else types.`] }, + interface: { title: "Choose a keyboard or gamepad mode on your interface", steps: ["Most switch interfaces have a mode switch or setting. Mouse-click modes cannot be learned as switches, so Switchify sees nothing when you press.", "Choose the keyboard mode or a gamepad mode. Most interfaces send Space and Enter in keyboard mode and buttons 1 to 4 in gamepad mode, and Switchify learns either.", "Choose Add switch, then press and release your switch. The key or button it sends appears as a badge.", `Assigned keys stay reserved while Switchify runs. If your interface can be programmed, set it to ${highKeys}, which nothing else types, or to a gamepad button, which never clashes with typing.`] }, usb: { title: "Connect a USB switch", steps: ["A USB switch or keyboard interface sends a key when you press the switch.", "Connect it to a compatible USB port on your computer.", "Choose Add switch and learn its key. Connecting the cable alone does not configure Switchify."] }, learn: { title: "Learn a switch key", steps: ["Choose Add switch, then Learn key to open the capture dialog.", "Press and release your physical switch once.", "The key it sends appears in the editor. F13 is only an example; your switch may send a different key."] }, select: { title: "Assign Select", steps: ["After learning a key, open its Press and release action list.", "Choose Select so a short press and release chooses the highlighted item.", "Save the switch. Automatic scanning needs Select; manual scanning also needs Next and Previous."] }, @@ -20,7 +20,7 @@ export function demonstration(kind: DemonstrationKind, platform: DemoPlatform) { }; const captions: Record = { jack: ["Check the connector.", "Plug in your switch.", "Connect the interface. Then add your switch."], - interface: ["Find the mode switch.", "Choose the keyboard mode.", "Press your switch to learn its key.", `Program ${highKeys} to avoid clashes.`], + interface: ["Find the mode switch.", "Choose keyboard or gamepad mode.", "Press your switch to learn its key.", `Program ${highKeys} or a gamepad button to avoid clashes.`], usb: ["Your switch sends a key.", "Plug it into your computer.", "Add your switch and learn its key."], learn: ["Choose Add switch, then Learn key.", "Press and release your switch.", "Check the key shown."], select: ["Open Press and release.", "Choose Select.", "Save your switch."], diff --git a/src/help/keyAvailability.test.ts b/src/help/keyAvailability.test.ts index 3613640c..a8589a32 100644 --- a/src/help/keyAvailability.test.ts +++ b/src/help/keyAvailability.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "vitest"; -import { keysNotLearned, reservedKeyAdvice, unavailableKeyReason } from "./keyAvailability"; +import { interfaceModeAdvice, keysNotLearned, reservedKeyAdvice, switchKeyLabel, unavailableKeyReason } from "./keyAvailability"; + +describe("switchKeyLabel", () => { + it("spells out controller buttons and leaves keys alone", () => { + expect(switchKeyLabel("Gamepad1")).toBe("Gamepad 1"); + expect(switchKeyLabel("Gamepad8")).toBe("Gamepad 8"); + expect(switchKeyLabel("Space")).toBe("Space"); + expect(switchKeyLabel("F13")).toBe("F13"); + }); + it("tells people gamepad mode is fine now", () => { + expect(interfaceModeAdvice).toMatch(/keyboard or gamepad mode/); + expect(interfaceModeAdvice).not.toMatch(/gamepad modes cannot/); + }); +}); describe("unavailableKeyReason", () => { it("names the Windows F12 reservation", () => { diff --git a/src/help/keyAvailability.ts b/src/help/keyAvailability.ts index 360d1af1..ee65fee6 100644 --- a/src/help/keyAvailability.ts +++ b/src/help/keyAvailability.ts @@ -19,11 +19,18 @@ export function keysNotLearned(platform?: DemoPlatform) { return null; } -export const interfaceModeAdvice = "Set your switch interface to a keyboard mode. Mouse-click and gamepad modes cannot be learned as switches."; +export const interfaceModeAdvice = "Set your switch interface to a keyboard or gamepad mode. Mouse-click modes cannot be learned as switches."; + +// How a saved key reads on a badge: keyboard keys by name, controller +// buttons as "Gamepad 1" to "Gamepad 8". +export function switchKeyLabel(key: string) { + const button = /^Gamepad([1-8])$/.exec(key); + return button ? `Gamepad ${button[1]}` : key; +} // The keys nothing else types: F13 and up, within what this platform can capture. export function highKeyRange(platform?: DemoPlatform) { return platform === "macos" ? "F13 to F20" : "F13 or higher"; } export function reservedKeyAdvice(platform?: DemoPlatform) { - return `Assigned keys stay reserved while Switchify runs. If your interface can be programmed, choose ${highKeyRange(platform)} so switch presses never clash with typing.`; + return `Assigned keys stay reserved while Switchify runs. If your interface can be programmed, choose ${highKeyRange(platform)} or a gamepad button so switch presses never clash with typing.`; } diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 7d4f0ac4..41930f34 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,7 +1,7 @@ import { Button, Input, Select, MoreOptions } from "../ui/controls"; import { Demonstration, SwitchDemonstrations } from "../help/Demonstration"; import type { DemoPlatform } from "../help/demonstrations"; -import { interfaceModeAdvice, keysNotLearned, reservedKeyAdvice, unavailableKeyReason } from "../help/keyAvailability"; +import { interfaceModeAdvice, keysNotLearned, reservedKeyAdvice, switchKeyLabel, unavailableKeyReason } from "../help/keyAvailability"; import { SwitchPractice } from "../scanning/SwitchPractice"; import { createPortal } from "react-dom"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; @@ -71,7 +71,7 @@ function ActionSelect({ function KeyBadge({ value, unavailable }: { value: string; unavailable: boolean }) { return value ? ( - {value} + {switchKeyLabel(value)} {unavailable && (unavailable on this computer)} ) : ( @@ -138,7 +138,7 @@ function SwitchEditor({ Name Date: Wed, 28 Oct 2026 09:28:31 +0000 Subject: [PATCH 2/4] Address review: classify controller removals, block on held buttons on macOS Windows now records controllers on arrival and at startup, so unplugging one that never reported is a controller removal and cannot send keyboard capture into recovery. The button usage list is sized from the device. macOS counts a button held while capture was off as held on begin, like a key. Held-key messages use the badge wording "Gamepad 1". The docs qualify the Xbox Adaptive Controller as checked on Windows. Co-Authored-By: Claude Fable 5.1 --- .../content/docs/get-started/add-a-switch.md | 2 +- src-tauri/src/switch_input/keys.rs | 11 +++ src-tauri/src/switch_input/mod.rs | 47 +++++++++- src-tauri/src/switch_input/windows.rs | 87 ++++++++++++++++--- 4 files changed, 130 insertions(+), 17 deletions(-) diff --git a/site/src/content/docs/get-started/add-a-switch.md b/site/src/content/docs/get-started/add-a-switch.md index e311df42..270513cb 100644 --- a/site/src/content/docs/get-started/add-a-switch.md +++ b/site/src/content/docs/get-started/add-a-switch.md @@ -13,7 +13,7 @@ Switchify PC sees a switch as a key press or a gamepad button press. Anything th **Choose a keyboard or gamepad mode on your interface.** Most interfaces have a mode setting. Mouse-click modes cannot be learned, so Switchify PC sees nothing when you press. In keyboard mode most interfaces send Space or Enter, and Switchify PC learns those. If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). Nothing else on the computer types those keys, so they never clash. F12 is reserved on Windows and cannot be used. -**Gamepad mode.** Interfaces such as the Smartbox Joycable, the Pretorian USB Switch and the Crick USB Switch Box can send gamepad buttons instead of keys, and so can a game controller such as the Xbox Adaptive Controller. Switchify PC learns buttons 1 to 8 and shows them as *Gamepad 1* to *Gamepad 8*. Button 1 on any connected controller counts as the same switch. Gamepad buttons never type anything, so they cannot clash with the keyboard. Sticks, triggers and the direction pad are not switches. +**Gamepad mode.** Interfaces such as the Smartbox Joycable, the Pretorian USB Switch and the Crick USB Switch Box can send gamepad buttons instead of keys, and so can a game controller, such as the Xbox Adaptive Controller on Windows. Switchify PC learns buttons 1 to 8 and shows them as *Gamepad 1* to *Gamepad 8*. Button 1 on any connected controller counts as the same switch. Gamepad buttons never type anything, so they cannot clash with the keyboard. Sticks, triggers and the direction pad are not switches. ## Learn the key diff --git a/src-tauri/src/switch_input/keys.rs b/src-tauri/src/switch_input/keys.rs index 1a6c2922..37a0f132 100644 --- a/src-tauri/src/switch_input/keys.rs +++ b/src-tauri/src/switch_input/keys.rs @@ -52,6 +52,15 @@ pub fn gamepad_name(button: u8) -> String { format!("Gamepad{button}") } +/// How a key reads in a message: keyboard keys by name, controller buttons +/// as "Gamepad 1" to "Gamepad 8", matching the badges in Settings. +pub fn display_name(code: &str) -> String { + match gamepad_button(code) { + Some(n) => format!("Gamepad {n}"), + None => code.to_string(), + } +} + /// The 1-based button number of a gamepad key name, if it is one. pub fn gamepad_button(name: &str) -> Option { let n = name.strip_prefix("Gamepad")?.parse::().ok()?; @@ -79,6 +88,8 @@ mod tests { assert_eq!(normalize(bad), None, "{bad}"); assert_eq!(gamepad_button(bad), None, "{bad}"); } + assert_eq!(display_name("Gamepad3"), "Gamepad 3"); + assert_eq!(display_name("Space"), "Space"); assert_eq!(normalize("Return").as_deref(), Some("Enter")); assert_eq!(normalize("F24").as_deref(), Some("F24")); } diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index a97fe8e8..9dc23dd3 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -203,7 +203,10 @@ impl Core { keys.sort(); bail!( "Release held keys before starting capture: {}.", - keys.join(", ") + keys.iter() + .map(|k| keys::display_name(k)) + .collect::>() + .join(", ") ); } self.begin(mode, now); @@ -486,7 +489,10 @@ impl Capture { held.dedup(); bail!( "Release held keys before starting capture: {}.", - held.join(", ") + held.iter() + .map(|k| keys::display_name(k)) + .collect::>() + .join(", ") ); } if let Some(native) = &self.native { @@ -501,9 +507,17 @@ impl Capture { self.ensure_native()?; let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); #[cfg(target_os = "macos")] - let down = macos::pressed_keys(); + let mut down = macos::pressed_keys(); #[cfg(not(target_os = "macos"))] - let down = core.down.clone(); + let mut down = core.down.clone(); + // The system reports held keys; controller buttons are known only + // from their reports, which the persistent monitor keeps tracking. + down.extend( + core.down + .iter() + .filter(|key| gamepad_button(key).is_some()) + .cloned(), + ); core.begin_with_pressed_keys(mode, self.driver.now(), down) } } @@ -867,6 +881,31 @@ mod tests { assert!(c.events.is_empty()); } #[test] + fn a_button_held_while_off_blocks_startup_under_its_display_name() { + // macOS keeps the controller monitor across sessions, so a press + // seen while capture is off is remembered and reported on begin. + let mut c = Core::default(); + c.mappings.insert("Gamepad1".into(), "select".into()); + assert!(!c.key("Gamepad1", true, 0)); + let held = c + .down + .iter() + .filter(|key| gamepad_button(key).is_some()) + .cloned() + .collect::>(); + let error = c + .begin_with_pressed_keys(Mode::Active, 1, held) + .unwrap_err(); + assert_eq!( + error.to_string(), + "Release held keys before starting capture: Gamepad 1." + ); + assert!(!c.key("Gamepad1", false, 2)); + c.begin_with_pressed_keys(Mode::Active, 3, HashSet::new()) + .unwrap(); + assert!(c.key("Gamepad1", true, 4)); + } + #[test] fn learning_ignores_preheld_keys_until_a_fresh_gesture() { for fresh in ["Space", "A"] { let mut c = Core::default(); diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs index b3afc207..969572d4 100644 --- a/src-tauri/src/switch_input/windows.rs +++ b/src-tauri/src/switch_input/windows.rs @@ -14,8 +14,8 @@ use std::{ }; use windows_sys::Win32::{ Devices::HumanInterfaceDevice::{ - HidP_GetUsages, HidP_Input, HIDP_STATUS_SUCCESS, HID_USAGE_PAGE_BUTTON, - HID_USAGE_PAGE_GENERIC, + HidP_GetUsages, HidP_Input, HidP_MaxUsageListLength, HIDP_STATUS_SUCCESS, + HID_USAGE_PAGE_BUTTON, HID_USAGE_PAGE_GENERIC, }, Foundation::*, System::{LibraryLoader::GetModuleHandleW, Threading::GetCurrentThreadId}, @@ -30,12 +30,34 @@ use windows_sys::Win32::{ const USAGE_KEYBOARD: u16 = 6; const USAGE_JOYSTICK: u16 = 4; const USAGE_GAMEPAD: u16 = 5; -/// Button usages beyond this cannot be switches, so no larger list is read. -const MAX_BUTTON_USAGES: usize = 64; +/// Button usages beyond this cannot be switches, so no larger list is kept. +const MAX_BUTTON_USAGES: usize = 256; + +/// Whether a Raw Input device is a game controller: an HID collection on +/// the joystick or gamepad usage. Keyboards and mice answer no. +unsafe fn is_controller(device: HANDLE) -> bool { + let mut info: RID_DEVICE_INFO = std::mem::zeroed(); + info.cbSize = std::mem::size_of::() as u32; + let mut size = info.cbSize; + let read = GetRawInputDeviceInfoW( + device, + RIDI_DEVICEINFO, + (&mut info as *mut RID_DEVICE_INFO).cast(), + &mut size, + ); + if read == u32::MAX || read == 0 || info.dwType != RIM_TYPEHID { + return false; + } + let hid = info.Anonymous.hid; + hid.usUsagePage == HID_USAGE_PAGE_GENERIC + && (hid.usUsage == USAGE_JOYSTICK || hid.usUsage == USAGE_GAMEPAD) +} /// The HID parser input for one controller, fetched once per device handle. struct HidDevice { preparsed: Vec, + /// Room for every Button-page usage the device can report at once. + usages: Vec, } impl HidDevice { unsafe fn load(device: HANDLE) -> Option { @@ -52,17 +74,26 @@ impl HidDevice { preparsed.as_mut_ptr().cast(), &mut size, ); - (read != u32::MAX && read as usize == preparsed.len()).then_some(Self { preparsed }) + if read == u32::MAX || read as usize != preparsed.len() { + return None; + } + let buttons = HidP_MaxUsageListLength( + HidP_Input, + HID_USAGE_PAGE_BUTTON, + preparsed.as_ptr() as isize, + ); + // A device with no buttons is remembered as a controller but reports nothing. + let usages = vec![0u16; (buttons as usize).clamp(1, MAX_BUTTON_USAGES)]; + Some(Self { preparsed, usages }) } /// Bit `n - 1` set for each pressed button `n` in 1 to 8. - unsafe fn buttons(&self, report: *const u8, len: u32) -> Option { - let mut usages = [0u16; MAX_BUTTON_USAGES]; - let mut count = MAX_BUTTON_USAGES as u32; + unsafe fn buttons(&mut self, report: *const u8, len: u32) -> Option { + let mut count = self.usages.len() as u32; let status = HidP_GetUsages( HidP_Input, HID_USAGE_PAGE_BUTTON, 0, - usages.as_mut_ptr(), + self.usages.as_mut_ptr(), &mut count, self.preparsed.as_ptr() as isize, report as *mut u8, @@ -72,7 +103,7 @@ impl HidDevice { return None; } Some(button_mask( - &usages[..(count as usize).min(MAX_BUTTON_USAGES)], + &self.usages[..(count as usize).min(self.usages.len())], )) } } @@ -198,6 +229,33 @@ impl Input { } } } + /// Remembers a controller as soon as it is present, so unplugging one + /// that never reported is still a controller removal, not a keyboard one. + unsafe fn device_arrived(&mut self, device: isize) { + if is_controller(device as HANDLE) { + self.devices + .entry(device) + .or_insert_with(|| HidDevice::load(device as HANDLE)); + } + } + /// Every controller connected right now. + unsafe fn enumerate_devices(&mut self) { + let mut count = 0u32; + let size = std::mem::size_of::() as u32; + if GetRawInputDeviceList(std::ptr::null_mut(), &mut count, size) == u32::MAX || count == 0 { + return; + } + let mut list: Vec = vec![std::mem::zeroed(); count as usize]; + let read = GetRawInputDeviceList(list.as_mut_ptr(), &mut count, size); + if read == u32::MAX { + return; + } + for entry in list.iter().take((read as usize).min(list.len())) { + if entry.dwType == RIM_TYPEHID { + self.device_arrived(entry.hDevice as isize); + } + } + } fn device_removed(&mut self, device: isize) { let now = self.now(); if self.devices.remove(&device).is_some() { @@ -301,11 +359,15 @@ unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) - } }); } - if msg == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize { + if msg == WM_INPUT_DEVICE_CHANGE { INPUT.with(|slot| { if let Ok(mut slot) = slot.try_borrow_mut() { if let Some(input) = slot.as_mut() { - input.device_removed(lp); + match wp as u32 { + GIDC_ARRIVAL => input.device_arrived(lp), + GIDC_REMOVAL => input.device_removed(lp), + _ => {} + } } } }); @@ -560,6 +622,7 @@ unsafe fn run( && INPUT.with(|slot| { let mut slot = slot.borrow_mut(); let input = slot.as_mut().unwrap(); + input.enumerate_devices(); let mut pressed = std::collections::HashSet::new(); for (code, name) in names.iter().enumerate() { let down = key_down(code); From 627ad7da16d32447b45274edd1069e0223684829 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 28 Oct 2026 09:35:05 +0000 Subject: [PATCH 3/4] Address second review: hook ignores controller codes, shutdown message The keyboard hook rejects virtual-key codes in the controller range, so a keyboard reporting an unassigned code can never be learned or suppressed as a button. The shutdown-wait message names held buttons the way the badges do. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/switch_input/windows_hook.rs | 13 ++++++++++++- src-tauri/src/switch_input/windows_worker.rs | 5 ++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/switch_input/windows_hook.rs b/src-tauri/src/switch_input/windows_hook.rs index c36335ce..b2c5c34f 100644 --- a/src-tauri/src/switch_input/windows_hook.rs +++ b/src-tauri/src/switch_input/windows_hook.rs @@ -381,7 +381,9 @@ impl State { generated: bool, ms: u64, ) -> bool { - if generated || code >= 256 { + // A keyboard reporting an unassigned code in the controller range is + // not a button: those only ever come from Raw Input HID reports. + if generated || code >= 256 || is_gamepad_code(code as usize) { return false; } let consumed = self.physical_key(shared, code, pressed, ms); @@ -481,6 +483,15 @@ mod tests { ) } #[test] + fn the_keyboard_hook_never_treats_a_controller_code_as_a_key() { + let (mut state, shared, _, _) = setup(Mode::Learning); + let code = gamepad_code(1); + state.active[code] = true; + assert!(!state.key(&shared, code as u32, true, false, 1)); + assert!(!shared.owned[code].load(Acquire)); + assert!(state.observations.is_empty()); + } + #[test] fn gamepad_codes_sit_in_the_unassigned_virtual_key_range() { assert_eq!(gamepad_code(1), 0x97); assert!(is_gamepad_code(gamepad_code(super::super::GAMEPAD_BUTTONS))); diff --git a/src-tauri/src/switch_input/windows_worker.rs b/src-tauri/src/switch_input/windows_worker.rs index 2694a1f3..f1f8aff1 100644 --- a/src-tauri/src/switch_input/windows_worker.rs +++ b/src-tauri/src/switch_input/windows_worker.rs @@ -298,7 +298,10 @@ impl Capture { if !held.is_empty() { bail!( "Release held keys before starting capture: {}.", - held.join(", ") + held.iter() + .map(|k| super::keys::display_name(k)) + .collect::>() + .join(", ") ); } Ok(self.shared.finished.load(Acquire)) From 4fa40f07da8be8b6a0a9e309b3286770b4038fcd Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 28 Oct 2026 09:37:03 +0000 Subject: [PATCH 4/4] Ignore controller codes on the raw keyboard path too A keyboard reporting an unassigned code in the controller range leaves no observation, so it can never read as a lost hook. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/switch_input/windows_hook.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/switch_input/windows_hook.rs b/src-tauri/src/switch_input/windows_hook.rs index b2c5c34f..530fb0b3 100644 --- a/src-tauri/src/switch_input/windows_hook.rs +++ b/src-tauri/src/switch_input/windows_hook.rs @@ -302,7 +302,9 @@ impl State { } } pub fn raw(&mut self, shared: &Shared, code: u32, pressed: bool, generated: bool, ms: u64) { - if !generated { + // The hook never sees controller codes, so a keyboard reporting one + // must not leave an observation that later reads as a lost hook. + if !generated && !is_gamepad_code(code as usize) { self.observe(shared, code, pressed, true, ms); } } @@ -490,6 +492,11 @@ mod tests { assert!(!state.key(&shared, code as u32, true, false, 1)); assert!(!shared.owned[code].load(Acquire)); assert!(state.observations.is_empty()); + // Nor can a raw keyboard report of that code look like a lost hook. + state.raw(&shared, code as u32, true, false, 2); + assert!(state.observations.is_empty()); + assert!(!state.maintenance(&shared, 2 + HEARTBEAT_TIMEOUT_MS, 4000)); + assert!(!shared.recovering.load(Acquire)); } #[test] fn gamepad_codes_sit_in_the_unassigned_virtual_key_range() {