From 1baaf4ff34f3ca182f0d94b18ffa7c9b4538cf3c Mon Sep 17 00:00:00 2001 From: SayHi044 <80337809+SayHi044@users.noreply.github.com> Date: Fri, 22 May 2026 17:35:44 +0200 Subject: [PATCH 1/5] feat(macos): virtual HID gamepad emulating Razer Serval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmake/compile_definitions/macos.cmake | 7 + cmake/dependencies/macos.cmake | 1 + cmake/packaging/macos.cmake | 7 +- docs/building.md | 47 ++ src/platform/macos/input.cpp | 525 ++++++++++++++++++- src/platform/macos/input_gamepad.h | 61 +++ src_assets/macos/build/sunshine.entitlements | 20 + tests/unit/platform/macos/test_input.cpp | 261 +++++++++ 8 files changed, 919 insertions(+), 10 deletions(-) create mode 100644 src/platform/macos/input_gamepad.h create mode 100644 src_assets/macos/build/sunshine.entitlements create mode 100644 tests/unit/platform/macos/test_input.cpp diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 42a5dbe10f1..1b750feff1d 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -35,12 +35,18 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CORE_MEDIA_LIBRARY} ${CORE_VIDEO_LIBRARY} ${FOUNDATION_LIBRARY} + ${IOKIT_LIBRARY} ${VIDEO_TOOLBOX_LIBRARY}) set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in") set(APPLE_PLIST_FILE "${CMAKE_BINARY_DIR}/Info.plist") configure_file("${APPLE_PLIST_TEMPLATE}" "${APPLE_PLIST_FILE}" @ONLY) +# Code-signing entitlements (used by the .app codesign step in +# cmake/packaging/macos.cmake). Grants com.apple.hid.manager.user-access-device +# for the virtual HID gamepad in src/platform/macos/input.cpp. +set(APPLE_ENTITLEMENTS_FILE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/sunshine.entitlements") + set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.mm" @@ -49,6 +55,7 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.m" "${CMAKE_SOURCE_DIR}/src/platform/macos/display.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/input.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/macos/input_gamepad.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/misc.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/misc.h" diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 5e225fdac21..4a027ef9dd5 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -9,6 +9,7 @@ FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio) FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia) FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo) FIND_LIBRARY(FOUNDATION_LIBRARY Foundation) +FIND_LIBRARY(IOKIT_LIBRARY IOKit) FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) if(SUNSHINE_ENABLE_TRAY) diff --git a/cmake/packaging/macos.cmake b/cmake/packaging/macos.cmake index b240f3838e8..1813aaae68b 100644 --- a/cmake/packaging/macos.cmake +++ b/cmake/packaging/macos.cmake @@ -81,9 +81,14 @@ else() endforeach() endif() - # Sign the app last + # Sign the app last. The --entitlements file grants + # com.apple.hid.manager.user-access-device, required by the virtual + # HID gamepad (src/platform/macos/input.cpp). This is an Apple- + # restricted entitlement: the signing identity must be authorized for + # it by Apple, or notarization/AMFI will reject the build. execute_process(COMMAND /usr/bin/codesign --verbose=2 --sign \"${APPLE_CODESIGN_IDENTITY}\" \"\${_app}\" + --entitlements \"${APPLE_ENTITLEMENTS_FILE}\" --force --timestamp --options=runtime RESULT_VARIABLE rc3 ) diff --git a/docs/building.md b/docs/building.md index 54d195b7f12..57786329df7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -234,6 +234,53 @@ ninja -C build }} } +### 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. + +The entitlements are defined in `src_assets/macos/build/sunshine.entitlements` and are +applied automatically when the `.app` is signed (when `SHOULD_SIGN=true`). + +This is an Apple-**restricted** entitlement, which has two consequences: + +- **Official / distributed builds:** the Developer ID signing identity must be authorized + by Apple for this entitlement, otherwise notarization (and AMFI at runtime) will reject + the build. +- **Local development (ad-hoc signed) builds:** ad-hoc signatures are not trusted to carry + restricted entitlements, so AMFI will still kill the process. To test the gamepad locally, + first sign the built `.app` with the entitlements: + ```bash + codesign --force --deep --sign - \ + --entitlements src_assets/macos/build/sunshine.entitlements \ + ./build/Sunshine.app + ``` + Then relax AMFI enforcement so the ad-hoc binary is allowed to use the restricted + entitlement. AMFI is controlled by the `amfi_get_out_of_my_way=0x1` boot argument (there is + no `csrutil` switch for it), and setting boot arguments requires SIP to be disabled. **This + weakens system security and is intended for development machines only.** + + - **Intel:** boot into Recovery (⌘-R), open Terminal, then: + ```bash + csrutil disable + nvram boot-args="amfi_get_out_of_my_way=0x1" + ``` + Reboot back into macOS. + - **Apple Silicon:** boot into Recovery (hold the power button), set the startup disk to + *Reduced Security* with *"Allow user management of kernel extensions"* via Startup Security + Utility, then from a Recovery Terminal: + ```bash + csrutil disable + bputil -k # follow the prompts to allow boot-args + nvram boot-args="amfi_get_out_of_my_way=0x1" + ``` + Reboot back into macOS. (Exact steps vary by macOS version — consult Apple's current + Startup Security Utility documentation.) + + When you are done developing, **revert these changes**: clear the boot argument + (`sudo nvram -d boot-args`) and re-enable SIP from Recovery with `csrutil enable` (and + restore *Full Security* on Apple Silicon). + ### Remote Build It may be beneficial to build remotely in some cases. This will enable easier building on different operating systems. diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 6eed2c1d365..70fb11d8c1c 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -4,10 +4,15 @@ */ // standard includes #include +#include #include #include #include +#include #include +#include +#include +#include #include // platform includes @@ -17,11 +22,32 @@ #include #include +// IOHIDUserDevice forward declarations (header absent in Command Line Tools SDK) +extern "C" { + typedef struct __IOHIDUserDevice *IOHIDUserDeviceRef; + extern IOHIDUserDeviceRef IOHIDUserDeviceCreate(CFAllocatorRef allocator, CFDictionaryRef properties); + extern void IOHIDUserDeviceScheduleWithRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); + extern void IOHIDUserDeviceUnscheduleFromRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); + extern IOReturn IOHIDUserDeviceHandleReport(IOHIDUserDeviceRef device, uint8_t *report, CFIndex reportLength); +} + +#define kIOHIDPrimaryUsagePageKey "PrimaryUsagePage" +#define kIOHIDPrimaryUsageKey "PrimaryUsage" +#define kIOHIDReportDescriptorKey "ReportDescriptor" +#define kIOHIDManufacturerKey "Manufacturer" +#define kIOHIDProductKey "Product" +#define kIOHIDVendorIDKey "VendorID" +#define kIOHIDProductIDKey "ProductID" + +#define kHIDPage_GenericDesktop 0x01 +#define kHIDUsage_GD_GamePad 0x05 + // local includes #include "src/display_device.h" #include "src/input.h" #include "src/logging.h" #include "src/platform/common.h" +#include "src/platform/macos/input_gamepad.h" #include "src/utility.h" /** @@ -30,6 +56,180 @@ */ constexpr std::chrono::milliseconds MULTICLICK_DELAY_MS(500); +// Gamepad HID report descriptor — emulates a Razer Serval (VID 0x1532 / +// PID 0x0900, an Android/PC Bluetooth gamepad). +// +// Why the Razer Serval? +// - It has a built-in SDL GameControllerDB entry with ANALOG triggers +// (lefttrigger:a5, righttrigger:a4) — so SDL/Wine/Steam auto-recognize it +// as a proper gamepad (is_gamepad=1) without any user setup. +// - Its VID 0x1532 (Razer) is never claimed by SDL's HIDAPI driver, so an +// IOHIDUserDevice with this identity is fully visible and readable by all +// consumers: IOKit, SDL, native Steam, Wine DirectInput, and winexinput. +// - True XInput PIDs (Xbox 0x045E, etc.) are claimed by HIDAPI / ignored by +// the IOKit backend; Logitech XInput PIDs share the same problem. The Serval +// PID is pure generic HID — never intercepted. +// - 11 buttons, HAT d-pad, 6 analog axes: maps cleanly onto a standard gamepad. +// +// Button order matches the Serval's SDL entry: +// b0=A b1=B b2=X b3=Y b4=LB b5=RB b6=Back b7=Start b8=Guide b9=LS b10=RS +// D-pad is a HAT switch (the standard way — not individual buttons). +// Axis HID usages are assigned so SDL's usage-sorted axis indices match the +// Serval's SDL mapping (leftx:a0 lefty:a1 rightx:a2 righty:a3 rt:a4 lt:a5): +// X(0x30)→LX=a0 Y(0x31)→LY=a1 Z(0x32)→RX=a2 +// Rx(0x33)→RY=a3 Ry(0x34)→RT=a4 Rz(0x35)→LT=a5 +static const uint8_t kGamepadHIDDescriptor[] = { + 0x05, + 0x01, // Usage Page: Generic Desktop + 0x09, + 0x05, // Usage: Gamepad + 0xA1, + 0x01, // Collection: Application + 0x85, + 0x01, // Report ID: 1 + + // Buttons 1-11: A, B, X, Y, LB, RB, Back, Start, Guide, LS, RS + 0x05, + 0x09, // Usage Page: Button + 0x19, + 0x01, // Usage Minimum: 1 + 0x29, + 0x0B, // Usage Maximum: 11 + 0x15, + 0x00, // Logical Minimum: 0 + 0x25, + 0x01, // Logical Maximum: 1 + 0x75, + 0x01, // Report Size: 1 bit + 0x95, + 0x0B, // Report Count: 11 + 0x81, + 0x02, // Input: Data, Variable, Absolute + // Padding: 5 bits to fill the 2nd byte + 0x75, + 0x01, + 0x95, + 0x05, + 0x81, + 0x03, // Input: Const + + // D-pad as HAT switch (0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW, 8=null) + 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 degrees + 0x46, + 0x3B, + 0x01, // Physical Maximum: 315 degrees + 0x65, + 0x14, // Unit: Degrees + 0x75, + 0x04, // Report Size: 4 bits + 0x95, + 0x01, // Report Count: 1 + 0x81, + 0x42, // Input: Data, Variable, Absolute, Null State + // Padding: 4 bits + 0x65, + 0x00, // Unit: None + 0x75, + 0x04, + 0x95, + 0x01, + 0x81, + 0x03, // Input: Const + + // Axes: LS X/Y, RS X/Y, RT, LT (6 × 16-bit signed). + // Usage codes are chosen so SDL's usage-sorted indices yield: + // X(0x30)=a0=LX Y(0x31)=a1=LY Z(0x32)=a2=RX + // Rx(0x33)=a3=RY Ry(0x34)=a4=RT Rz(0x35)=a5=LT + // This matches the Serval's SDL entry: leftx:a0 lefty:a1 rightx:a2 righty:a3 + // righttrigger:a4 lefttrigger:a5 + 0x09, + 0x30, // Usage: X → Left Stick X (a0) + 0x09, + 0x31, // Usage: Y → Left Stick Y (a1) + 0x09, + 0x32, // Usage: Z → Right Stick X (a2) + 0x09, + 0x33, // Usage: Rx → Right Stick Y (a3) + 0x09, + 0x34, // Usage: Ry → Right Trigger (a4) + 0x09, + 0x35, // Usage: Rz → Left Trigger (a5) + 0x16, + 0x00, + 0x80, // Logical Minimum: -32768 + 0x26, + 0xFF, + 0x7F, // Logical Maximum: 32767 + 0x75, + 0x10, // Report Size: 16 bits + 0x95, + 0x06, // Report Count: 6 + 0x81, + 0x02, // Input: Data, Variable, Absolute + 0xC0 // End Collection +}; + +// gamepad_hid_report_t (the HID report layout matching kGamepadHIDDescriptor) +// and the state→report mapping live in input_gamepad.h so the mapping can be +// unit-tested without a real IOHIDUserDevice. + +struct macos_gamepad_t { + IOHIDUserDeviceRef hid_device = nullptr; + platf::gamepad_hid_report_t report {}; + CFRunLoopRef run_loop = nullptr; // run loop of the dedicated HID thread + std::thread run_loop_thread; // owns the dedicated HID run-loop thread + + macos_gamepad_t() = default; + + // Non-copyable / non-movable: the destructor joins a thread that captures + // `this`-owned state, so the object must stay put for its whole lifetime. + macos_gamepad_t(const macos_gamepad_t &) = delete; + macos_gamepad_t &operator=(const macos_gamepad_t &) = delete; + + /** + * @brief Tears down the virtual device: stops the run loop, joins its thread, + * then releases the HID device. + * + * Keeping cleanup in the destructor (rather than only in free_gamepad) means + * the device and its thread are released no matter how the object dies — + * including when the whole macos_input_t is torn down at shutdown. + * + * The stop request is *enqueued* on the run loop via CFRunLoopPerformBlock + * instead of calling CFRunLoopStop directly. CFRunLoopStop only takes effect + * if the loop is already running; if free happens right after alloc, the stop + * could land before CFRunLoopRun() starts and be lost, hanging the thread + * forever. A queued block instead runs as soon as the loop spins up and stops + * it from the inside, which is race-free. The device is released only after + * the thread has joined, so the thread never touches a freed device. + */ + ~macos_gamepad_t() { + if (run_loop) { + CFRunLoopPerformBlock(run_loop, kCFRunLoopDefaultMode, ^{ + CFRunLoopStop(CFRunLoopGetCurrent()); + }); + CFRunLoopWakeUp(run_loop); + } + if (run_loop_thread.joinable()) { + run_loop_thread.join(); + } + if (run_loop) { + CFRelease(run_loop); // balance the CFRetain in alloc_gamepad + } + if (hid_device) { + CFRelease(hid_device); + } + } +}; + namespace platf { using namespace std::literals; @@ -53,6 +253,16 @@ namespace platf { int scroll_lines_per_detent {DEFAULT_SCROLL_LINES_PER_DETENT}; bool mouse_down[3] {}; // mouse button status std::chrono::steady_clock::steady_clock::time_point last_mouse_event[3][2]; // timestamp of last mouse events + + // gamepad related stuff + static constexpr int MAX_GAMEPADS = platf::MAX_GAMEPADS; + std::array, MAX_GAMEPADS> gamepads {}; + // Guards the gamepads array. alloc_gamepad / free_gamepad / gamepad_update + // can run on different threads (e.g. the control stream and task_pool + // workers — the back→home button emulation calls gamepad_update from a + // delayed task while a session teardown may push free_gamepad), so all + // access to the array is serialized. + std::mutex gamepads_mutex; }; // A struct to hold a Windows keycode to Mac virtual keycode mapping. @@ -342,17 +552,307 @@ const KeyCodeMap kKeyCodesMap[] = { BOOST_LOG(info) << "unicode: Unicode input not yet implemented for MacOS."sv; } + /** + * @brief Creates a virtual HID gamepad for the given slot. + * + * The macOS virtual gamepad is currently input-only: it emulates a single + * fixed device (a Razer Serval) and reports buttons/axes to the OS, but does + * not consume @p metadata or post anything to @p feedback_queue. As a result + * none of the feedback features other platforms support — rumble, trigger + * rumble, RGB LED, adaptive triggers, motion/battery (see gamepad_feedback_e + * and the inputtino/ViGEm backends) — are implemented here. The descriptor + * also has no output report, so OS-side SET_REPORTs (e.g. rumble) are not + * received. Wiring these up would require an output report in + * kGamepadHIDDescriptor plus a SET_REPORT callback on the run loop. + * + * @param input The global input context. + * @param id The gamepad ID (globalIndex used as the slot). + * @param metadata Controller metadata from the client (currently unused). + * @param feedback_queue The queue for posting messages back to the client (currently unused). + * @return 0 on success, -1 on failure. + */ int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) { - BOOST_LOG(info) << "alloc_gamepad: Gamepad not yet implemented for MacOS."sv; - return -1; + auto *macos_input = static_cast(input.get()); + const int nr = id.globalIndex; + + if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + BOOST_LOG(error) << "alloc_gamepad: slot " << nr << " out of range"; + return -1; + } + + // If this slot is already occupied (e.g. the client re-sends a controller + // arrival without an intervening removal, or on reconnect), release the + // previous device first. Otherwise the old IOHIDUserDevice and its dedicated + // run-loop thread leak — overwriting gamepads[nr] does not stop that thread, + // so the stale virtual device stays registered with the HID system. + // free_gamepad takes gamepads_mutex itself, so don't hold it across this. + bool occupied; + { + std::lock_guard lock(macos_input->gamepads_mutex); + occupied = static_cast(macos_input->gamepads[nr]); + } + if (occupied) { + BOOST_LOG(warning) << "alloc_gamepad: slot " << nr << " already occupied; releasing previous device"; + free_gamepad(input, nr); + } + + // Build device properties + CFMutableDictionaryRef props = CFDictionaryCreateMutable( + kCFAllocatorDefault, + 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + + // Helper: create a CFNumber, set it on props, then release our reference + // (the dictionary retains its own copy via kCFTypeDictionaryValueCallBacks). + auto set_int32 = [&](CFStringRef key, int32_t value) { + CFNumberRef num = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &value); + CFDictionarySetValue(props, key, num); + CFRelease(num); + }; + + set_int32(CFSTR(kIOHIDPrimaryUsagePageKey), kHIDPage_GenericDesktop); + set_int32(CFSTR(kIOHIDPrimaryUsageKey), kHIDUsage_GD_GamePad); + + // Embed the HID report descriptor + CFDataRef descriptor = CFDataCreate(kCFAllocatorDefault, kGamepadHIDDescriptor, sizeof(kGamepadHIDDescriptor)); + CFDictionarySetValue(props, CFSTR(kIOHIDReportDescriptorKey), descriptor); + CFRelease(descriptor); + + // Vendor/product identity — see the kGamepadHIDDescriptor comment for why + // we emulate a Razer Serval (0x1532/0x0900). + CFDictionarySetValue(props, CFSTR(kIOHIDManufacturerKey), CFSTR("Razer")); + CFDictionarySetValue(props, CFSTR(kIOHIDProductKey), CFSTR("Razer Serval")); + set_int32(CFSTR(kIOHIDVendorIDKey), 0x1532); // Razer + set_int32(CFSTR(kIOHIDProductIDKey), 0x0900); // Serval + + IOHIDUserDeviceRef device = IOHIDUserDeviceCreate(kCFAllocatorDefault, props); + CFRelease(props); + + if (!device) { + BOOST_LOG(error) << "alloc_gamepad: IOHIDUserDeviceCreate failed for slot " << nr; + return -1; + } + + // Sunshine's main thread runs Boost.Asio, not a CFRunLoop, so scheduling + // on CFRunLoopGetMain() would leave the device with an unspun run loop. + // Spin a dedicated thread that schedules the device on its own CFRunLoop + // and keeps it running. Use a promise to hand the run loop ref back so the + // destructor can stop it cleanly. The thread is owned by macos_gamepad_t + // and joined in its destructor, so `device` stays valid for the thread's + // whole lifetime (it is CFRelease'd only after the join). + auto gp = std::make_unique(); + gp->hid_device = device; + gp->report.report_id = 1; + gp->report.hat = 8; // null state (no D-pad direction) + + // Spinning up the thread (or, in the worst case, taking the lock) can throw + // under resource exhaustion. Callers only inspect the return code, so + // translate any failure into -1 rather than letting it escape. If we throw + // here, gp's destructor releases the device (and joins the thread if it was + // already started), so nothing leaks. + try { + std::promise rl_promise; + auto rl_future = rl_promise.get_future(); + + gp->run_loop_thread = std::thread([device, nr, promise = std::move(rl_promise)]() mutable { + // Name the thread so it's identifiable in Console/Instruments/lldb + // (repo convention; up to MAX_GAMEPADS of these can exist at once). + set_thread_name("gamepad::hid[" + std::to_string(nr) + "]"); + CFRunLoopRef rl = CFRunLoopGetCurrent(); + IOHIDUserDeviceScheduleWithRunLoop(device, rl, kCFRunLoopDefaultMode); + promise.set_value(rl); // hand run loop ref back to alloc_gamepad + CFRunLoopRun(); // blocks until the destructor's queued block stops it + IOHIDUserDeviceUnscheduleFromRunLoop(device, rl, kCFRunLoopDefaultMode); + }); + + // Wait until the thread has scheduled the device, then take our own + // reference on its run loop. CFRunLoopGetCurrent() (used inside the + // thread) returns a non-owning reference tied to the thread's lifetime; + // retaining here keeps the run loop valid for the destructor even in the + // unlikely event the thread exits early (e.g. CFRunLoopRun returns with + // no source). + CFRunLoopRef rl = rl_future.get(); + CFRetain(rl); + gp->run_loop = rl; + + // The slot is empty here: alloc runs on the single control-stream thread + // and global ids are unique, so no other thread fills nr while we're in + // this function (and the "occupied" case above already freed it). The + // assignment therefore never destroys a live device, i.e. never joins a + // run-loop thread while holding the lock — the one thing free_gamepad + // takes care to avoid. + std::lock_guard lock(macos_input->gamepads_mutex); + macos_input->gamepads[nr] = std::move(gp); + } catch (const std::exception &e) { + BOOST_LOG(error) << "alloc_gamepad: failed to start HID run-loop thread for slot " << nr << ": " << e.what(); + return -1; + } + + BOOST_LOG(info) << "alloc_gamepad: created virtual gamepad in slot " << nr; + return 0; } + /** + * @brief Destroys the virtual HID gamepad in the given slot. + * @param input The global input context. + * @param nr The global gamepad slot index. + */ void free_gamepad(input_t &input, int nr) { - BOOST_LOG(info) << "free_gamepad: Gamepad not yet implemented for MacOS."sv; + auto *macos_input = static_cast(input.get()); + if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + return; + } + + // Detach the device from the slot under the lock so the slot reads as empty + // immediately, then let it destruct *outside* the lock. ~macos_gamepad_t() + // joins the run-loop thread, which we must not do while holding the mutex + // (that would block gamepad_update for the whole teardown). + std::unique_ptr doomed; + { + std::lock_guard lock(macos_input->gamepads_mutex); + doomed = std::move(macos_input->gamepads[nr]); + } + + if (doomed) { + doomed.reset(); // stops the run loop, joins its thread, releases the HID device + BOOST_LOG(info) << "free_gamepad: released slot " << nr; + } + } + + /** + * @brief Negates a signed 16-bit axis value without overflowing. + * + * A plain unary minus on INT16_MIN (-32768) would produce +32768, which is + * not representable as int16_t and wraps back to -32768 — leaving a + * fully-deflected stick stuck at the wrong extreme. Clamp that one case to + * INT16_MAX so the negation stays monotonic across the whole range. + * + * @param v The axis value to negate. + * @return -v, clamped to the int16_t range. + */ + static int16_t negate_axis(int16_t v) { + return v == INT16_MIN ? INT16_MAX : static_cast(-v); } + gamepad_hid_report_t map_gamepad_state_to_hid_report(const gamepad_state_t &gamepad_state) { + gamepad_hid_report_t report {}; + report.report_id = 1; + + // Buttons (bits 0-10 → HID buttons 1-11). Order matches the Razer Serval's + // SDL GameControllerDB entry so SDL/Steam/Wine auto-map correctly: + // b0=A b1=B b2=X b3=Y b4=LB b5=RB b6=Back b7=Start b8=Guide b9=LS b10=RS + // (SDL Serval mapping: a:b0, b:b1, x:b2, y:b3, leftshoulder:b4, + // rightshoulder:b5, back:b6, start:b7, guide:b8, leftstick:b9, rightstick:b10) + if (gamepad_state.buttonFlags & A) { + report.buttons |= (1 << 0); // b0 A + } + if (gamepad_state.buttonFlags & B) { + report.buttons |= (1 << 1); // b1 B + } + if (gamepad_state.buttonFlags & X) { + report.buttons |= (1 << 2); // b2 X + } + if (gamepad_state.buttonFlags & Y) { + report.buttons |= (1 << 3); // b3 Y + } + if (gamepad_state.buttonFlags & LEFT_BUTTON) { + report.buttons |= (1 << 4); // b4 LB + } + if (gamepad_state.buttonFlags & RIGHT_BUTTON) { + report.buttons |= (1 << 5); // b5 RB + } + if (gamepad_state.buttonFlags & BACK) { + report.buttons |= (1 << 6); // b6 Back + } + if (gamepad_state.buttonFlags & START) { + report.buttons |= (1 << 7); // b7 Start + } + if (gamepad_state.buttonFlags & HOME) { + report.buttons |= (1 << 8); // b8 Guide + } + if (gamepad_state.buttonFlags & LEFT_STICK) { + report.buttons |= (1 << 9); // b9 LS + } + if (gamepad_state.buttonFlags & RIGHT_STICK) { + report.buttons |= (1 << 10); // b10 RS + } + + // D-pad as HAT switch: 0=N 1=NE 2=E 3=SE 4=S 5=SW 6=W 7=NW 8=null + { + const bool up = gamepad_state.buttonFlags & DPAD_UP; + const bool down = gamepad_state.buttonFlags & DPAD_DOWN; + const bool left = gamepad_state.buttonFlags & DPAD_LEFT; + const bool right = gamepad_state.buttonFlags & DPAD_RIGHT; + uint8_t hat = 8; // null (no direction) + if (up && !down) { + hat = right ? 1 : (left ? 7 : 0); // NE / NW / N + } else if (down && !up) { + hat = right ? 3 : (left ? 5 : 4); // SE / SW / S + } else if (right && !left) { + hat = 2; // E + } else if (left && !right) { + hat = 6; // W + } + report.hat = hat; + } + + // Sticks: pass through as-is (-32768..32767); Y axes are negated to match + // HID convention where up is the negative direction. + // Triggers: scale 0..255 → full signed range -32768..32767 so SDL reads + // them as a full-range axis (rest = -32768 → SDL_GameController 0; full = + // 32767 → 32767). Sending 0..32767 made the axis idle at the midpoint, + // which SDL reported as a half-pressed trigger. + report.left_x = gamepad_state.lsX; + report.left_y = negate_axis(gamepad_state.lsY); + report.right_x = gamepad_state.rsX; + report.right_y = negate_axis(gamepad_state.rsY); + report.l2 = static_cast((gamepad_state.lt / 255.0f) * 65535.0f - 32768.0f); + report.r2 = static_cast((gamepad_state.rt / 255.0f) * 65535.0f - 32768.0f); + + return report; + } + + /** + * @brief Sends a gamepad state update as a HID report. + * + * Maps the Moonlight button flags and axis values from @p gamepad_state into + * the packed HID report (see map_gamepad_state_to_hid_report) and submits it + * to the virtual device. + * + * @param input The global input context. + * @param nr The global gamepad slot index. + * @param gamepad_state The current gamepad state from the client. + */ void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) { - BOOST_LOG(info) << "gamepad: Gamepad not yet implemented for MacOS."sv; + auto *macos_input = static_cast(input.get()); + if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + return; + } + + // Hold the lock for the whole update: it keeps free_gamepad from detaching + // and destroying the device between the null-check and HandleReport. The + // HID submission is a fast syscall, and updates for a single gamepad are + // serial anyway, so this does not throttle the input path. + std::lock_guard lock(macos_input->gamepads_mutex); + if (!macos_input->gamepads[nr]) { + return; + } + + auto &gp = *macos_input->gamepads[nr]; + gp.report = map_gamepad_state_to_hid_report(gamepad_state); + + // Send the HID report + IOReturn ret = IOHIDUserDeviceHandleReport( + gp.hid_device, + reinterpret_cast(&gp.report), + sizeof(gp.report) + ); + + if (ret != kIOReturnSuccess) { + BOOST_LOG(warning) << "gamepad_update: IOHIDUserDeviceHandleReport failed: " << ret; + } } // returns current mouse location: @@ -637,10 +1137,17 @@ const KeyCodeMap kKeyCodesMap[] = { } } - // Input coordinates are based on the virtual resolution not the physical, so we need the scaling factor + // Input coordinates are based on the virtual resolution not the physical, so we need the scaling factor. + // CGDisplayCopyDisplayMode can return null (e.g. no/asleep display on a headless host or CI runner); + // fall back to 1.0 rather than dereferencing/CFRelease-ing null. const CGDisplayModeRef mode = CGDisplayCopyDisplayMode(macos_input->display); - macos_input->displayScaling = ((CGFloat) CGDisplayPixelsWide(macos_input->display)) / ((CGFloat) CGDisplayModeGetPixelWidth(mode)); - CFRelease(mode); + if (mode) { + macos_input->displayScaling = ((CGFloat) CGDisplayPixelsWide(macos_input->display)) / ((CGFloat) CGDisplayModeGetPixelWidth(mode)); + CFRelease(mode); + } else { + macos_input->displayScaling = 1.0; + BOOST_LOG(warning) << "input(): CGDisplayCopyDisplayMode returned null; defaulting display scaling to 1.0"sv; + } macos_input->source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); macos_input->keyboard_source = CGEventSourceCreate(kCGEventSourceStatePrivate); @@ -670,8 +1177,8 @@ const KeyCodeMap kKeyCodesMap[] = { } std::vector &supported_gamepads(input_t *input) { - static std::vector gamepads { - supported_gamepad_t {"", false, "gamepads.macos_not_implemented"} + static std::vector gamepads { + supported_gamepad_t {"XInput", true, ""} }; return gamepads; diff --git a/src/platform/macos/input_gamepad.h b/src/platform/macos/input_gamepad.h new file mode 100644 index 00000000000..50ab76b1556 --- /dev/null +++ b/src/platform/macos/input_gamepad.h @@ -0,0 +1,61 @@ +/** + * @file src/platform/macos/input_gamepad.h + * @brief Virtual HID gamepad report layout and the pure state→report mapping. + * + * Split out from input.cpp so the mapping logic (button bits, D-pad HAT, axis + * and trigger scaling) can be unit-tested without creating a real + * IOHIDUserDevice, which requires a restricted entitlement and cannot run in + * CI. See tests/unit/platform/macos/test_input.cpp. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/platform/common.h" + +namespace platf { + + /** + * @brief HID input report layout for the virtual gamepad. + * + * The field order MUST match the Usage declaration order in the HID report + * descriptor (kGamepadHIDDescriptor in input.cpp). + * Total: 1 (report_id) + 2 (buttons 1-11 + 5 pad) + 1 (hat 4b + 4b pad) + 12 (axes) = 16 bytes. + */ + struct gamepad_hid_report_t { + uint8_t report_id; ///< always 1 + uint16_t buttons; ///< bits 0-10: A B X Y LB RB Back Start Guide LS RS; bits 11-15: padding + uint8_t hat; ///< lower nibble: HAT direction (0-7 or 8=null); upper nibble: padding + int16_t left_x; ///< Usage X (0x30) → SDL a0 = Left Stick X + int16_t left_y; ///< Usage Y (0x31) → SDL a1 = Left Stick Y + int16_t right_x; ///< Usage Z (0x32) → SDL a2 = Right Stick X + int16_t right_y; ///< Usage Rx (0x33) → SDL a3 = Right Stick Y + int16_t r2; ///< Usage Ry (0x34) → SDL a4 = Right Trigger + int16_t l2; ///< Usage Rz (0x35) → SDL a5 = Left Trigger + } __attribute__((packed)); + + static_assert( + sizeof(gamepad_hid_report_t) == 16, + "gamepad_hid_report_t size mismatch — update the HID descriptor if fields change" + ); + + /** + * @brief Maps a Moonlight gamepad state onto the virtual gamepad's HID report. + * + * Pure function with no device I/O so it can be unit-tested. The returned + * report has report_id == 1. + * + * Buttons (bits 0-10 → HID buttons 1-11) follow the Razer Serval's SDL + * GameControllerDB entry so SDL/Steam/Wine auto-map correctly. The D-pad is + * encoded as a HAT switch (0=N..7=NW, 8=null). Stick Y axes are negated to + * match the HID convention (up = negative); triggers scale 0..255 onto the + * full signed range -32768..32767. + * + * @param state The gamepad state from the client. + * @return The populated HID report. + */ + gamepad_hid_report_t map_gamepad_state_to_hid_report(const gamepad_state_t &state); + +} // namespace platf diff --git a/src_assets/macos/build/sunshine.entitlements b/src_assets/macos/build/sunshine.entitlements new file mode 100644 index 00000000000..d5a5b944521 --- /dev/null +++ b/src_assets/macos/build/sunshine.entitlements @@ -0,0 +1,20 @@ + + + + + + com.apple.hid.manager.user-access-device + + + diff --git a/tests/unit/platform/macos/test_input.cpp b/tests/unit/platform/macos/test_input.cpp new file mode 100644 index 00000000000..7712b8667ac --- /dev/null +++ b/tests/unit/platform/macos/test_input.cpp @@ -0,0 +1,261 @@ +/** + * @file tests/unit/platform/macos/test_input.cpp + * @brief Unit tests for src/platform/macos/input.cpp + */ + +// Only compile these tests on macOS +#ifdef __APPLE__ + + #include "../../../tests_common.h" + + #include + #include + +/** + * @brief Verify that each Moonlight button constant has the value defined by + * the Moonlight protocol (Limelight-internal.h). These constants are + * referenced directly by gamepad_update() and a wrong value would + * silently mis-map an entire button. + */ +TEST(MacosGamepadButtonConstants, ProtocolValues) { + EXPECT_EQ(platf::DPAD_UP, 0x0001u); + EXPECT_EQ(platf::DPAD_DOWN, 0x0002u); + EXPECT_EQ(platf::DPAD_LEFT, 0x0004u); + EXPECT_EQ(platf::DPAD_RIGHT, 0x0008u); + EXPECT_EQ(platf::START, 0x0010u); + EXPECT_EQ(platf::BACK, 0x0020u); + EXPECT_EQ(platf::LEFT_STICK, 0x0040u); + EXPECT_EQ(platf::RIGHT_STICK, 0x0080u); + EXPECT_EQ(platf::LEFT_BUTTON, 0x0100u); + EXPECT_EQ(platf::RIGHT_BUTTON, 0x0200u); + EXPECT_EQ(platf::HOME, 0x0400u); + EXPECT_EQ(platf::A, 0x1000u); + EXPECT_EQ(platf::B, 0x2000u); + EXPECT_EQ(platf::X, 0x4000u); + EXPECT_EQ(platf::Y, 0x8000u); +} + +/** + * @brief Verify that all mapped button constants are distinct (no two buttons + * share a bit), which is a prerequisite for the bitwise mapping in + * gamepad_update() to be lossless. + */ +TEST(MacosGamepadButtonConstants, AllDistinct) { + constexpr std::uint32_t buttons[] = { + platf::DPAD_UP, + platf::DPAD_DOWN, + platf::DPAD_LEFT, + platf::DPAD_RIGHT, + platf::START, + platf::BACK, + platf::LEFT_STICK, + platf::RIGHT_STICK, + platf::LEFT_BUTTON, + platf::RIGHT_BUTTON, + platf::HOME, + platf::A, + platf::B, + platf::X, + platf::Y, + }; + + std::uint32_t seen = 0; + for (auto btn : buttons) { + EXPECT_EQ(btn & seen, 0u) << "Button 0x" << std::hex << btn << " overlaps with a previously seen button"; + seen |= btn; + } +} + +/** + * @brief Verify trigger scaling on the real mapping: a 0–255 byte value must + * map onto the full signed 16-bit range -32768..32767 so SDL reads the + * analog trigger as a full-range axis (rest = -32768, full = 32767). + * This is the actual formula used by map_gamepad_state_to_hid_report(); + * a regression here would surface as half-pressed or inverted triggers. + */ +TEST(MacosGamepadTriggerScaling, RestProducesMin) { + platf::gamepad_state_t state {}; + state.lt = 0; + state.rt = 0; + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.l2, -32768); + EXPECT_EQ(report.r2, -32768); +} + +TEST(MacosGamepadTriggerScaling, FullProducesMax) { + platf::gamepad_state_t state {}; + state.lt = 255; + state.rt = 255; + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.l2, 32767); + EXPECT_EQ(report.r2, 32767); +} + +TEST(MacosGamepadTriggerScaling, MidpointIsNearZero) { + platf::gamepad_state_t state {}; + state.lt = 127; + // 127/255 * 65535 - 32768 ≈ -131; allow generous tolerance for rounding + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_NEAR(report.l2, -131, 64); +} + +/** + * @brief Verify each Moonlight button flag maps onto exactly the expected HID + * button bit (and only that bit), matching the Razer Serval SDL entry. + */ +TEST(MacosGamepadMapping, ButtonsMapToExpectedBits) { + const struct { + std::uint32_t flag; + int bit; + const char *name; + } cases[] = { + {platf::A, 0, "A"}, + {platf::B, 1, "B"}, + {platf::X, 2, "X"}, + {platf::Y, 3, "Y"}, + {platf::LEFT_BUTTON, 4, "LB"}, + {platf::RIGHT_BUTTON, 5, "RB"}, + {platf::BACK, 6, "Back"}, + {platf::START, 7, "Start"}, + {platf::HOME, 8, "Guide"}, + {platf::LEFT_STICK, 9, "LS"}, + {platf::RIGHT_STICK, 10, "RS"}, + }; + + for (const auto &c : cases) { + platf::gamepad_state_t state {}; + state.buttonFlags = c.flag; + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.buttons, static_cast(1u << c.bit)) + << "button " << c.name << " mapped to the wrong bit(s)"; + } +} + +TEST(MacosGamepadMapping, NoButtonsProducesZeroAndNullHat) { + const auto report = platf::map_gamepad_state_to_hid_report(platf::gamepad_state_t {}); + EXPECT_EQ(report.buttons, 0u); + EXPECT_EQ(report.hat, 8); // null (no D-pad direction) + EXPECT_EQ(report.report_id, 1); +} + +/** + * @brief Verify the D-pad button flags encode into the correct HAT direction, + * including diagonals, the null state, and ambiguous opposite-direction + * (SOCD) combinations. + */ +TEST(MacosGamepadMapping, DpadEncodesHatDirections) { + const struct { + std::uint32_t flags; + int hat; + const char *name; + } cases[] = { + {platf::DPAD_UP, 0, "N"}, + {platf::DPAD_UP | platf::DPAD_RIGHT, 1, "NE"}, + {platf::DPAD_RIGHT, 2, "E"}, + {platf::DPAD_DOWN | platf::DPAD_RIGHT, 3, "SE"}, + {platf::DPAD_DOWN, 4, "S"}, + {platf::DPAD_DOWN | platf::DPAD_LEFT, 5, "SW"}, + {platf::DPAD_LEFT, 6, "W"}, + {platf::DPAD_UP | platf::DPAD_LEFT, 7, "NW"}, + {0, 8, "null"}, + // Opposite directions cancel to null rather than picking a side. + {platf::DPAD_UP | platf::DPAD_DOWN, 8, "U+D"}, + {platf::DPAD_LEFT | platf::DPAD_RIGHT, 8, "L+R"}, + }; + + for (const auto &c : cases) { + platf::gamepad_state_t state {}; + state.buttonFlags = c.flags; + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.hat, c.hat) << "D-pad combo " << c.name << " produced the wrong HAT value"; + } +} + +/** + * @brief Verify stick axes: X passes through, Y is negated to the HID + * convention (up = negative), and the INT16_MIN extreme negates to + * INT16_MAX instead of wrapping back to INT16_MIN. + */ +TEST(MacosGamepadMapping, SticksPassThroughAndYNegated) { + platf::gamepad_state_t state {}; + state.lsX = 1000; + state.lsY = 2000; + state.rsX = -1500; + state.rsY = 3000; + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.left_x, 1000); + EXPECT_EQ(report.left_y, -2000); + EXPECT_EQ(report.right_x, -1500); + EXPECT_EQ(report.right_y, -3000); +} + +TEST(MacosGamepadMapping, YAxisExtremeDoesNotOverflow) { + platf::gamepad_state_t state {}; + state.lsY = INT16_MIN; // -32768; plain `-v` would wrap back to -32768 + state.rsY = INT16_MAX; // 32767 + const auto report = platf::map_gamepad_state_to_hid_report(state); + EXPECT_EQ(report.left_y, INT16_MAX); // clamped, not wrapped + EXPECT_EQ(report.right_y, -32767); +} + +/** + * @brief Test that alloc_gamepad rejects out-of-range slot indices without + * crashing, before any IOHIDUserDeviceCreate call is made. + */ +class MacosAllocGamepadTest: public testing::Test {}; + +TEST_F(MacosAllocGamepadTest, NegativeSlotReturnsError) { + auto input = platf::input(); + platf::gamepad_id_t id {-1, 0}; + EXPECT_NE(platf::alloc_gamepad(input, id, {}, {}), 0); +} + +TEST_F(MacosAllocGamepadTest, OversizedSlotReturnsError) { + auto input = platf::input(); + // platf::MAX_GAMEPADS is the ceiling the macOS implementation also enforces + platf::gamepad_id_t id {platf::MAX_GAMEPADS, 0}; + EXPECT_NE(platf::alloc_gamepad(input, id, {}, {}), 0); +} + +/** + * @brief Test that free_gamepad on an unallocated slot is a no-op (no crash, + * no assertion failure). + */ +TEST_F(MacosAllocGamepadTest, FreeUnallocatedSlotIsNoOp) { + auto input = platf::input(); + EXPECT_NO_FATAL_FAILURE(platf::free_gamepad(input, 0)); +} + +TEST_F(MacosAllocGamepadTest, FreeNegativeSlotIsNoOp) { + auto input = platf::input(); + EXPECT_NO_FATAL_FAILURE(platf::free_gamepad(input, -1)); +} + +/** + * @brief Test that gamepad_update on an unallocated slot is a no-op (no crash, + * no assertion failure). This mirrors the behaviour in src/input.cpp + * which may call gamepad_update after alloc_gamepad fails. + */ +TEST_F(MacosAllocGamepadTest, UpdateUnallocatedSlotIsNoOp) { + auto input = platf::input(); + platf::gamepad_state_t state {}; + EXPECT_NO_FATAL_FAILURE(platf::gamepad_update(input, 0, state)); +} + +TEST_F(MacosAllocGamepadTest, UpdateNegativeSlotIsNoOp) { + auto input = platf::input(); + platf::gamepad_state_t state {}; + EXPECT_NO_FATAL_FAILURE(platf::gamepad_update(input, -1, state)); +} + +/** + * @brief Test that get_capabilities does not advertise pen/touch features, + * which are unimplemented on macOS. + */ +TEST(MacosCapabilities, NoPenTouch) { + const auto caps = platf::get_capabilities(); + EXPECT_EQ(caps & platf::platform_caps::pen_touch, 0u); + EXPECT_EQ(caps & platf::platform_caps::controller_touch, 0u); +} + +#endif // __APPLE__ From 9b54b80a127fdc3c986024036ed949647144cfb0 Mon Sep 17 00:00:00 2001 From: SayHi044 <80337809+SayHi044@users.noreply.github.com> Date: Fri, 22 May 2026 21:55:57 +0200 Subject: [PATCH 2/5] fix(macos): resolve SonarCloud code smells and Doxygen build errors 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. --- src/platform/macos/input.cpp | 210 ++++++++++++++++------------------- 1 file changed, 98 insertions(+), 112 deletions(-) diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 70fb11d8c1c..826022f2e86 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -8,11 +8,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include // platform includes @@ -22,26 +24,6 @@ #include #include -// IOHIDUserDevice forward declarations (header absent in Command Line Tools SDK) -extern "C" { - typedef struct __IOHIDUserDevice *IOHIDUserDeviceRef; - extern IOHIDUserDeviceRef IOHIDUserDeviceCreate(CFAllocatorRef allocator, CFDictionaryRef properties); - extern void IOHIDUserDeviceScheduleWithRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); - extern void IOHIDUserDeviceUnscheduleFromRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); - extern IOReturn IOHIDUserDeviceHandleReport(IOHIDUserDeviceRef device, uint8_t *report, CFIndex reportLength); -} - -#define kIOHIDPrimaryUsagePageKey "PrimaryUsagePage" -#define kIOHIDPrimaryUsageKey "PrimaryUsage" -#define kIOHIDReportDescriptorKey "ReportDescriptor" -#define kIOHIDManufacturerKey "Manufacturer" -#define kIOHIDProductKey "Product" -#define kIOHIDVendorIDKey "VendorID" -#define kIOHIDProductIDKey "ProductID" - -#define kHIDPage_GenericDesktop 0x01 -#define kHIDUsage_GD_GamePad 0x05 - // local includes #include "src/display_device.h" #include "src/input.h" @@ -50,6 +32,18 @@ extern "C" { #include "src/platform/macos/input_gamepad.h" #include "src/utility.h" +// IOHIDUserDevice forward declarations (header absent in Command Line Tools SDK). +// Declared after the includes so every #include stays grouped at the top of the file. +// The IOKit HID property-key strings ("Product", "VendorID", etc.) are likewise +// absent from the CLT SDK, so they are passed inline at the call sites below. +extern "C" { + typedef struct __IOHIDUserDevice *IOHIDUserDeviceRef; + extern IOHIDUserDeviceRef IOHIDUserDeviceCreate(CFAllocatorRef allocator, CFDictionaryRef properties); + extern void IOHIDUserDeviceScheduleWithRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); + extern void IOHIDUserDeviceUnscheduleFromRunLoop(IOHIDUserDeviceRef device, CFRunLoopRef runLoop, CFStringRef runLoopMode); + extern IOReturn IOHIDUserDeviceHandleReport(IOHIDUserDeviceRef device, uint8_t *report, CFIndex reportLength); +} + /** * @brief Delay for a double click, in milliseconds. * @todo Make this configurable. @@ -113,7 +107,7 @@ static const uint8_t kGamepadHIDDescriptor[] = { 0x81, 0x03, // Input: Const - // D-pad as HAT switch (0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW, 8=null) + // D-pad as a HAT switch (directions run clockwise from North; value eight means centered) 0x05, 0x01, // Usage Page: Generic Desktop 0x09, @@ -186,7 +180,7 @@ struct macos_gamepad_t { IOHIDUserDeviceRef hid_device = nullptr; platf::gamepad_hid_report_t report {}; CFRunLoopRef run_loop = nullptr; // run loop of the dedicated HID thread - std::thread run_loop_thread; // owns the dedicated HID run-loop thread + std::jthread run_loop_thread; // owns the dedicated HID run-loop thread macos_gamepad_t() = default; @@ -255,8 +249,7 @@ namespace platf { std::chrono::steady_clock::steady_clock::time_point last_mouse_event[3][2]; // timestamp of last mouse events // gamepad related stuff - static constexpr int MAX_GAMEPADS = platf::MAX_GAMEPADS; - std::array, MAX_GAMEPADS> gamepads {}; + std::array, platf::MAX_GAMEPADS> gamepads {}; // Guards the gamepads array. alloc_gamepad / free_gamepad / gamepad_update // can run on different threads (e.g. the control stream and task_pool // workers — the back→home button emulation calls gamepad_update from a @@ -552,30 +545,23 @@ const KeyCodeMap kKeyCodesMap[] = { BOOST_LOG(info) << "unicode: Unicode input not yet implemented for MacOS."sv; } - /** - * @brief Creates a virtual HID gamepad for the given slot. - * - * The macOS virtual gamepad is currently input-only: it emulates a single - * fixed device (a Razer Serval) and reports buttons/axes to the OS, but does - * not consume @p metadata or post anything to @p feedback_queue. As a result - * none of the feedback features other platforms support — rumble, trigger - * rumble, RGB LED, adaptive triggers, motion/battery (see gamepad_feedback_e - * and the inputtino/ViGEm backends) — are implemented here. The descriptor - * also has no output report, so OS-side SET_REPORTs (e.g. rumble) are not - * received. Wiring these up would require an output report in - * kGamepadHIDDescriptor plus a SET_REPORT callback on the run loop. - * - * @param input The global input context. - * @param id The gamepad ID (globalIndex used as the slot). - * @param metadata Controller metadata from the client (currently unused). - * @param feedback_queue The queue for posting messages back to the client (currently unused). - * @return 0 on success, -1 on failure. - */ + // Creates a virtual HID gamepad for the given slot (documented in + // src/platform/common.h, the shared platf:: interface). + // + // The macOS virtual gamepad is currently input-only: it emulates a single + // fixed device (a Razer Serval) and reports buttons/axes to the OS, but does + // not consume the arrival metadata or post anything to the feedback queue. As + // a result none of the feedback features other platforms support — rumble, + // trigger rumble, RGB LED, adaptive triggers, motion/battery (see + // gamepad_feedback_e and the inputtino/ViGEm backends) — are implemented here. + // The descriptor also has no output report, so OS-side SET_REPORTs (e.g. + // rumble) are not received. Wiring these up would require an output report in + // kGamepadHIDDescriptor plus a SET_REPORT callback on the run loop. int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) { auto *macos_input = static_cast(input.get()); const int nr = id.globalIndex; - if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + if (nr < 0 || nr >= platf::MAX_GAMEPADS) { BOOST_LOG(error) << "alloc_gamepad: slot " << nr << " out of range"; return -1; } @@ -588,7 +574,7 @@ const KeyCodeMap kKeyCodesMap[] = { // free_gamepad takes gamepads_mutex itself, so don't hold it across this. bool occupied; { - std::lock_guard lock(macos_input->gamepads_mutex); + std::scoped_lock lock(macos_input->gamepads_mutex); occupied = static_cast(macos_input->gamepads[nr]); } if (occupied) { @@ -612,20 +598,20 @@ const KeyCodeMap kKeyCodesMap[] = { CFRelease(num); }; - set_int32(CFSTR(kIOHIDPrimaryUsagePageKey), kHIDPage_GenericDesktop); - set_int32(CFSTR(kIOHIDPrimaryUsageKey), kHIDUsage_GD_GamePad); + set_int32(CFSTR("PrimaryUsagePage"), 0x01); // Generic Desktop usage page + set_int32(CFSTR("PrimaryUsage"), 0x05); // Gamepad usage // Embed the HID report descriptor CFDataRef descriptor = CFDataCreate(kCFAllocatorDefault, kGamepadHIDDescriptor, sizeof(kGamepadHIDDescriptor)); - CFDictionarySetValue(props, CFSTR(kIOHIDReportDescriptorKey), descriptor); + CFDictionarySetValue(props, CFSTR("ReportDescriptor"), descriptor); CFRelease(descriptor); // Vendor/product identity — see the kGamepadHIDDescriptor comment for why // we emulate a Razer Serval (0x1532/0x0900). - CFDictionarySetValue(props, CFSTR(kIOHIDManufacturerKey), CFSTR("Razer")); - CFDictionarySetValue(props, CFSTR(kIOHIDProductKey), CFSTR("Razer Serval")); - set_int32(CFSTR(kIOHIDVendorIDKey), 0x1532); // Razer - set_int32(CFSTR(kIOHIDProductIDKey), 0x0900); // Serval + CFDictionarySetValue(props, CFSTR("Manufacturer"), CFSTR("Razer")); + CFDictionarySetValue(props, CFSTR("Product"), CFSTR("Razer Serval")); + set_int32(CFSTR("VendorID"), 0x1532); // Razer + set_int32(CFSTR("ProductID"), 0x0900); // Serval IOHIDUserDeviceRef device = IOHIDUserDeviceCreate(kCFAllocatorDefault, props); CFRelease(props); @@ -647,19 +633,19 @@ const KeyCodeMap kKeyCodesMap[] = { gp->report.report_id = 1; gp->report.hat = 8; // null state (no D-pad direction) - // Spinning up the thread (or, in the worst case, taking the lock) can throw + // Spinning up the thread (or taking the lock) can throw std::system_error // under resource exhaustion. Callers only inspect the return code, so - // translate any failure into -1 rather than letting it escape. If we throw + // translate that failure into -1 rather than letting it escape. If we throw // here, gp's destructor releases the device (and joins the thread if it was // already started), so nothing leaks. try { std::promise rl_promise; auto rl_future = rl_promise.get_future(); - gp->run_loop_thread = std::thread([device, nr, promise = std::move(rl_promise)]() mutable { + gp->run_loop_thread = std::jthread([device, nr, promise = std::move(rl_promise)]() mutable { // Name the thread so it's identifiable in Console/Instruments/lldb - // (repo convention; up to MAX_GAMEPADS of these can exist at once). - set_thread_name("gamepad::hid[" + std::to_string(nr) + "]"); + // (repo convention; up to platf::MAX_GAMEPADS of these can exist at once). + set_thread_name(std::format("gamepad::hid[{}]", nr)); CFRunLoopRef rl = CFRunLoopGetCurrent(); IOHIDUserDeviceScheduleWithRunLoop(device, rl, kCFRunLoopDefaultMode); promise.set_value(rl); // hand run loop ref back to alloc_gamepad @@ -683,9 +669,9 @@ const KeyCodeMap kKeyCodesMap[] = { // assignment therefore never destroys a live device, i.e. never joins a // run-loop thread while holding the lock — the one thing free_gamepad // takes care to avoid. - std::lock_guard lock(macos_input->gamepads_mutex); + std::scoped_lock lock(macos_input->gamepads_mutex); macos_input->gamepads[nr] = std::move(gp); - } catch (const std::exception &e) { + } catch (const std::system_error &e) { BOOST_LOG(error) << "alloc_gamepad: failed to start HID run-loop thread for slot " << nr << ": " << e.what(); return -1; } @@ -694,14 +680,10 @@ const KeyCodeMap kKeyCodesMap[] = { return 0; } - /** - * @brief Destroys the virtual HID gamepad in the given slot. - * @param input The global input context. - * @param nr The global gamepad slot index. - */ + // Destroys the virtual HID gamepad in the given slot (documented in src/platform/common.h). void free_gamepad(input_t &input, int nr) { auto *macos_input = static_cast(input.get()); - if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + if (nr < 0 || nr >= platf::MAX_GAMEPADS) { return; } @@ -711,7 +693,7 @@ const KeyCodeMap kKeyCodesMap[] = { // (that would block gamepad_update for the whole teardown). std::unique_ptr doomed; { - std::lock_guard lock(macos_input->gamepads_mutex); + std::scoped_lock lock(macos_input->gamepads_mutex); doomed = std::move(macos_input->gamepads[nr]); } @@ -736,6 +718,47 @@ const KeyCodeMap kKeyCodesMap[] = { return v == INT16_MIN ? INT16_MAX : static_cast(-v); } + /** + * @brief Computes the HID HAT-switch value for the current D-pad button state. + * + * The HAT encodes eight directions running clockwise from North; a value of + * eight means centered. Opposing presses (e.g. up and down together) cancel. + * + * @param buttonFlags The Moonlight button bitmask. + * @return The HAT value: zero through seven for a direction, eight for centered. + */ + static uint8_t compute_dpad_hat(std::uint32_t buttonFlags) { + const bool up = buttonFlags & DPAD_UP; + const bool down = buttonFlags & DPAD_DOWN; + const bool left = buttonFlags & DPAD_LEFT; + const bool right = buttonFlags & DPAD_RIGHT; + if (up && !down) { + if (right) { + return 1; // NE + } + if (left) { + return 7; // NW + } + return 0; // N + } + if (down && !up) { + if (right) { + return 3; // SE + } + if (left) { + return 5; // SW + } + return 4; // S + } + if (right && !left) { + return 2; // E + } + if (left && !right) { + return 6; // W + } + return 8; // centered + } + gamepad_hid_report_t map_gamepad_state_to_hid_report(const gamepad_state_t &gamepad_state) { gamepad_hid_report_t report {}; report.report_id = 1; @@ -779,24 +802,7 @@ const KeyCodeMap kKeyCodesMap[] = { report.buttons |= (1 << 10); // b10 RS } - // D-pad as HAT switch: 0=N 1=NE 2=E 3=SE 4=S 5=SW 6=W 7=NW 8=null - { - const bool up = gamepad_state.buttonFlags & DPAD_UP; - const bool down = gamepad_state.buttonFlags & DPAD_DOWN; - const bool left = gamepad_state.buttonFlags & DPAD_LEFT; - const bool right = gamepad_state.buttonFlags & DPAD_RIGHT; - uint8_t hat = 8; // null (no direction) - if (up && !down) { - hat = right ? 1 : (left ? 7 : 0); // NE / NW / N - } else if (down && !up) { - hat = right ? 3 : (left ? 5 : 4); // SE / SW / S - } else if (right && !left) { - hat = 2; // E - } else if (left && !right) { - hat = 6; // W - } - report.hat = hat; - } + report.hat = compute_dpad_hat(gamepad_state.buttonFlags); // Sticks: pass through as-is (-32768..32767); Y axes are negated to match // HID convention where up is the negative direction. @@ -814,20 +820,12 @@ const KeyCodeMap kKeyCodesMap[] = { return report; } - /** - * @brief Sends a gamepad state update as a HID report. - * - * Maps the Moonlight button flags and axis values from @p gamepad_state into - * the packed HID report (see map_gamepad_state_to_hid_report) and submits it - * to the virtual device. - * - * @param input The global input context. - * @param nr The global gamepad slot index. - * @param gamepad_state The current gamepad state from the client. - */ + // Sends a gamepad state update as a HID report (documented in src/platform/common.h): + // maps the Moonlight button flags and axis values into the packed HID report + // (see map_gamepad_state_to_hid_report) and submits it to the virtual device. void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) { auto *macos_input = static_cast(input.get()); - if (nr < 0 || nr >= macos_input_t::MAX_GAMEPADS) { + if (nr < 0 || nr >= platf::MAX_GAMEPADS) { return; } @@ -835,7 +833,7 @@ const KeyCodeMap kKeyCodesMap[] = { // and destroying the device between the null-check and HandleReport. The // HID submission is a fast syscall, and updates for a single gamepad are // serial anyway, so this does not throttle the input path. - std::lock_guard lock(macos_input->gamepads_mutex); + std::scoped_lock lock(macos_input->gamepads_mutex); if (!macos_input->gamepads[nr]) { return; } @@ -1083,29 +1081,17 @@ const KeyCodeMap kKeyCodesMap[] = { // Unimplemented feature - platform_caps::pen_touch } - /** - * @brief Sends a gamepad touch event to the OS. - * @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. void gamepad_touch(input_t &input, const gamepad_touch_t &touch) { // Unimplemented feature - platform_caps::controller_touch } - /** - * @brief Sends a gamepad motion event to the OS. - * @param input The global input context. - * @param motion The motion event. - */ + // Sends a gamepad motion event to the OS (documented in src/platform/common.h). Unimplemented on macOS. void gamepad_motion(input_t &input, const gamepad_motion_t &motion) { // Unimplemented } - /** - * @brief Sends a gamepad battery event to the OS. - * @param input The global input context. - * @param battery The battery event. - */ + // Sends a gamepad battery event to the OS (documented in src/platform/common.h). Unimplemented on macOS. void gamepad_battery(input_t &input, const gamepad_battery_t &battery) { // Unimplemented } From c01a0ab22e14145f694e209f42b920c7ff135681 Mon Sep 17 00:00:00 2001 From: SayHi044 <80337809+SayHi044@users.noreply.github.com> Date: Fri, 22 May 2026 23:11:33 +0200 Subject: [PATCH 3/5] fix(macos): use std::thread with NOSONAR instead of std::jthread 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. --- src/platform/macos/input.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 826022f2e86..fa95121ea8e 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -180,7 +180,7 @@ struct macos_gamepad_t { IOHIDUserDeviceRef hid_device = nullptr; platf::gamepad_hid_report_t report {}; CFRunLoopRef run_loop = nullptr; // run loop of the dedicated HID thread - std::jthread run_loop_thread; // owns the dedicated HID run-loop thread + std::thread run_loop_thread; // NOSONAR(cpp:S6168) - std::jthread is unavailable on the older macOS / AppleClang libc++ we support; owns the HID run-loop thread macos_gamepad_t() = default; @@ -642,7 +642,7 @@ const KeyCodeMap kKeyCodesMap[] = { std::promise rl_promise; auto rl_future = rl_promise.get_future(); - gp->run_loop_thread = std::jthread([device, nr, promise = std::move(rl_promise)]() mutable { + gp->run_loop_thread = std::thread([device, nr, promise = std::move(rl_promise)]() mutable { // NOSONAR(cpp:S6168) - std::jthread is unavailable on the older macOS / AppleClang libc++ we support // Name the thread so it's identifiable in Console/Instruments/lldb // (repo convention; up to platf::MAX_GAMEPADS of these can exist at once). set_thread_name(std::format("gamepad::hid[{}]", nr)); From a4994b8111e9775cdc479ce4ede56affc3003183 Mon Sep 17 00:00:00 2001 From: SayHi044 <80337809+SayHi044@users.noreply.github.com> Date: Sat, 23 May 2026 22:10:55 +0200 Subject: [PATCH 4/5] chore(macos): tidy-up docs and comments --- docs/building.md | 7 +++++-- docs/getting_started.md | 3 --- gh-pages-template/_data/features.yml | 2 +- src/platform/macos/input.cpp | 10 ---------- src_assets/macos/build/sunshine.entitlements | 5 +++-- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/docs/building.md b/docs/building.md index 57786329df7..ee6c99496d0 100644 --- a/docs/building.md +++ b/docs/building.md @@ -236,8 +236,11 @@ ninja -C build ### 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. +which requires the `com.apple.hid.manager.user-access-device` entitlement. Builds that +don't carry it (Homebrew, unsigned PR/CI builds) still run normally — `IOHIDUserDeviceCreate` +fails, the gamepad is simply unavailable, and the rest of Sunshine is unaffected. AMFI only +terminates Sunshine when a build *declares* this restricted entitlement under a signature +that isn't authorized to use it (see below). The entitlements are defined in `src_assets/macos/build/sunshine.entitlements` and are applied automatically when the `.app` is signed (when `SHOULD_SIGN=true`). diff --git a/docs/getting_started.md b/docs/getting_started.md index 1e3582c4d56..960feae534b 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -305,9 +305,6 @@ brew uninstall sunshine ### macOS -> [!IMPORTANT] -> Sunshine on macOS is experimental. Gamepads do not work. - #### DMG ##### Install diff --git a/gh-pages-template/_data/features.yml b/gh-pages-template/_data/features.yml index 595fa3288f2..9918072138f 100644 --- a/gh-pages-template/_data/features.yml +++ b/gh-pages-template/_data/features.yml @@ -31,7 +31,7 @@ Use nearly any controller on your Moonlight client!
  • Nintendo Switch emulation is only available on Linux.
  • -
  • Gamepad emulation is not currently supported on macOS.
  • +
  • On macOS, only a generic gamepad is emulated, since an Xbox gamepad cannot be properly emulated on it.
- title: "Configurable" diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index fa95121ea8e..31e7ba4e1b8 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -545,9 +545,6 @@ const KeyCodeMap kKeyCodesMap[] = { BOOST_LOG(info) << "unicode: Unicode input not yet implemented for MacOS."sv; } - // Creates a virtual HID gamepad for the given slot (documented in - // src/platform/common.h, the shared platf:: interface). - // // The macOS virtual gamepad is currently input-only: it emulates a single // fixed device (a Razer Serval) and reports buttons/axes to the OS, but does // not consume the arrival metadata or post anything to the feedback queue. As @@ -680,7 +677,6 @@ const KeyCodeMap kKeyCodesMap[] = { return 0; } - // Destroys the virtual HID gamepad in the given slot (documented in src/platform/common.h). void free_gamepad(input_t &input, int nr) { auto *macos_input = static_cast(input.get()); if (nr < 0 || nr >= platf::MAX_GAMEPADS) { @@ -820,9 +816,6 @@ const KeyCodeMap kKeyCodesMap[] = { return report; } - // Sends a gamepad state update as a HID report (documented in src/platform/common.h): - // maps the Moonlight button flags and axis values into the packed HID report - // (see map_gamepad_state_to_hid_report) and submits it to the virtual device. void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) { auto *macos_input = static_cast(input.get()); if (nr < 0 || nr >= platf::MAX_GAMEPADS) { @@ -1081,17 +1074,14 @@ const KeyCodeMap kKeyCodesMap[] = { // Unimplemented feature - platform_caps::pen_touch } - // Sends a gamepad touch event to the OS (documented in src/platform/common.h). Unimplemented on macOS. void gamepad_touch(input_t &input, const gamepad_touch_t &touch) { // Unimplemented feature - platform_caps::controller_touch } - // Sends a gamepad motion event to the OS (documented in src/platform/common.h). Unimplemented on macOS. void gamepad_motion(input_t &input, const gamepad_motion_t &motion) { // Unimplemented } - // Sends a gamepad battery event to the OS (documented in src/platform/common.h). Unimplemented on macOS. void gamepad_battery(input_t &input, const gamepad_battery_t &battery) { // Unimplemented } diff --git a/src_assets/macos/build/sunshine.entitlements b/src_assets/macos/build/sunshine.entitlements index d5a5b944521..ec029c99b11 100644 --- a/src_assets/macos/build/sunshine.entitlements +++ b/src_assets/macos/build/sunshine.entitlements @@ -5,8 +5,9 @@ - com.apple.hid.manager.user-access-device + com.apple.developer.hid.virtual.device