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 TypeBackendDetection
Xbox (One/Series/360)GameController frameworkAutomatic via GCController
DualSense (PS5)GameController framework + IOKit HID (for mic/LEDs/touchpad)Automatic via GCController
Third-party (8BitDo, Logitech, etc.)IOKit HID + SDL gamecontrollerdb1-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

FilePurpose
Services/GameControllerDatabase.swiftParses SDL database, GUID construction, lookup, GitHub refresh
Services/GenericHIDController.swiftIOKit HID element enumeration, input translation to ControllerButton
Resources/gamecontrollerdb.txtBundled SDL database (~313 macOS controller mappings)
Services/ControllerService.swiftIntegration: 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 NameControllerButtonSDL NameControllerButton
a.aleftshoulder.leftBumper
b.brightshoulder.rightBumper
x.xdpup.dpadUp
y.ydpdown.dpadDown
start.menudpleft.dpadLeft
back.viewdpright.dpadRight
guide.xboxleftstick.leftThumbstick
misc1.sharerightstick.rightThumbstick

Axes: leftx, lefty, rightx, righty (sticks), lefttrigger, righttrigger (triggers)

GenericHIDController Internals

Element enumeration (SDL-compatible sorting):

  • Buttons: kHIDPage_Button elements sorted by usage number -> b0, b1, b2...
  • Axes: kHIDPage_GenericDesktop elements 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 connections
  • genericHIDController: GenericHIDController? -- Active generic controller instance
  • genericHIDFallbackTimer: DispatchWorkItem? -- 1-second delay before fallback activation
  • isGenericController: Bool -- Published; true when active controller is generic HID

Key behaviors:

  • setupGenericHIDMonitoring() -- Called in init(), sets up IOKit HID manager matching GamePad (0x05) and Joystick (0x04) usage pages
  • controllerConnected() -- Cancels fallback timer and stops any active generic controller (GameController takes priority)
  • genericDeviceRemoved() -- Calls controllerDisconnected() 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 marked nonisolated due to the project's -default-isolation=MainActor build setting
  • GenericHIDController uses Unmanaged pointers for IOKit callback context (same pattern as XboxGuideMonitor)
  • Callbacks dispatch to controllerQueue or 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/MacroStep remain the persisted and UI model — no profile migration. Conversion happens at execution time in MacroAutomationBridge.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 into InputSimulator.
  • OBS WebSocket steps travel as TriggerKit custom steps (namespace controllerkeys.obs-websocket) and are dispatched to SystemCommandExecutor via the executor's stepOverride hook.
  • 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 macroId resolves profile macros first; an ID not in profile.macros is a shared-library reference (see ActionCommandFactory, 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.sharedMacroSnapshots embeds a copy of each referenced library macro, synced on every ProfileManager.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 — and ProfileImportSafetyAuditor walks 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's AutomationMacroLibraryView. ProfileManager.sharedLibraryMacros refreshes via .triggerKitMacrosChanged (including distributed notifications from other apps, which trigger AutomationMacroStore.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

FilePurpose
Services/Macros/MacroAutomationBridge.swiftMacroStepAutomationStep conversion table, modifier-set mapping, marker translation
Services/Macros/TriggerKitInputAdapter.swiftTriggerKit InputSimulating → CK InputSimulatorProtocol; reproduces pre-TriggerKit call sequences exactly
Services/Macros/MacroExecutor.swiftThin orchestrator: convert, then run through AutomationExecutor with the CK policy + step overrides
XboxControllerMapperTests/MacroExecutorBehaviorTests.swiftPins step → simulator call sequences (written pre-refactor, passes unchanged post-refactor)
XboxControllerMapperTests/MacroAutomationBridgeTests.swiftConversion-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):

PathCostFrequency
makeCommand for a plain key press0.5–1.5µsper button press (unchanged vs pre-TriggerKit)
makeCommand for a profile macro~2µsper macro trigger
makeCommand for a shared library macro (store lookup + normalize)~7µsper macro trigger
Bridge conversion, 10-step macro~8µsper macro trigger (on inputQueue)
Macro dispatch: execute() → first posted event~0.05ms medianper macro trigger
Snapshot sync (SharedMacroSnapshotPolicy) on a ~150-binding profile~250µsper profile save (UI edits only)

Invariants the store integration maintains:

  • AutomationMacroStore runs JSON encode/disk writes on a dedicated ioQueue, so the queue.sync macro 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 sharedLibraryMacros when 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

ServiceFileResponsibility
ControllerServiceServices/ControllerService.swiftController connection, raw input handling, haptics, battery, DualSense HID
MappingEngineServices/MappingEngine.swiftButton->action mapping, chords, long hold, double tap, macros, system commands
ControllerInputEventServices/Mapping/ControllerInputEvent.swiftEvent envelope and queue policy for MappingEngine controller callbacks
InputSimulatorServices/InputSimulator.swiftCGEvent-based keyboard/mouse output
ModifierKeyEmissionPolicyServices/Input/ModifierKeyEmissionPolicy.swiftPure side-aware modifier key selection for InputSimulator
ProfileManagerServices/ProfileManager.swiftProfile CRUD, persistence, backup
AppMonitorServices/AppMonitor.swiftActive app detection for profile auto-switching
XboxGuideMonitorServices/XboxGuideMonitor.swiftIOKit HID for Xbox Guide button (swallowed by GameController)
GameControllerDatabaseServices/GameControllerDatabase.swiftSDL database parsing and lookup
GenericHIDControllerServices/GenericHIDController.swiftRaw HID input for third-party controllers
HIDControllerDriverDescriptorServices/Controller/HIDControllerDriverDescriptor.swiftTestable raw-HID matching criteria for specialized controller backends
OnScreenKeyboardManagerServices/OnScreenKeyboardManager.swiftFloating keyboard overlay window
CommandWheelManagerServices/CommandWheelManager.swiftRadial menu for app/website switching
InputLogServiceServices/InputLogService.swiftDebug input logging
SystemCommandExecutorServices/SystemCommandExecutor.swiftShell commands, app launch, URL open
ControllerVisualDescriptorViews/MainWindow/ControllerVisualDescriptor.swiftPreview 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.

ComponentCPU %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%NSDisplayCycleFlushNSHostingView.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. The sample tool shows most mouse-queue time in mach_msg2_trap waiting 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. ControllerService is observed by 26 SwiftUI views via @EnvironmentObject. When any @Published property fires objectWillChange, all 26 views re-evaluate their bodies — even if only 2 views read the changed property. Solved by using CurrentValueSubject for 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 @State updates 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, account trialStart) so delete+reinstall does NOT reset the 14-day trial. License key + licensedConfirmed flag stored the same way; a confirmed license stays valid offline.
  • Gumroad verify: POSTs to https://api.gumroad.com/v2/licenses/verify with product_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 } in XboxControllerMapperApp.init (skipped in screenshot mode); fires at startup + on transition to expired. Dev builds compile in DEV_BYPASS_LICENSE via make 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.sh re-signs Sparkle's bundled Updater.app, Autoupdate, XPCServices/*.xpc inside-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.sh EdDSA-signs the DMG from a clean staging dir (one-item feed), writes/commits/pushes appcast.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.