Skip to main content

retch_sysinfo/
gamepad.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Gamepad and joystick controller detection.
5
6#[cfg(target_os = "macos")]
7pub fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
8    let mut gamepads = Vec::new();
9    let keywords = [
10        "controller",
11        "gamepad",
12        "joystick",
13        "xbox",
14        "playstation",
15        "dualshock",
16        "dualsense",
17        "nintendo",
18        "joy-con",
19        "joycon",
20    ];
21
22    let is_gamepad = |name: &str| -> bool {
23        let name_lower = name.to_lowercase();
24        keywords.iter().any(|&kw| name_lower.contains(kw))
25    };
26
27    // Parse USB
28    for line in usb_stdout.lines() {
29        let trimmed = line.trim();
30        let indent = line.len() - line.trim_start().len();
31        if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
32            let name = trimmed.trim_end_matches(':').trim().to_string();
33            if is_gamepad(&name) && !gamepads.contains(&name) {
34                gamepads.push(name);
35            }
36        }
37    }
38
39    // Parse Bluetooth
40    let mut current_device = None;
41    for line in bt_stdout.lines() {
42        let trimmed = line.trim();
43        let indent = line.len() - line.trim_start().len();
44        if indent >= 8 && trimmed.ends_with(':') {
45            current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
46        } else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
47            if let Some(ref dev) = current_device {
48                if is_gamepad(dev) && !gamepads.contains(dev) {
49                    gamepads.push(dev.clone());
50                }
51            }
52        }
53    }
54
55    gamepads
56}
57
58pub(crate) fn detect_gamepad() -> Vec<String> {
59    #[cfg(target_os = "linux")]
60    {
61        let mut gamepads = Vec::new();
62        if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
63            for entry in entries.filter_map(|e| e.ok()) {
64                let name = entry.file_name().to_string_lossy().to_string();
65                if name.starts_with("js") {
66                    let path = entry.path().join("device/name");
67                    if path.exists() {
68                        if let Ok(dev_name) = std::fs::read_to_string(path) {
69                            let trimmed = dev_name.trim().to_string();
70                            if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
71                                gamepads.push(trimmed);
72                            }
73                        }
74                    }
75                }
76            }
77        }
78        gamepads
79    }
80
81    #[cfg(target_os = "macos")]
82    {
83        crate::macos_ffi::get_hid_gamepads()
84    }
85
86    #[cfg(target_os = "windows")]
87    {
88        let cmd = "Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Class -eq 'HIDClass' -and ($_.HardwareID -match 'HID_DEVICE_SYSTEM_GAME' -or $_.HardwareID -match 'HID_DEVICE_GAME') -or $_.FriendlyName -match 'Xbox Controller|Gamepad|Joystick' } | Select-Object -ExpandProperty FriendlyName";
89        if let Ok(output) = std::process::Command::new("powershell")
90            .args(["-Command", cmd])
91            .output()
92        {
93            if let Ok(stdout) = String::from_utf8(output.stdout) {
94                let mut gamepads = Vec::new();
95                for line in stdout.lines() {
96                    let trimmed = line.trim().to_string();
97                    if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
98                        gamepads.push(trimmed);
99                    }
100                }
101                return gamepads;
102            }
103        }
104        Vec::new()
105    }
106
107    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
108    {
109        Vec::new()
110    }
111}
112
113#[cfg(all(test, target_os = "macos"))]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_parse_macos_gamepad() {
119        let usb_sample = "USB 3.1 Bus:\n\n    Xbox Wireless Controller:\n\n      Product ID: 0x02e0\n      Vendor ID: 0x045e\n";
120        let bt_sample = "Bluetooth:\n\n      Devices (Connected):\n          DualSense Wireless Controller:\n              Address: AA-BB-CC\n              Connected: Yes\n";
121        let parsed = parse_macos_gamepad(usb_sample, bt_sample);
122        assert_eq!(
123            parsed,
124            vec![
125                "Xbox Wireless Controller".to_string(),
126                "DualSense Wireless Controller".to_string()
127            ]
128        );
129    }
130}