diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 42a5dbe10f1..1bd68571976 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.developer.hid.virtual.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..b3d3d0ac4a3 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.developer.hid.virtual.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..754cbe92a22 100644 --- a/docs/building.md +++ b/docs/building.md @@ -234,6 +234,56 @@ ninja -C build }} } +### macOS code signing & entitlements +The macOS virtual gamepad publishes a virtual HID device via `IOHIDUserDeviceCreate`, +which requires the `com.apple.developer.hid.virtual.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`). + +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/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!
- title: "Configurable" diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 6eed2c1d365..31e7ba4e1b8 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -4,10 +4,17 @@ */ // standard includes #include +#include #include #include #include +#include +#include #include +#include +#include +#include +#include #include // platform includes @@ -22,14 +29,201 @@ #include "src/input.h" #include "src/logging.h" #include "src/platform/common.h" +#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. */ 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 a HAT switch (directions run clockwise from North; value eight means centered) + 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; // 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; + + // 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 +247,15 @@ 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 + 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 + // 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 +545,305 @@ const KeyCodeMap kKeyCodesMap[] = { BOOST_LOG(info) << "unicode: Unicode input not yet implemented for MacOS."sv; } + // 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) { - 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 >= platf::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::scoped_lock 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("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("ReportDescriptor"), descriptor); + CFRelease(descriptor); + + // Vendor/product identity — see the kGamepadHIDDescriptor comment for why + // we emulate a Razer Serval (0x1532/0x0900). + 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); + + 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 taking the lock) can throw std::system_error + // under resource exhaustion. Callers only inspect the return code, so + // 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 { // 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)); + 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::scoped_lock lock(macos_input->gamepads_mutex); + macos_input->gamepads[nr] = std::move(gp); + } 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; + } + + BOOST_LOG(info) << "alloc_gamepad: created virtual gamepad in slot " << nr; + return 0; } 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 >= platf::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::scoped_lock 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); + } + + /** + * @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; + + // 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 + } + + 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. + // 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; } 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 >= platf::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::scoped_lock 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: @@ -583,29 +1074,14 @@ 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. - */ 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. - */ 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. - */ void gamepad_battery(input_t &input, const gamepad_battery_t &battery) { // Unimplemented } @@ -637,10 +1113,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 +1153,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..18b626321fe --- /dev/null +++ b/src_assets/macos/build/sunshine.entitlements @@ -0,0 +1,21 @@ + + + + + + com.apple.developer.hid.virtual.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__