ControllerKeys Architecture
July 4, 2026 · View on GitHub
Technical reference for developers and AI agents working on this codebase.
Controller Input Pipeline
The app supports three controller types through different input backends, all normalizing to the same ControllerButton enum and callback interface:
| Controller Type | Backend | Detection |
|---|---|---|
| Xbox (One/Series/360) | GameController framework | Automatic via GCController |
| DualSense (PS5) | GameController framework + IOKit HID (for mic/LEDs/touchpad) | Automatic via GCController |
| Third-party (8BitDo, Logitech, etc.) | IOKit HID + SDL gamecontrollerdb | 1-second fallback if GameController doesn't claim |
All controller types feed into the same MappingEngine which handles button→action mapping, chords, long hold, double tap, macros, and system commands.
Generic HID Controller Fallback (Third-Party Controller Support)
Overview
Controllers not recognized by Apple's GameController framework are supported via IOKit HID + SDL's community-maintained gamecontrollerdb.txt database. This maps raw HID inputs to the Xbox-standard button layout, requiring zero manual configuration from users. Supports ~313 macOS controllers.
Detection Flow
Controller plugged in (USB/Bluetooth)
|
+-- IOKit HID manager fires genericDeviceAppeared()
| |
| +-- Start 1-second fallback timer
|
+-- GameController framework claims device within 1s
| +-- controllerConnected() cancels timer -> normal path (Xbox/DualSense)
|
+-- Timer fires (unclaimed) -> attemptGenericFallback()
|
+-- Construct GUID from vendor/product/version/transport
+-- Look up in GameControllerDatabase (tries exact version, then version=0)
|
+-- Found -> Create GenericHIDController -> wire callbacks -> activate
+-- Not found -> log GUID, ignore device
Key Files
| File | Purpose |
|---|---|
Services/GameControllerDatabase.swift | Parses SDL database, GUID construction, lookup, GitHub refresh |
Services/GenericHIDController.swift | IOKit HID element enumeration, input translation to ControllerButton |
Resources/gamecontrollerdb.txt | Bundled SDL database (~313 macOS controller mappings) |
Services/ControllerService.swift | Integration: HID manager setup, fallback timer, callback wiring |
SDL gamecontrollerdb.txt Format
Each line: GUID,Name,button:ref,button:ref,...,platform:Mac OS X,
GUID construction (little-endian 16-bit fields):
[bus][0000][vendor][0000][product][0000][version][0000]
- Bus: USB =
0300, Bluetooth =0500 - Values from IOKit:
kIOHIDVendorIDKey,kIOHIDProductIDKey,kIOHIDVersionNumberKey,kIOHIDTransportKey
Element references:
b0,b1, ... -- Button by index (sorted by HID Button page usage)a0,a1, ... -- Axis by index (sorted by GenericDesktop usage: X->a0, Y->a1, Z->a2, Rx->a3, Ry->a4, Rz->a5)h0.1,h0.2,h0.4,h0.8-- Hat switch directions (up/right/down/left bitmask)~a2-- Inverted axis+a1,-a1-- Half-axis (positive/negative direction only)
SDL to ControllerButton Mapping
| SDL Name | ControllerButton | SDL Name | ControllerButton |
|---|---|---|---|
| a | .a | leftshoulder | .leftBumper |
| b | .b | rightshoulder | .rightBumper |
| x | .x | dpup | .dpadUp |
| y | .y | dpdown | .dpadDown |
| start | .menu | dpleft | .dpadLeft |
| back | .view | dpright | .dpadRight |
| guide | .xbox | leftstick | .leftThumbstick |
| misc1 | .share | rightstick | .rightThumbstick |
Axes: leftx, lefty, rightx, righty (sticks), lefttrigger, righttrigger (triggers)
GenericHIDController Internals
Element enumeration (SDL-compatible sorting):
- Buttons:
kHIDPage_Buttonelements sorted by usage number -> b0, b1, b2... - Axes:
kHIDPage_GenericDesktopelements with usages X(0x30)->a0, Y(0x31)->a1, Z(0x32)->a2, Rx(0x33)->a3, Ry(0x34)->a4, Rz(0x35)->a5, sorted by usage - Hat: usage
kHIDUsage_GD_Hatswitch(0x39)
Axis normalization:
- Sticks:
(value - center) / (range/2)-> -1.0..1.0 - Triggers:
(value - logicalMin) / range-> 0.0..1.0 - Calibration from
IOHIDElementGetLogicalMin/Max
Hat switch: 8-position value (0-7) -> bitmask (1=up, 2=right, 4=down, 8=left). Value >= 8 = neutral.
Callbacks match ControllerService's existing interface:
onButtonAction: ((ControllerButton, Bool) -> Void)?onLeftStickMoved: ((Float, Float) -> Void)?onRightStickMoved: ((Float, Float) -> Void)?onLeftTriggerChanged: ((Float, Bool) -> Void)?onRightTriggerChanged: ((Float, Bool) -> Void)?
ControllerService Integration
Properties added to ControllerService:
genericHIDManager: IOHIDManager?-- Monitors gamepad/joystick device connectionsgenericHIDController: GenericHIDController?-- Active generic controller instancegenericHIDFallbackTimer: DispatchWorkItem?-- 1-second delay before fallback activationisGenericController: Bool-- Published; true when active controller is generic HID
Key behaviors:
setupGenericHIDMonitoring()-- Called ininit(), sets up IOKit HID manager matching GamePad (0x05) and Joystick (0x04) usage pagescontrollerConnected()-- Cancels fallback timer and stops any active generic controller (GameController takes priority)genericDeviceRemoved()-- CallscontrollerDisconnected()for cleanup- Generic controllers show Xbox layout in UI (no DualSense-specific features)
Database Storage
- Bundled:
Bundle.main.path(forResource: "gamecontrollerdb", ofType: "txt") - User-downloaded:
~/.controllerkeys/gamecontrollerdb.txt - Priority: user copy > bundled copy
- Refresh: Settings -> Third-Party Controllers -> Refresh (downloads from GitHub SDL_GameControllerDB)
Swift 6 / Concurrency Notes
- Free-standing C callback functions (
genericHIDDeviceMatched,genericHIDDeviceRemoved) must be markednonisolateddue to the project's-default-isolation=MainActorbuild setting - GenericHIDController uses
Unmanagedpointers for IOKit callback context (same pattern as XboxGuideMonitor) - Callbacks dispatch to
controllerQueueor main actor as appropriate
Known Workarounds
Accessibility Zoom (Control+Scroll)
Problem: macOS Accessibility Zoom ("Use scroll gesture with modifier keys to zoom") doesn't respond to synthetic CGEvent scroll events with Control modifier. Accessibility Zoom requires real trackpad gesture events with specific touch data structures.
Research findings:
- Real trackpad gestures contain proprietary touch data appended to CGEvent structures
- These touch data structures are undocumented by Apple
- Hammerspoon explored synthesizing these gestures (issue #1434, PR #2512)
- Gesture synthesis is complex and has limitations—not impossible, but difficult to get working reliably
Our solution: Convert Control+scroll to Accessibility Zoom keyboard shortcuts:
- Scroll up → Option+Command+= (zoom in)
- Scroll down → Option+Command+- (zoom out)
Implementation (InputSimulator.swift):
- Accumulates scroll delta with 10px threshold to prevent flooding
- Rate limited to max 20 zoom actions/second (50ms interval)
- Requires user to enable "Use keyboard shortcuts to zoom" in System Settings → Accessibility → Zoom
Why keyboard shortcuts instead of gesture synthesis?
- Gesture synthesis is complex and has known limitations
- Keyboard shortcuts provide the same functionality reliably
TriggerKit Integration (Macro Execution)
Macros execute through TriggerKit, Kevin's shared macOS automation package (also used by Tardy, Plaque, and TriggerKit.app). ControllerKeys supplies controller-button triggers; TriggerKit supplies the trigger-independent automation engine.
Architecture
Macro (MacroStep list, persisted in profile JSON)
|
v MacroAutomationBridge (conversion, total)
AutomationProgram (TriggerKit schema)
|
v TriggerKitRuntime.AutomationExecutor
+-- input steps -> TriggerKitInputAdapter -> InputSimulatorProtocol (CK's tuned simulator + mock seam)
+-- shell/webhook/OBS -> stepOverride hook -> SystemCommandExecutor (terminal selection, retry, keychain)
+-- delay/openApp/openURL -> TriggerKit native (http/https scheme allowlist)
Macro/MacroStepremain the persisted and UI model — no profile migration. Conversion happens at execution time inMacroAutomationBridge.automationProgram(for:).- Conversion is total. Synthetic marker key codes map to semantic TriggerKit steps where one exists (mouse clicks →
.mouseClick, unmodified scrolls →.mouseScroll, media keys share the same 0xF020–0xF041 codes). Markers with no TriggerKit equivalent (special actions, modified scrolls) pass through as raw-key strokes that the adapter routes back intoInputSimulator. - OBS WebSocket steps travel as TriggerKit
customsteps (namespacecontrollerkeys.obs-websocket) and are dispatched toSystemCommandExecutorvia the executor'sstepOverridehook. - Execution policy preserves pre-TriggerKit semantics:
.concurrent(macros never block each other),continuesOnStepFailure: true(a failing step logs and continues),validatesAccessibility: false(CK manages its own permission UX).
Shared macro library
ControllerKeys also consumes TriggerKit's per-user macro library (~/Library/Application Support/TriggerKit/macros.json, shared with TriggerKit.app, Tardy, and Plaque):
- One UUID namespace. A binding's
macroIdresolves profile macros first; an ID not inprofile.macrosis a shared-library reference (seeActionCommandFactory, priority 2). Every binding surface — buttons, chords, sequences, gestures, command wheel, layers — can reference shared macros with no model change. - Snapshot fallback (TriggerKit consumer contract).
Profile.sharedMacroSnapshotsembeds a copy of each referenced library macro, synced on everyProfileManager.updateProfile. Deleted library macro → bindings run the snapshot. Live macro emptied → bindings intentionally do nothing. Snapshots also make exported/community profiles portable. - Reference collection reuses
ProfileSurfaceVisitor(SharedMacroSnapshotPolicy), so new binding surfaces are covered automatically — andProfileImportSafetyAuditorwalks snapshot programs, so a community profile can't hide shell payloads inside a snapshot. - UI: macro pickers show a "Shared Library" section (
MacroPickerSections); the Macros tab manages the library through TriggerKitUI'sAutomationMacroLibraryView.ProfileManager.sharedLibraryMacrosrefreshes via.triggerKitMacrosChanged(including distributed notifications from other apps, which triggerAutomationMacroStore.reloadFromDisk()).
Vendored package
TriggerKit/ at the repo root is a vendored copy; the canonical source lives at ~/projects/triggerkit (local-only, no public remote). Develop TriggerKit changes there (swift test), then run Scripts/sync-triggerkit.sh and commit the result here. The Xcode project references it as a local Swift package (TriggerKitCore + TriggerKitRuntime linked into the app and unit-test targets).
Key files
| File | Purpose |
|---|---|
Services/Macros/MacroAutomationBridge.swift | MacroStep → AutomationStep conversion table, modifier-set mapping, marker translation |
Services/Macros/TriggerKitInputAdapter.swift | TriggerKit InputSimulating → CK InputSimulatorProtocol; reproduces pre-TriggerKit call sequences exactly |
Services/Macros/MacroExecutor.swift | Thin orchestrator: convert, then run through AutomationExecutor with the CK policy + step overrides |
XboxControllerMapperTests/MacroExecutorBehaviorTests.swift | Pins step → simulator call sequences (written pre-refactor, passes unchanged post-refactor) |
XboxControllerMapperTests/MacroAutomationBridgeTests.swift | Conversion-table coverage |
Performance
The controller hot paths (button → key press, joystick → mouse at 120Hz) never touch TriggerKit — MappingEngine and InputSimulator are unchanged. Macro execution adds one O(steps) conversion per macro trigger plus a MainActor task hop, replacing the previous detached-task spawn; input posting still happens on InputSimulator's dedicated queues.
Measured June 2026 (debug build, SharedMacroLatencyBenchmarkTests — medians):
| Path | Cost | Frequency |
|---|---|---|
makeCommand for a plain key press | 0.5–1.5µs | per button press (unchanged vs pre-TriggerKit) |
makeCommand for a profile macro | ~2µs | per macro trigger |
makeCommand for a shared library macro (store lookup + normalize) | ~7µs | per macro trigger |
| Bridge conversion, 10-step macro | ~8µs | per macro trigger (on inputQueue) |
Macro dispatch: execute() → first posted event | ~0.05ms median | per macro trigger |
Snapshot sync (SharedMacroSnapshotPolicy) on a ~150-binding profile | ~250µs | per profile save (UI edits only) |
Invariants the store integration maintains:
AutomationMacroStoreruns JSON encode/disk writes on a dedicatedioQueue, so thequeue.syncmacro lookup on button dispatch never waits behind file I/O.- The store's distributed change notification posts only after the debounced write lands, so cross-process observers never re-read a stale file; ControllerKeys performs the resulting
reloadFromDisk()off the main actor and only fires@Published sharedLibraryMacroswhen the list actually changed (26 views observe ProfileManager). TriggerKey.catalogKey(keyCode:)is a cached dictionary lookup (was an O(catalog) rebuild per converted key step).
Service Layer Overview
| Service | File | Responsibility |
|---|---|---|
ControllerService | Services/ControllerService.swift | Controller connection, raw input handling, haptics, battery, DualSense HID |
MappingEngine | Services/MappingEngine.swift | Button->action mapping, chords, long hold, double tap, macros, system commands |
ControllerInputEvent | Services/Mapping/ControllerInputEvent.swift | Event envelope and queue policy for MappingEngine controller callbacks |
InputSimulator | Services/InputSimulator.swift | CGEvent-based keyboard/mouse output |
ModifierKeyEmissionPolicy | Services/Input/ModifierKeyEmissionPolicy.swift | Pure side-aware modifier key selection for InputSimulator |
ProfileManager | Services/ProfileManager.swift | Profile CRUD, persistence, backup |
AppMonitor | Services/AppMonitor.swift | Active app detection for profile auto-switching |
XboxGuideMonitor | Services/XboxGuideMonitor.swift | IOKit HID for Xbox Guide button (swallowed by GameController) |
GameControllerDatabase | Services/GameControllerDatabase.swift | SDL database parsing and lookup |
GenericHIDController | Services/GenericHIDController.swift | Raw HID input for third-party controllers |
HIDControllerDriverDescriptor | Services/Controller/HIDControllerDriverDescriptor.swift | Testable raw-HID matching criteria for specialized controller backends |
OnScreenKeyboardManager | Services/OnScreenKeyboardManager.swift | Floating keyboard overlay window |
CommandWheelManager | Services/CommandWheelManager.swift | Radial menu for app/website switching |
InputLogService | Services/InputLogService.swift | Debug input logging |
SystemCommandExecutor | Services/SystemCommandExecutor.swift | Shell commands, app launch, URL open |
ControllerVisualDescriptor | Views/MainWindow/ControllerVisualDescriptor.swift | Preview capability/row descriptor for controller mapping canvas |
Threading Model
- controllerQueue (
DispatchQueue,.userInteractive): All button press/release logic, chord detection, mapping execution - Main thread: UI updates, IOKit HID manager run loop, GameController callbacks
- ControllerStorage (
NSLock): Thread-safe joystick/trigger/touchpad state shared between polling timer and input callbacks - Display update timer: 15Hz UI refresh for stick/trigger positions (vs ~120Hz internal polling)
Project Structure
XboxControllerMapper/
XboxControllerMapper/
Config.swift -- Constants, paths, keys
XboxControllerMapperApp.swift -- App entry point
Models/ -- Data types (ControllerButton, KeyMapping, Profile, etc.)
Services/ -- Business logic (see table above)
Utilities/ -- KeyCodeMapping, VariableExpander, HIDPropertyScanner
Views/
MainWindow/ -- ContentView, ButtonMappingSheet, ControllerVisualView
Components/ -- Reusable views (KeyCaptureField, FlowLayout, etc.)
Macros/ -- MacroListView, MacroEditorSheet
MenuBar/ -- MenuBarView
Resources/ -- gamecontrollerdb.txt, Assets
Build Configuration
- Xcode project (not workspace):
XboxControllerMapper/XboxControllerMapper.xcodeproj - Scheme:
XboxControllerMapper - Bundle ID: Uses
PBXFileSystemSynchronizedRootGroup(Xcode 16+) -- files in the project directory are auto-discovered, no manual "Add Files" needed - Deployment target: macOS 14.6
- Swift version: 5 with upcoming features (
NonisolatedNonsendingByDefault,MemberImportVisibility,InferSendableFromCaptures,DefaultIsolation=MainActor) - Team ID: 542GXYT5Z2
- Build command:
make install BUILD_FROM_SOURCE=1(kills running app, builds Release, copies to /Applications, launches)
Performance Profile (April 2026)
CPU Breakdown During Active Joystick Mouse Movement
Measured via sample and top on Apple M2 Ultra, macOS 26.2.
| Component | CPU % | Notes |
|---|---|---|
| CGEvent.post IPC | ~28% | Synchronous Mach IPC to WindowServer per event at 120Hz. Irreducible without reducing event rate. |
| 120Hz polling timer | ~2% | DispatchSource timer + snapshot reads + math. Very efficient. |
| SwiftUI layout passes | ~2-5% | NSDisplayCycleFlush → NSHostingView.layout() at 15Hz when analog values change. |
| GCDeviceSession callbacks | <1% | ~100Hz for DualSense motion data. |
| Total (window visible) | ~30-35% | |
| Total (window minimized) | ~10% | Display timer suspended; only polling + CGEvent.post. |
| Total (idle, no input) | ~1% | All timers quiescent. |
Key Findings
-
CGEvent.post is the dominant cost. Each
CGEvent.post(tap: .cghidEventTap)is a synchronous Mach message to WindowServer. At 120Hz, this is 120 kernel round-trips/sec. Thesampletool shows most mouse-queue time inmach_msg2_trapwaiting for WindowServer. This is the macOS cost of synthetic mouse movement — any app posting mouse events at this rate pays the same price. -
CGWarpMouseCursorPosition was the original top offender. Before removal, it consumed 98.7% of mouse-queue CPU (586/594 samples) — a redundant synchronous IPC call every frame in addition to the CGEvent that already positions the cursor.
-
@Published on high-frequency properties invalidates the entire view tree.
ControllerServiceis observed by 26 SwiftUI views via@EnvironmentObject. When any@Publishedproperty firesobjectWillChange, all 26 views re-evaluate their bodies — even if only 2 views read the changed property. Solved by usingCurrentValueSubjectfor analog display values. -
Window visibility check must exclude overlay panels. The display timer suspension logic checked
NSApp.windows.contains { \$0.isVisible }, but overlay panels (on-screen keyboard, laser pointer, etc.) remain visible when the main window is minimized. Fixed by filtering to$0.level == .normal. -
ProcessInfo.environment is expensive in hot paths. Copies the entire environment dictionary on every call. Must be cached at launch for values checked per-frame.
Profiling Commands
# CPU by process while moving joystick
top -l 5 -pid $(pgrep -x ControllerKeys) -stats pid,cpu,command | grep ControllerKeys
# Full call tree sample (5 seconds)
sample $(pgrep -x ControllerKeys) 5 -f /tmp/ck_sample.txt
# Thread breakdown
grep -E '^\s+[0-9]+ Thread' /tmp/ck_sample.txt
# App code hotspots
grep -oE '[A-Za-z_.]+\s+\(in ControllerKeys\)' /tmp/ck_sample.txt | sort | uniq -c | sort -rn | head -20
# SwiftUI layout cost
grep -oE '[0-9]+ NSDisplayCycleFlush' /tmp/ck_sample.txt | awk '{sum+=\$1} END {print sum}'
Future Optimization Opportunities
- Reduce mouse event rate to 60Hz with velocity-adaptive posting — post at 120Hz during slow/precise movement, drop to 60Hz during fast sweeps where individual frames aren't perceptible. Would halve IPC cost during fast movement.
- Separate NSHostingView for ControllerAnalogOverlay — isolates the analog overlay's
@Stateupdates from the main view tree's layout pass. - CALayer-based analog overlay — replace SwiftUI analog rendering with Core Graphics/CALayer. Layer property updates don't trigger view tree diffing.
Pref-verification gotcha
ControllerKeys is NOT sandboxed (entitlements set com.apple.security.app-sandbox = false). The leftover ~/Library/Containers/KevinTang.XboxControllerMapper/ dir is a red herring — real prefs live at ~/Library/Preferences/KevinTang.XboxControllerMapper.plist (defaults read KevinTang.XboxControllerMapper).
Do NOT verify a UI-pref change by defaults write then relaunching — cfprefsd caching makes the running app read a stale value even when defaults read shows the new one. Click the real control instead (synthetic Quartz CGEventPost clicks work). Also: codesign -d --entitlements - prints the app-sandbox key even when its value is false — use --xml … | plutil -p - for the actual boolean.
Project-file note: the Xcode project is objectVersion 77 (PBXFileSystemSynchronizedRootGroup) — Swift sources are auto-discovered; do NOT run add-xcode-files.py here (only frameworks need explicit PBXBuildFile entries).
Licensing & release internals
- Trial clock anchored in the login keychain (service
com.controllerkeys.license, accounttrialStart) so delete+reinstall does NOT reset the 14-day trial. License key +licensedConfirmedflag stored the same way; a confirmed license stays valid offline. - Gumroad verify: POSTs to
https://api.gumroad.com/v2/licenses/verifywithproduct_id=9AsXgsuZVzxgNYfoVcFW1A==(percent-encode so the trailing==survives),increment_uses_count=false; rejects refunded/disputed/chargebacked. The Gumroad product MUST have license keys enabled. - Enforcement:
LicenseManager.enforce { mappingEngine.isEnabled = false }inXboxControllerMapperApp.init(skipped in screenshot mode); fires at startup + on transition to expired. Dev builds compile inDEV_BYPASS_LICENSEviamake install; the release never sets it. - Sparkle:
SUFeedURL = https://raw.githubusercontent.com/NSEvent/xbox-controller-mapper/main/appcast.xml,SUPublicEDKey = UE8E+aypYDGJ7X8LznvFn1YBdENZDo5qrsX93K1/1Js=(private EdDSA key in Kevin's login keychain — back it up). CFBundleVersion (BUILD_NUMBER) MUST increase every release or Sparkle won't detect the update. - Notarization re-sign (do not remove):
Scripts/sign-and-notarize.shre-signs Sparkle's bundledUpdater.app,Autoupdate,XPCServices/*.xpcinside-out, re-signs the framework, then re-seals the app — Sparkle ships them with its own signature, which Apple notarization rejects. - Release pipeline:
Scripts/release.shEdDSA-signs the DMG from a clean staging dir (one-item feed), writes/commits/pushesappcast.xml, attaches the DMG to the GitHub release, and auto-bumps the homebrew tap cask (NSEvent/homebrew-tap, version + sha256). Public free download gated only by the in-app trial.