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!
- 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 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__