master ohmu/fastfetch-openbsd / src / detection / gamepad / gamepad_apple.m
ohmu  ·  2026-10-15
  1#include "gamepad.h"
  2#include "common/apple/cf_helpers.h"
  3
  4#include <IOKit/IOKitLib.h>
  5#include <IOKit/hid/IOHIDLib.h>
  6#include <GameController/GameController.h>
  7
  8#include <math.h>
  9
 10// macOS offers two overlapping views of the same controller, and neither replaces the other:
 11//
 12// * `IOHIDManager` sees every HID device whose primary usage is a joystick or a gamepad, third
 13//   party pads included. It is the only source of the manufacturer, the product and the serial
 14//   number.
 15// * `GCController` is the GameController framework's view of the pads Apple has whitelisted -- the
 16//   official Sony, Microsoft, Nintendo and MFi ones. It is the only source of a battery level
 17//   (`GCDeviceBattery`, macOS 11.0+), and it is what the system's own games read.
 18//
 19// Neither view replaces the other, so each contributes whole entries rather than a name from one and
 20// a battery from the other. Nothing on the public surface can pair them: `GCDevice` carries only the
 21// display strings `vendorName` and `productCategory`, `+[GCController supportsHIDDevice:]` answers in
 22// the other direction only, and SDL -- the reference implementation for all of this -- does not pair
 23// them either ("we don't have an easy way to know if those devices correspond to a specific
 24// GCController", `SDL_mfijoystick.m`).
 25//
 26// The serial is the one field both views carry, so it is also the one field that would otherwise be
 27// spelled two different ways for the same pad depending on which pass contributed the entry. It is
 28// joined through a private property -- see the `GCController()` category below for what was measured
 29// -- and the join only *borrows* the serial. The entries themselves stay separate.
 30//
 31// ⚠️ `[GCController controllers]` is filled in by the private `_GCControllerManager` once it is
 32// told that the application became active, which a process that never activates -- a command line
 33// tool -- never tells it. SDL records the symptom for macOS 11.0.1 ("this always returns an empty
 34// array"). The array may therefore legitimately be empty here, so the IOHID pass only skips a
 35// device when the framework actually contributed something: with an empty array there is no
 36// duplicate to avoid, and skipping would drop the device from the output altogether.
 37//
 38// The array also does not fill on its own. Measured on this machine with a Pro Controller attached:
 39// it reads 0 at process start, is *still* 0 after 20 ms of unrelated work with no run loop, and
 40// only becomes non-empty ~10.7 ms after the first `CFRunLoopRunInMode` call -- the request is sent
 41// by the run loop turn, not by the property read. Since nothing else in this file pumps, waiting for
 42// the framework has to be explicit, which is what `GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS` and the
 43// pump loop in `findGameControllerDevices()` are for. Without them this whole pass contributes
 44// nothing and macOS never shows a battery.
 45//
 46// Merely reading `GCController.controllers` is what starts the framework -- it connects to
 47// `gamecontrollerd` on first touch -- so that read is *not* hoisted to the top of
 48// `ffDetectGamepad()`. It is therefore paid only on the path where the framework has something to
 49// answer for. Measured on this machine with no controller attached, module time via `--stat`:
 50//
 51//     nothing hoisted .................  0.3 ms
 52//     the read only ...................  8.3 ms
 53//     the read plus the run loop turn . 14.3 ms
 54//
 55// Both halves are expensive and neither is optional: the read is the ignition and the turn is what
 56// carries the request. The turn is nominally bounded by 0.5 ms, but `returnAfterSourceHandled` does
 57// not bound the handler, so it adds ~6 ms of its own. Paying 14 ms on a machine with no pad at all
 58// -- to learn what `supportsHIDDevice:` below already answered -- is why the kick is gone rather
 59// than merely deferred.
 60#define GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS 0.05
 61
 62@interface GCController()
 63    // Not in the public header, and the only identity the framework exposes at all: the public
 64    // `GCDevice` surface has just the two display strings, so two pads of the same model are
 65    // indistinguishable through it -- no vendor id, no product id, no serial.
 66    //
 67    // On the Bluetooth pad this was measured with, the description is the transport address, which is
 68    // the very string IOKit reports as `kIOHIDSerialNumberKey`:
 69    //
 70    //     GCController.identifier   LOGICAL_DEVICE(5c-52-1e-88-7e-70)
 71    //     IOHIDDevice serial        5C:52:1E:88:7E:70
 72    //
 73    // `persistentIdentifier` answered the same string on that pad and is deliberately not used: its
 74    // name promises stability across launches, which is a different contract from "the address this
 75    // pad has right now". `physicalDeviceUniqueID` is nil there, and `deviceHash` is a per-process
 76    // hash, so neither is something that could be looked up in the IOKit set.
 77    @property (readonly, nonatomic) NSString* identifier;
 78@end
 79
 80// ---------------------------------------------------------------------------------------------
 81// Joining the two views
 82// ---------------------------------------------------------------------------------------------
 83//
 84// The serial is borrowed rather than guessed. The framework's own spelling of the address is the join
 85// key, and both sides are normalised before they are compared.
 86//
 87// Only that one pad has been measured: the wrapper format is private, so a pad that reports its
 88// identity in some other shape -- a wired pad with a real serial number, say -- fails to match. That
 89// is the intended failure mode, and it is why this only ever fills in a field that would otherwise be
 90// empty: no match means an empty serial, never a wrong one.
 91
 92// `LOGICAL_DEVICE(5c-52-1e-88-7e-70)` -> `5c-52-1e-88-7e-70`. Anything without a parenthesised part
 93// is rejected outright rather than guessed at, because the wrapper is private formatting that can
 94// change without notice.
 95static bool extractIdentityToken(NSString* identifier, FFstrbuf* result) {
 96    // The property is declared opaque in the framework's metadata -- `NSObject<NSCopying,
 97    // NSSecureCoding>` -- but the value is a plain string, so it is used as-is: measured on this pad
 98    // the object is an `__NSCFString` inheriting `NSMutableString`, and `description` returns the
 99    // same 33 characters. Anything that is not a string is rejected rather than stringified, because
100    // the wrapper format is private either way and a non-string value would mean it changed.
101    if (![identifier isKindOfClass: NSString.class]) {
102        return false;
103    }
104    NSRange open = [identifier rangeOfString: @"("];
105    if (open.location == NSNotFound) {
106        return false;
107    }
108    NSRange close = [identifier rangeOfString: @")" options: 0 range: NSMakeRange(open.location + 1, identifier.length - open.location - 1)];
109    if (close.location == NSNotFound) {
110        return false;
111    }
112    NSString* token = [identifier substringWithRange: NSMakeRange(open.location + 1, close.location - open.location - 1)];
113    ffStrbufSetS(result, token.UTF8String);
114    return result->length > 0;
115}
116
117// Drop every separator and fold to upper case, so `5c-52-1e-88-7e-70` and `5C:52:1E:88:7E:70` -- the
118// two spellings of one address above -- compare equal.
119static void stripIdentitySeparators(FFstrbuf* strbuf) {
120    uint32_t out = 0;
121    for (uint32_t i = 0; i < strbuf->length; ++i) {
122        char c = strbuf->chars[i];
123        if (c >= '0' && c <= '9') {
124            strbuf->chars[out++] = c;
125        } else if (c >= 'a' && c <= 'f') {
126            strbuf->chars[out++] = (char) (c - 'a' + 'A');
127        } else if (c >= 'A' && c <= 'F') {
128            strbuf->chars[out++] = c;
129        }
130    }
131    strbuf->chars[out] = '\0';
132    strbuf->length = out;
133}
134
135// The IOKit device the framework is describing, or `nullptr` when the two cannot be matched. The
136// returned ref is owned by `set`, which the caller holds for the rest of the call.
137static IOHIDDeviceRef findHIDDeviceForController(GCController* controller, NSSet* set) {
138    if (set == nil || ![controller respondsToSelector: @selector(identifier)]) {
139        return nullptr;
140    }
141
142    FF_STRBUF_AUTO_DESTROY identity = ffStrbufCreate();
143    if (!extractIdentityToken(controller.identifier, &identity)) {
144        return nullptr;
145    }
146    stripIdentitySeparators(&identity);
147
148    for (id value in set) {
149        IOHIDDeviceRef device = (__bridge IOHIDDeviceRef) value;
150        FF_STRBUF_AUTO_DESTROY serial = ffStrbufCreate();
151        ffCfStrGetString(IOHIDDeviceGetProperty(device, CFSTR(kIOHIDSerialNumberKey)), &serial);
152        stripIdentitySeparators(&serial);
153        if (serial.length > 0 && ffStrbufEqual(&serial, &identity)) {
154            return device;
155        }
156    }
157    return nullptr;
158}
159
160typedef struct FFFindGamepadContext {
161    FFlist* devices;
162    uint32_t gameControllerCount;
163} FFFindGamepadContext;
164
165// The GameController framework's own devices, and how many of them there were. `set` is the IOKit
166// side, used only to borrow the serial -- see `findHIDDeviceForController()`.
167static uint32_t findGameControllerDevices(FFlist* devices, NSSet* set) {
168    if (@available(macOS 11.0, *)) {
169        // See the note at the top: the list stays empty until the run loop has been pumped once, and
170        // the timeout only bounds the case where the framework has nothing to report at all. The
171        // caller checks `supportsHIDDevice:` first, so a pad the framework does not claim never
172        // reaches this wait.
173        //
174        // The turn is handed the time that is left rather than 0, so it blocks until the framework's
175        // source fires or the deadline passes. A zero timeout returns as soon as the run loop has
176        // nothing pending, which turns this into a busy spin: measured with no pad and the loop
177        // forced to run its full timeout, `CFRunLoopRunInMode(mode, 0, true)` burned 50.4 ms of CPU
178        // in 50.0 ms of wall time (30k-40k turns), where passing the remaining time burned 0.08 ms
179        // in a single turn. The deadline is a soft bound either way -- the blocking turn overshot it
180        // by 3-5 ms in the same measurement.
181        NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow: GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS];
182        while (GCController.controllers.count == 0 && deadline.timeIntervalSinceNow > 0) {
183            CFRunLoopRunInMode(kCFRunLoopDefaultMode, deadline.timeIntervalSinceNow, true);
184        }
185
186        uint32_t count = 0;
187        @autoreleasepool {
188            for (GCController* controller in GCController.controllers) {
189                FFGamepadDevice* device = FF_LIST_ADD(FFGamepadDevice, *devices);
190                ffStrbufInit(&device->serial);
191                ffStrbufInit(&device->name);
192                device->battery = 0;
193
194                // The framework knows the pad but not its serial -- the public surface has no such
195                // field -- so it is borrowed from the IOKit device its private identity points at.
196                // Both passes then spell the same serial the same way, which is what makes the two
197                // entries comparable in the output.
198                IOHIDDeviceRef hidDevice = findHIDDeviceForController(controller, set);
199                if (hidDevice) {
200                    ffCfStrGetString(IOHIDDeviceGetProperty(hidDevice, CFSTR(kIOHIDSerialNumberKey)), &device->serial);
201                }
202
203                if (controller.productCategory) {
204                    ffStrbufSetS(&device->name, controller.productCategory.UTF8String);
205                } else if (controller.vendorName) {
206                    ffStrbufSetS(&device->name, controller.vendorName.UTF8String);
207                } else {
208                    ffStrbufSetS(&device->name, "MFi Gamepad");
209                }
210
211                // A wired pad has no battery object at all, and `batteryLevel` is `0.0` by default,
212                // so an unknown level cannot be told apart from a drained one. Both become the
213                // module's "0 means unknown", which hides the number rather than printing a wrong 0%.
214                GCDeviceBattery* battery = controller.battery;
215                if (battery) {
216                    int percent = (int) lroundf(battery.batteryLevel * 100.f);
217                    device->battery = (uint8_t) (percent < 0 ? 0 : (percent > 100 ? 100 : percent));
218                }
219
220                ++count;
221            }
222        }
223        return count;
224    }
225    return 0;
226}
227
228// Whether the framework would claim any of these devices. Unlike the list itself this is a local
229// predicate on the HID descriptor, not a daemon round trip, so it is the right thing to ask *before*
230// paying for the wait above. Measured on this machine: 0.3 ms warm, 0.9 ms as the very first
231// GameController call in a process -- and reading `GCController.controllers` immediately afterwards
232// still cost 6 ms. That is the point: the gate answers without starting the framework, so the
233// ~14 ms above is only ever paid by a pad Apple actually claims. A Logitech pad, or any pad outside
234// Apple's whitelist, fails this and skips the wait entirely instead of sitting through a timeout for
235// a list that will never contain it.
236static bool anyDeviceIsClaimedByGameController(NSSet* set) {
237    if (@available(macOS 11.0, *)) {
238        for (id value in set) {
239            if ([GCController supportsHIDDevice: (__bridge IOHIDDeviceRef) value]) {
240                return true;
241            }
242        }
243    }
244    return false;
245}
246
247static void enumSet(IOHIDDeviceRef value, FFFindGamepadContext* ctx) {
248    // A device the framework owns is already in the list under its own name; see the note at the
249    // top for why this is conditional on the framework having contributed anything at all.
250    if (ctx->gameControllerCount > 0) {
251        if (@available(macOS 11.0, *)) {
252            if ([GCController supportsHIDDevice:value]) {
253                return;
254            }
255        }
256    }
257
258    FFGamepadDevice* device = FF_LIST_ADD(FFGamepadDevice, *ctx->devices);
259    ffStrbufInit(&device->serial);
260    ffStrbufInit(&device->name);
261    device->battery = 0;
262
263    CFStringRef manufacturer = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDManufacturerKey));
264    ffCfStrGetString(manufacturer, &device->name);
265
266    CFStringRef product = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDProductKey));
267    if (device->name.length) {
268        ffCfStrGetString(product, &device->serial);
269        ffStrbufAppendC(&device->name, ' ');
270        ffStrbufAppend(&device->name, &device->serial);
271    } else {
272        ffCfStrGetString(product, &device->name);
273    }
274
275    CFStringRef serialNumber = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDSerialNumberKey));
276    ffCfStrGetString(serialNumber, &device->serial);
277}
278
279const char* ffDetectGamepad(FFlist* devices /* List of FFGamepadDevice */) {
280    FF_CFTYPE_AUTO_RELEASE IOHIDManagerRef manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
281    if (IOHIDManagerOpen(manager, kIOHIDOptionsTypeNone) != kIOReturnSuccess) {
282        return "IOHIDManagerOpen() failed";
283    }
284
285    // Built as Foundation collections and bridged for the call. The C spelling of this is four lines
286    // of `CFDictionaryCreate` with inline array casts around two numbers each; a literal says the same
287    // thing in two. `kIOHIDDevice*Key` are C string macros -- the old spelling had to wrap them in
288    // `CFSTR()` -- and `@()` boxes them straight to `NSString`.
289    //
290    // The old spelling was *not* leaking, tempting as that is to assume: the four `ffCfCreateInt()`
291    // numbers do hand a +1 reference to a dictionary that retains them and never give it back, but
292    // `CFNumberCreate()` answers with a tagged pointer for every int32 -- measured, all seven test
293    // values came back pointer-identical and 200k stranded references grew RSS by 0 KB. So this is a
294    // readability change, not a fix.
295    NSArray* matchings = @[
296        @{ @(kIOHIDDeviceUsagePageKey): @(kHIDPage_GenericDesktop), @(kIOHIDDeviceUsageKey): @(kHIDUsage_GD_Joystick) },
297        @{ @(kIOHIDDeviceUsagePageKey): @(kHIDPage_GenericDesktop), @(kIOHIDDeviceUsageKey): @(kHIDUsage_GD_GamePad) },
298    ];
299    IOHIDManagerSetDeviceMatchingMultiple(manager, (__bridge CFArrayRef) matchings);
300
301    // `IOHIDManagerCopyDevices()` follows the Create rule, so the set is handed to ARC rather than
302    // tracked with a cleanup attribute. It may also return NULL, which bridging turns into nil.
303    NSSet* set = CFBridgingRelease(IOHIDManagerCopyDevices(manager));
304
305    // The framework's entries come first, and the wait for them happens only when the framework has
306    // a reason to answer -- see `anyDeviceIsClaimedByGameController()`.
307    FFFindGamepadContext ctx = {
308        .devices = devices,
309        .gameControllerCount = anyDeviceIsClaimedByGameController(set) ? findGameControllerDevices(devices, set) : 0,
310    };
311
312    for (id value in set) {
313        enumSet((__bridge IOHIDDeviceRef) value, &ctx);
314    }
315    IOHIDManagerClose(manager, kIOHIDOptionsTypeNone);
316
317    return nullptr;
318}