#[cfg(target_os = "macos")]
pub fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
let mut gamepads = Vec::new();
let keywords = [
"controller",
"gamepad",
"joystick",
"xbox",
"playstation",
"dualshock",
"dualsense",
"nintendo",
"joy-con",
"joycon",
];
let is_gamepad = |name: &str| -> bool {
let name_lower = name.to_lowercase();
keywords.iter().any(|&kw| name_lower.contains(kw))
};
for line in usb_stdout.lines() {
let trimmed = line.trim();
let indent = line.len() - line.trim_start().len();
if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
let name = trimmed.trim_end_matches(':').trim().to_string();
if is_gamepad(&name) && !gamepads.contains(&name) {
gamepads.push(name);
}
}
}
let mut current_device = None;
for line in bt_stdout.lines() {
let trimmed = line.trim();
let indent = line.len() - line.trim_start().len();
if indent >= 8 && trimmed.ends_with(':') {
current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
} else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
if let Some(ref dev) = current_device {
if is_gamepad(dev) && !gamepads.contains(dev) {
gamepads.push(dev.clone());
}
}
}
}
gamepads
}
pub(crate) fn detect_gamepad() -> Vec<String> {
#[cfg(target_os = "linux")]
{
let mut gamepads = Vec::new();
if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("js") {
let path = entry.path().join("device/name");
if path.exists() {
if let Ok(dev_name) = std::fs::read_to_string(path) {
let trimmed = dev_name.trim().to_string();
if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
gamepads.push(trimmed);
}
}
}
}
}
}
gamepads
}
#[cfg(target_os = "macos")]
{
crate::macos_ffi::get_hid_gamepads()
}
#[cfg(target_os = "windows")]
{
use crate::win_setupapi::{present_devices_all_classes, GUID_DEVCLASS_HIDCLASS};
let mut gamepads: Vec<String> = Vec::new();
for device in present_devices_all_classes() {
let Some(name) = device.name.as_deref() else {
continue;
};
let is_hid = device.in_class(&GUID_DEVCLASS_HIDCLASS);
if is_windows_gamepad(is_hid, &device.hardware_ids, name)
&& !gamepads.iter().any(|g| g == name)
{
gamepads.push(name.to_string());
}
}
gamepads
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Vec::new()
}
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn is_windows_gamepad(is_hid_class: bool, hardware_ids: &[String], name: &str) -> bool {
let has_game_id = hardware_ids.iter().any(|id| {
let id = id.to_ascii_uppercase();
id.contains("HID_DEVICE_SYSTEM_GAME") || id.contains("HID_DEVICE_GAME")
});
let name = name.to_lowercase();
let named = ["xbox controller", "gamepad", "joystick"]
.iter()
.any(|kw| name.contains(kw));
(is_hid_class && has_game_id) || named
}
#[cfg(test)]
mod windows_predicate_tests {
use super::is_windows_gamepad;
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn rejects_the_hid_controllers_that_are_not_gamepads() {
let multi_axis = ids(&[
r"HID\VEN_ASUS&DEV_2020&Col01",
r"HID\ASUS2020&Col01",
r"HID\*ASUS2020&Col01",
r"HID\VID_0B05&UP:0001_U:000E",
"HID_DEVICE_UP:0001_U:000E",
"HID_DEVICE",
]);
let system_control = ids(&[
r"HID\ConvertedDevice&Col03",
r"HID\VID_045E&UP:0001_U:0080",
"HID_DEVICE_SYSTEM_CONTROL",
"HID_DEVICE_UP:0001_U:0080",
"HID_DEVICE",
]);
assert!(!is_windows_gamepad(
true,
&multi_axis,
"HID-compliant system multi-axis controller"
));
assert!(!is_windows_gamepad(
true,
&system_control,
"HID-compliant system controller"
));
}
#[test]
fn accepts_a_hid_game_collection() {
let gamepad = ids(&[
r"HID\VID_054C&PID_0CE6&MI_03",
r"HID\VID_054C&UP:0001_U:0005",
"HID_DEVICE_SYSTEM_GAME",
"HID_DEVICE_UP:0001_U:0005",
"HID_DEVICE",
]);
assert!(is_windows_gamepad(
true,
&gamepad,
"HID-compliant game controller"
));
assert!(is_windows_gamepad(
true,
&ids(&["hid_device_system_game"]),
"HID-compliant game controller"
));
}
#[test]
fn the_hardware_id_match_requires_hidclass() {
assert!(!is_windows_gamepad(
false,
&ids(&["HID_DEVICE_SYSTEM_GAME"]),
"Some Composite Device"
));
}
#[test]
fn names_match_in_any_class_case_insensitively() {
assert!(is_windows_gamepad(false, &[], "Xbox Controller"));
assert!(is_windows_gamepad(false, &[], "Generic USB GAMEPAD"));
assert!(is_windows_gamepad(false, &[], "Thrustmaster Joystick"));
assert!(!is_windows_gamepad(false, &[], "USB Input Device"));
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn test_parse_macos_gamepad() {
let usb_sample = "USB 3.1 Bus:\n\n Xbox Wireless Controller:\n\n Product ID: 0x02e0\n Vendor ID: 0x045e\n";
let bt_sample = "Bluetooth:\n\n Devices (Connected):\n DualSense Wireless Controller:\n Address: AA-BB-CC\n Connected: Yes\n";
let parsed = parse_macos_gamepad(usb_sample, bt_sample);
assert_eq!(
parsed,
vec![
"Xbox Wireless Controller".to_string(),
"DualSense Wireless Controller".to_string()
]
);
}
}