feat(macos): virtual HID gamepad emulating Razer Serval#5171
Conversation
Add gamepad support to the macOS platform backend by publishing a virtual HID device via IOHIDUserDeviceCreate, translating Moonlight controller state into HID reports. The device emulates a Razer Serval (VID 0x1532 / PID 0x0900) because it has a built-in SDL GameControllerDB entry with analog triggers under a vendor ID that SDL's HIDAPI driver never claims. As a result the controller is auto-recognized as a gamepad (isGameController/is_gamepad=1) everywhere — native Steam (no setup prompt), SDL, Wine DirectInput, and winexinput (XInput games under CrossOver) — while still exposing full analog triggers. Report mapping: - HID descriptor: 11 buttons, HAT d-pad, 6x 16-bit axes. Axis HID usages are assigned so SDL's usage-sorted indices match the Serval mapping (X->LX, Y->LY, Z->RX, Rx->RY, Ry->RT, Rz->LT); the report struct field order matches. - Button bits follow the Serval layout (a:b0 b:b1 x:b2 y:b3 ...). - Triggers scale 0..255 -> full signed range so SDL reads them as full-range analog axes. Stick Y axes are negated, with INT16_MIN clamped to INT16_MAX so a fully-deflected axis does not wrap to the wrong extreme. - The state->report mapping is a pure function in input_gamepad.h so it can be unit-tested without a real IOHIDUserDevice (which needs a restricted entitlement and cannot run in CI). Lifecycle & threading: - Device teardown lives in ~macos_gamepad_t (RAII): the object owns the run-loop thread and releases the device only after joining it. The run loop is stopped via a queued CFRunLoopPerformBlock so the stop cannot be lost before CFRunLoopRun() starts, and is retained for the object's lifetime. - The gamepads array is guarded by a mutex, since gamepad_update and free_gamepad can run concurrently on task_pool workers; free detaches under the lock and destroys outside it so a thread join never blocks updates. - alloc_gamepad returns -1 instead of letting a thread-creation exception escape; the HID run-loop thread is named for debuggability. Code signing: - Add src_assets/macos/build/sunshine.entitlements granting com.apple.hid.manager.user-access-device (required by IOHIDUserDeviceCreate) and wire it into the .app codesign step. - Document the (Apple-restricted) entitlement, the input-only limitation (no rumble/LED/motion feedback), and the local ad-hoc + AMFI dev workaround in docs/building.md. Also guards a null CGDisplayCopyDisplayMode (headless/CI hosts) and includes unit tests for the gamepad state-to-HID-report mapping.
Address the SonarCloud quality gate (0 new code smells) and the failing Read the Docs build on the gamepad PR: - Replace #define key macros with inline CFSTR literals (S5028) and move the IOHIDUserDevice extern block below the includes so all #includes are grouped (S954). - Use std::scoped_lock instead of std::lock_guard with explicit template args (S5997, S6012); std::jthread instead of std::thread (S6168); std::format for the thread name (S6185); catch std::system_error rather than std::exception (S1181); drop the MAX_GAMEPADS member that shadowed platf::MAX_GAMEPADS (S1117). - Extract compute_dpad_hat() to cut map_gamepad_state_to_hid_report cognitive complexity below 25 (S3776) and remove the nested ternaries (S3358); reword comments flagged as commented-out code (S125). - Convert the macOS definitions of the shared platf:: gamepad functions to plain comments. They are documented in src/platform/common.h, and Doxygen merges @param blocks across every platform definition, which produced "too many @param" errors under WARN_AS_ERROR. No behavior change; the gamepad unit tests still pass.
Bundle ReportBundle size has no change ✅ |
|
Sorry, jthread doesn't work on older macOS versions. You can use a nosonar comment, like https://github.com/LizardByte/tray/blob/fc343dcea4061b06a81bfa72dfe19626f549edcd/tests/unit/test_tray.cpp#L119 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #5171 +/- ##
==========================================
- Coverage 17.86% 17.25% -0.61%
==========================================
Files 111 111
Lines 24129 24268 +139
Branches 10675 10720 +45
==========================================
- Hits 4310 4187 -123
+ Misses 15610 14935 -675
- Partials 4209 5146 +937
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 31 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
|
Per maintainer review: std::jthread is unavailable in the libc++ that Sunshine's supported toolchains ship (it depends on the availability-gated C++20 sync library). Revert the two std::jthread uses to std::thread and suppress the SonarCloud S6168 suggestion with NOSONAR, matching the existing convention in tests/unit/test_confighttp.cpp. The destructor already performs an explicit join (required by std::thread), so there is no behavior change.
|
ReenigneArcher
left a comment
There was a problem hiding this comment.
Thank you for this PR! It's a highly requested feature for a long time.
Looks like you can remove these lines:
Sunshine/docs/getting_started.md
Lines 308 to 309 in 3c54d5f
- ... and might need to adjust the wording if Xbox or PlayStation cannot be emulated on macOS.
| ### macOS code signing & entitlements | ||
| The macOS virtual gamepad publishes a virtual HID device via `IOHIDUserDeviceCreate`, | ||
| which requires the `com.apple.hid.manager.user-access-device` entitlement. Without it, | ||
| AMFI terminates Sunshine the moment a controller is first connected. |
There was a problem hiding this comment.
So, if we don't sign then Sunshine will not work at all?
This is going to break the homebrew package, and make it difficult to test any PR builds as those are not signed.
| // Gamepad HID report descriptor — emulates a Razer Serval (VID 0x1532 / | ||
| // PID 0x0900, an Android/PC Bluetooth gamepad). |
There was a problem hiding this comment.
What would be required to emulate standard Xbox and PlayStation gamepads?
| * @param input The global input context. | ||
| * @param touch The touch event. | ||
| */ | ||
| // Sends a gamepad touch event to the OS (documented in src/platform/common.h). Unimplemented on macOS. |
There was a problem hiding this comment.
you can just remove the comments if they're duplicated in the common.h file
| input device; without it AMFI kills the process when a controller is | ||
| first connected. | ||
|
|
||
| NOTE: this is an Apple-RESTRICTED entitlement. A Developer ID build must |
There was a problem hiding this comment.
I have to make a change in the apple developer portal for this?



Description
Adds gamepad support to the macOS platform.
Achieved by publishing a virtual gamepad and translating Moonlight controller state into HID input reports via
IOHIDUserDeviceCreate. No kext, no third-party driver — unlike the prior attempt (#756 ), which depended on an external VirtualHID driver. It emulates a Razer Serval, because it is auto-recognized by SDL/Steam/Wine with working analog triggers.Testing
Setup: host — MacBook Pro M4 (macOS 26.5); client — Steam Deck, used as the Moonlight (v6.1) client in both desktop and gaming modes.
Code signing
IOHIDUserDeviceCreateneeds thecom.apple.hid.manager.user-access-deviceentitlement (added insunshine.entitlementsand wired into the codesign step). It's an Apple-restricted entitlement, so the official signing identity must be authorized for it (documented in docs/building.md).Screenshot
Issues Fixed or Closed
Roadmap Issues
Type of Change
Checklist
AI Usage