はじめに
macOS 用のデバイスドライバというと kext や DriverKit を思い浮かべますが、システムのドライバが掴んでいないインターフェースなら、普通のアプリ(ユーザー空間)から直接 USB を叩けます。署名の特別な entitlement も、SIP の無効化も要りません。
この記事では、macOS が標準サポートしていない Nintendo Switch 2 Pro Controller を題材に、
- macOS のドライバ実装の選択肢と、どれを選ぶかの判断基準
- IORegistry でデバイスを調べる方法
- IOUSBHost で Bulk 転送 → IOHIDManager で入力を読む実装
- 入力をキー/マウス操作に変換する、BLE で接続する
- 配布時の署名・TCC(プライバシー許可)の注意
をまとめます。
成果物:
- 最小サンプル(約200行、MIT): https://github.com/kakignu/switch2pro-macos-sample
- 完成版アプリ(メニューバー常駐、キー割り当て GUI 付き)の配布ページ: https://github.com/kakignu/Switch2ProBridge-releases
確認環境: Apple Silicon Mac / macOS 26 / Swift 5.9+(Swift Package)。最小デプロイ先は macOS 13。
macOS のドライバ実装、3 つの選択肢
| 方式 | 動く場所 | 必要なもの | 向いているケース |
|---|---|---|---|
| kext | カーネル | 起動セキュリティの緩和・ユーザー承認。Apple は非推奨 | 新規開発ではまず選ばない |
| DriverKit(dext) | ユーザー空間(専用プロセス) |
com.apple.developer.driverkit.transport.usb などの entitlement を Apple に申請。対象 VID の指定が必要で、他社製品の VID は通常もらえない
|
自社ハードを OS 全体に見せたい |
| ユーザー空間ライブラリ(IOKit / IOUSBHost / IOHIDManager / CoreBluetooth) | 普通のアプリ | 基本なし | システムのドライバが掴んでいないインターフェースを、自分のアプリ(や常駐アプリ)から使う |
他社製品をとりあえず Mac で使いたい、という個人開発ではほぼ 3 番目一択です。問題は「そのインターフェースは誰かに掴まれていないか」で、これは IORegistry を見れば分かります。
まず IORegistry を見る
デバイスを挿して ioreg で構成を確認します。
# USB デバイスのツリー(デバイス → インターフェース → ドライバ)
ioreg -p IOService -l -r -c IOUSBHostDevice -n "Pro Controller" | less
# インターフェースだけを一覧
ioreg -r -c IOUSBHostInterface -l | grep -E '"(bInterfaceNumber|bInterfaceClass|IOClass)"|\+-o'
見るポイントは 2 つです。
-
bInterfaceClass:0x03は HID、0xFFはベンダー定義 -
IOUSBHostInterfaceの下にドライバ(子ノード)がぶら下がっているか:AppleUserHIDDeviceなどが付いていれば掴まれている。何も付いていなければアプリから開ける
Switch 2 Pro Controller(VID 0x057E / PID 0x2069)の場合はこうなっていました。
IOUSBHostDevice "Pro Controller"
├─ IOUSBHostInterface #0 class 0x03 (HID) ← AppleUserHIDDevice が掴む
└─ IOUSBHostInterface #1 class 0xFF (Vendor) ← 誰も掴んでいない
EP 0x02 Bulk OUT / EP 0x82 Bulk IN (64B)
さらに、このコントローラーは挿しただけでは HID 入力を送ってきません。IF#1 に初期化コマンドを送ると、IF#0 から入力レポートが流れ始めます。つまり、
- IF#1(空いている)を IOUSBHost で開いて初期化コマンドを送る
- IF#0(macOS の HID ドライバが掴んでいる)は IOHIDManager で読む
という役割分担になります。IF#0 は掴まれているので IOUSBHost では開けませんが、HID として既に OS に見えているので、HID の API で読めば十分です。
プロトコル(コマンド形式・初期化手順・レポート形式)は ndeadly/switch2_controller_research と SDL3 の SDL_hidapi_switch2.c を参考にしました。
実装
Swift Package の構成
IOUSBHost は Objective-C の API なので、薄い Objective-C ラッパーを 1 ターゲット作り、Swift から呼びます。
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "Switch2ProMacOSSample",
platforms: [.macOS(.v13)],
targets: [
.target(
name: "USBBulk",
cSettings: [.unsafeFlags(["-fobjc-arc"])], // ← 重要
linkerSettings: [
.linkedFramework("IOKit"),
.linkedFramework("IOUSBHost"),
]
),
.executableTarget(
name: "switch2-sample",
dependencies: ["USBBulk"],
linkerSettings: [.linkedFramework("IOKit")]
),
]
)
SwiftPM の Objective-C ターゲットは ARC 無効がデフォルトです。-fobjc-arc を付けないと、ARC 前提で書いたコードがリークしたりクラッシュしたりします。
1. デバイスとインターフェースを探す
IOUSBHostDevice を列挙し、VID/PID が一致したデバイスの子から bInterfaceNumber == 1 の IOUSBHostInterface を探します。
func intProperty(_ entry: io_registry_entry_t, _ key: String) -> Int? {
IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0)?
.takeRetainedValue() as? Int
}
func next(_ iterator: io_iterator_t) -> io_object_t? {
let obj = IOIteratorNext(iterator)
return obj == 0 ? nil : obj
}
func findCommandInterface() -> io_service_t? {
var devices: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOUSBHostDevice"),
&devices) == KERN_SUCCESS else { return nil }
defer { IOObjectRelease(devices) }
while let device = next(devices) {
defer { IOObjectRelease(device) }
guard intProperty(device, "idVendor") == 0x057E,
intProperty(device, "idProduct") == 0x2069 else { continue }
var children: io_iterator_t = 0
guard IORegistryEntryGetChildIterator(device, kIOServicePlane, &children) == KERN_SUCCESS else { continue }
defer { IOObjectRelease(children) }
while let child = next(children) {
if IOObjectConformsTo(child, "IOUSBHostInterface") != 0,
intProperty(child, "bInterfaceNumber") == 1 {
return child // 呼び出し側で IOObjectRelease
}
IOObjectRelease(child)
}
}
return nil
}
ハマりどころが 2 つありました。
マッチングDictionaryに数値プロパティを入れるとマッチしなかった
IOServiceMatching のDictionaryに idVendor / idProduct を数値で入れると、実機では 1 件もマッチしませんでした(レジストリ側の OSNumber とビット幅が合わないためと推測)。クラス名だけでマッチして、VID/PID はコードで比較するのが確実です。
インターフェースはデバイスより少し遅れて登録される
デバイス検出直後に子を探すと、まだインターフェースが無いことがあります。0.25 秒間隔で数秒リトライしています。常駐アプリなら IOServiceAddMatchingNotification で抜き差しを監視し、検出時に同じ探索をします。
2. IOUSBHost で Bulk 送受信する
IOUSBHostInterface を開き、エンドポイントアドレスから IOUSBHostPipe を取り出して sendIORequestWithData: で同期送受信します。
- (nullable instancetype)initWithService:(io_service_t)service
outEndpoint:(uint8_t)outEP
inEndpoint:(uint8_t)inEP
error:(NSError **)error {
self = [super init];
if (!self) { return nil; }
// システムのドライバが掴んでいるインターフェースだとここで失敗する
_interface = [[IOUSBHostInterface alloc] initWithIOService:service
options:IOUSBHostObjectInitOptionsNone
queue:nil
error:error
interestHandler:nil];
if (!_interface) { return nil; }
_outPipe = [_interface copyPipeWithAddress:outEP error:error];
if (!_outPipe) { [self close]; return nil; }
_inPipe = [_interface copyPipeWithAddress:inEP error:error];
if (!_inPipe) { [self close]; return nil; }
return self;
}
- (nullable NSData *)sendCommand:(NSData *)command
maxResponseLength:(NSUInteger)maxResponseLength
timeout:(NSTimeInterval)timeout
error:(NSError **)error {
NSMutableData *outData = [command mutableCopy];
NSUInteger sent = 0;
if (![_outPipe sendIORequestWithData:outData bytesTransferred:&sent
completionTimeout:1.0 error:error]) {
return nil;
}
NSMutableData *response = [NSMutableData data];
while (response.length < maxResponseLength) {
// Bulk IN は wMaxPacketSize の倍数で要求する(オーバーラン防止)
NSMutableData *chunk = [NSMutableData dataWithLength:64];
NSUInteger got = 0;
if (![_inPipe sendIORequestWithData:chunk bytesTransferred:&got
completionTimeout:timeout error:nil]) {
break; // タイムアウト = 応答なし
}
[response appendBytes:chunk.bytes length:got];
if (got < 64) { break; } // ショートパケット = 転送終了
}
return response;
}
Swift 側からは try USBBulkInterface(service:outEndpoint:inEndpoint:)、try iface.sendCommand(_:maxResponseLength:timeout:) として呼べます(NSError ** を取るメソッドは Swift では throws になります)。
3. 初期化コマンドを送る
コマンドは 8 バイトのヘッダ + データです。
[0] cmd [1] 0x91 (host→device) [2] 0x00 (USB) / 0x01 (BLE)
[3] subcmd [4] 0x00 [5] データ長 [6..7] 0x00 [8..] データ
応答は [cmd] 0x01 0x00 [subcmd] 0x00 0xF8 … のような形で返ってきます(0xF8 が ACK)。
func command(_ cmd: UInt8, _ sub: UInt8, _ data: [UInt8] = []) -> [UInt8] {
[cmd, 0x91, 0x00, sub, 0x00, UInt8(data.count), 0x00, 0x00] + data
}
let initSequence: [(String, [UInt8])] = [
("07/01", command(0x07, 0x01)),
("feature mask", command(0x0C, 0x02, [0x27, 0, 0, 0])),
("11/01", command(0x11, 0x01)),
("vibration data", command(0x0A, 0x08, [0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x35, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00])),
("enable features", command(0x0C, 0x04, [0x27, 0, 0, 0])),
("01/0C", command(0x01, 0x0C)),
("01/01", command(0x01, 0x01)),
("select report 0x09", command(0x03, 0x0A, [0x09, 0, 0, 0])),
("initialise USB", command(0x03, 0x0D, [0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])),
("player LED 1", command(0x09, 0x07, [0x01, 0, 0, 0, 0, 0, 0, 0])),
]
順番と内容は SDL3 の実装に準拠しています(意味が分かっていないコマンドもあります)。1 点だけ SDL と変えていて、入力レポートに 0x09 を選んでいます(SDL は 0x05)。
なぜ report 0x09 か
USB の HID レポートディスクリプタ上、0x09 は「21 ボタン + X/Y/Rx/Rz(12bit)」の標準 Game Pad として記述されています。macOS 標準の HID ドライバがそのまま解釈できるので、自前のアプリだけでなく他のアプリからも普通のゲームパッドとして見えます。0x05 はベンダー定義で、自分で解析する以外に使い道がありません。
同じインターフェースでフラッシュ読み出し(02/01)もでき、スティックの工場較正値などが取れます(詳細はプロトコルメモ)。
4. IOHIDManager で入力を読む
初期化が終わると IF#0 から report 0x09 が流れてきます。これは IOHIDManager で読みます。
let reportBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 128)
let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
func startHID() {
IOHIDManagerSetDeviceMatching(manager, [
kIOHIDVendorIDKey: 0x057E,
kIOHIDProductIDKey: 0x2069,
kIOHIDPrimaryUsagePageKey: kHIDPage_GenericDesktop,
kIOHIDPrimaryUsageKey: kHIDUsage_GD_GamePad,
] as CFDictionary)
IOHIDManagerRegisterDeviceMatchingCallback(manager, { _, _, _, device in
IOHIDDeviceRegisterInputReportCallback(device, reportBuffer, 128, { _, _, _, _, _, report, length in
handleReport(Array(UnsafeBufferPointer(start: report, count: length)))
}, nil)
}, nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
// kIOHIDOptionsTypeNone = 非排他。ゲームや Steam も同時に読める
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
ポイント:
-
kIOHIDOptionsTypeSeizeDeviceで開くと排他になり、他のアプリから見えなくなります。ブリッジ用途なら非排他で開く -
IOHIDManagerはプロセスが生きている間ずっと参照を持っておく(ローカル変数にすると解放されてコールバックが来なくなる) - C の関数ポインタとして渡すクロージャはキャプチャできません。グローバル関数を呼ぶか、
contextにUnmanaged.passUnretained(self).toOpaque()を渡してインスタンスを取り出します - USB HID のコールバックでは先頭に report ID(0x09)が付いた 64 バイトが来ました
レポートの中身(report ID 込みのオフセット):
| offset | 内容 |
|---|---|
| 0 | report ID 0x09
|
| 1 | カウンタ |
| 2 | 電源(bit0 外部電源 / bit1 充電中 / bit2-5 残量 0-9) |
| 3-5 | ボタン 24bit |
| 6-8 | 左スティック(12bit × 2) |
| 9-11 | 右スティック(12bit × 2) |
let buttons = UInt32(r[3]) | UInt32(r[4]) << 8 | UInt32(r[5]) << 16
// 3 バイトに 12bit 値が 2 つ詰まっている
func stick(_ i: Int) -> (Int, Int) {
(Int(r[i]) | Int(r[i + 1] & 0x0F) << 8,
Int(r[i + 1] >> 4) | Int(r[i + 2]) << 4)
}
ボタンのビット割り当ては byte3 が B A Y X R ZR + RS、byte4 が ↓ → ← ↑ L ZL − LS、byte5 が HOME Capture GR GL C(LSB から)です。
ここまでで最小サンプルは完成です。
git clone https://github.com/kakignu/switch2pro-macos-sample.git
cd switch2pro-macos-sample
swift run -c release switch2-sample
アプリにするときの追加要素
最小サンプルから、実用アプリ(Switch2ProBridge)にするために足したものです。
抜き差しの監視
IOServiceAddMatchingNotification で kIOFirstMatchNotification / kIOTerminatedNotification を登録し、IONotificationPortSetDispatchQueue で専用キューに流します。挿された時に初期化を送り直さないと、HID 入力は止まったままです。
入力をキー/マウスに変換する(CGEvent)
macOS ネイティブのゲームでパッドを使えるようにする手段がないので(後述)、現実解としてキー/マウス操作に変換します。
let e = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)
e?.post(tap: .cghidEventTap)
CGEvent.post にはアクセシビリティ許可が必要です(AXIsProcessTrustedWithOptions でダイアログを出せます)。
アクセシビリティ許可はコード署名とパスに紐づく
ad-hoc 署名(codesign -s -)だとビルドのたびに署名が変わり、「ON にしているのに効かない」状態になります。Apple Development 証明書など安定した ID で署名し、/Applications の 1 か所に置いて起動するのが確実です。ad-hoc で開発するなら tccutil reset Accessibility <bundle id> で毎回リセットします。
Bluetooth LE
Switch 2 Pro Controller は BLE でも接続できますが、HOGP(HID over GATT)ではなく任天堂独自の GATT サービスです。そのため macOS の Bluetooth 設定からペアリングしても使えず、CoreBluetooth で自前実装します。
- SYNC ボタン長押しで、Manufacturer Data(会社 ID
0x0553、VID/PID 入り)付きの広告が出る →scanForPeripheralsで拾う - 入力レポートは Notify キャラクタリスティックで届く(USB と同じ 0x09 形式、report ID なし)
- コマンドは Write Without Response で送る(ヘッダの transport を
0x01に) - 標準の SMP ペアリングを始めると切断されるので、ペアリングはしない
CoreBluetooth を使うには Info.plist に NSBluetoothAlwaysUsageDescription が必要です。swift run で直接起動するとクラッシュするので、.app バンドルを組み立てて起動します。
.app バンドルを組み立てる
Xcode プロジェクトを作らなくても、SwiftPM でビルドしたバイナリを .app の形に置けば動きます。
swift build -c release --product Switch2ProBridge
BIN="$(swift build -c release --show-bin-path)/Switch2ProBridge"
APP=build/Switch2ProBridge.app
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/"
cp Resources/Info.plist "$APP/Contents/Info.plist" # LSUIElement, NSBluetoothAlwaysUsageDescription など
codesign --force --deep --sign "Apple Development: ..." "$APP"
メニューバー常駐にするなら Info.plist に LSUIElement = true、UI は SwiftUI の MenuBarExtra で書けます。
できなかったこと: Apple 純正のゲームコントローラーとして見せる
一番やりたかったのは「GameController.framework 対応のゲームでそのまま使う」ことですが、ユーザー空間からは無理でした。
- GameController.framework は既知のコントローラー(MFi / Xbox / DualSense / Switch Pro 初代 など)しか認識しない。Switch 2 Pro は 2026年10月時点で未対応
- 仮想 HID デバイス(CoreHID の
HIDVirtualDevice/IOHIDUserDevice)で既知コントローラーになりすます案は、com.apple.developer.hid.virtual.deviceという Apple 承認制の entitlement が必要。加えて Apple のエンジニアがフォーラムで「OS は仮想 HID デバイスを意図的に無視する」と回答している(Virtual Controllers on Mac) - DriverKit で USB デバイスとして見せる案は、任天堂 VID を指定した entitlement が個人では得られない(開発時は SIP 無効化が必要)
そのため、前述のキー/マウス割り当てを実用上の落としどころにしています。
配布: ソース非公開・Release だけ公開
今回はアプリ本体のソースを非公開にして、Release 専用の公開リポジトリを作り、そこにビルド済みの zip と README・技術資料(docs)だけを置いています。
- ソース: private リポジトリ
- 配布: public リポジトリ(README / docs / Releases)
- 手法の解説: この記事 + 最小サンプル(MIT)
証明書なし(ad-hoc 署名)で配るときにユーザーがやること
Developer ID 証明書(Apple Developer Program、年額有料)が無い場合、.app は ad-hoc 署名で配ることになります。この場合、利用者側で次の対応が必要になるので、配布ページに手順を書いておきます(使い方ガイド)。
| タイミング | 起きること | 対応 |
|---|---|---|
| 初回起動 | Gatekeeper が「“〜”は開いていません」と表示してブロック | システム設定 → プライバシーとセキュリティ →「このまま開く」 |
| アクセシビリティ等の許可 | 署名(cdhash)に紐づいて記録される | 通常どおり許可 |
| アップデート | 署名が変わるので以前の許可が無効化(設定画面では ON のまま) | 一覧から削除して許可し直す / tccutil reset
|
macOS 15 Sequoia 以降、Finder の右クリック →「開く」で Gatekeeper を回避する方法は廃止されました。 古い記事の手順は通用しないので、「プライバシーとセキュリティ」の「このまま開く」か、xattr -dr com.apple.quarantine <app> で隔離属性を外す方法を案内します。
また、Apple Silicon では署名が一切ないバイナリは起動できないので、最低でも codesign --sign -(ad-hoc)は必要です。Apple Development 証明書(無料の Apple ID でも Xcode で作れる)は開発機での実行用で、配布物を Gatekeeper に通すことはできません。ちゃんと配るなら Developer ID 署名 + 公証(notarytool)が必要です。
まとめ
- 他社 USB デバイスを Mac で動かすなら、まず
ioregでインターフェースが空いているかを見る - 空いていれば IOUSBHost でユーザー空間から直接 Bulk/Interrupt 転送できる(entitlement 不要)
- すでに HID として見えている部分は IOHIDManager で非排他に読む
- BLE は独自 GATT なら CoreBluetooth で自前実装
- OS 全体に「別の純正デバイス」として見せるのは entitlement の壁があり、個人ではほぼ不可能
kext や DriverKit に手を出す前に、ユーザー空間でどこまでできるかを確認してみてください。
参考
- ndeadly/switch2_controller_research — Switch 2 コントローラーのプロトコル調査
-
SDL3
SDL_hidapi_switch2.c— 初期化シーケンス - IOUSBHost | Apple Developer Documentation
- Apple Developer Forums: Virtual Controllers on Mac
- 最小サンプル: https://github.com/kakignu/switch2pro-macos-sample
- 配布ページ / 技術資料: https://github.com/kakignu/Switch2ProBridge-releases
※ Nintendo Switch は任天堂の商標です。本記事および各リポジトリは任天堂とは無関係の非公式なものです。