Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions AKPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import AppKit
import CoreGraphics
import Foundation
import IOKit.hid

// Add a lightweight struct so we can decode only the flag we care about
private struct AKAppSettingsData: Codable {
Expand All @@ -19,6 +20,11 @@ private struct AKAppSettingsData: Codable {
}

class AKPlugin: NSObject, Plugin {
private static let hidAxisUsages: Set<Int> = [0x30, 0x31, 0x32, 0x33, 0x34, 0x35]
// Simulation controls used by some HID gamepads for analog triggers.
private static let hidSimulationAxisUsages: Set<Int> = [0xC4, 0xC5] // Accelerator / Brake
private static let hidHatUsage = 0x39

required override init() {
super.init()
if let window = NSApplication.shared.windows.first {
Expand Down Expand Up @@ -129,6 +135,37 @@ class AKPlugin: NSObject, Plugin {
}

private var modifierFlag: UInt = 0
private var hidManager: IOHIDManager?
private var hidPrimaryDevice: IOHIDDevice?
private var hidOnConnected: (() -> Void)?
private var hidOnDisconnected: (() -> Void)?
private var hidOnButton: ((Int, Bool) -> Void)?
private var hidOnAxis: ((Int, CGFloat) -> Void)?
private var hidOnHat: ((Int) -> Void)?

private static let hidDeviceMatchingCallback: IOHIDDeviceCallback = { context, _, _, device in
guard let context else {
return
}
let plugin = Unmanaged<AKPlugin>.fromOpaque(context).takeUnretainedValue()
plugin.handleHIDDeviceConnected(device)
}

private static let hidDeviceRemovalCallback: IOHIDDeviceCallback = { context, _, _, device in
guard let context else {
return
}
let plugin = Unmanaged<AKPlugin>.fromOpaque(context).takeUnretainedValue()
plugin.handleHIDDeviceRemoved(device)
}

private static let hidInputValueCallback: IOHIDValueCallback = { context, _, _, value in
guard let context else {
return
}
let plugin = Unmanaged<AKPlugin>.fromOpaque(context).takeUnretainedValue()
plugin.handleHIDInputValue(value)
}

// swiftlint:disable:next function_body_length
func setupKeyboard(keyboard: @escaping (UInt16, Bool, Bool, Bool) -> Bool,
Expand Down Expand Up @@ -279,6 +316,47 @@ class AKPlugin: NSObject, Plugin {
})
}

func setupHIDControllerInput(onConnected: @escaping () -> Void,
onDisconnected: @escaping () -> Void,
onButton: @escaping (Int, Bool) -> Void,
onAxis: @escaping (Int, CGFloat) -> Void,
onHat: @escaping (Int) -> Void) {
hidOnConnected = onConnected
hidOnDisconnected = onDisconnected
hidOnButton = onButton
hidOnAxis = onAxis
hidOnHat = onHat

if hidManager != nil {
return
}

let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))

IOHIDManagerSetDeviceMatching(manager, nil)

let context = Unmanaged.passUnretained(self).toOpaque()
IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.hidDeviceMatchingCallback, context)
IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.hidDeviceRemovalCallback, context)
IOHIDManagerRegisterInputValueCallback(manager, Self.hidInputValueCallback, context)

IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
let status = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
if status != kIOReturnSuccess {
return
}

hidManager = manager

// Callbacks are not guaranteed to fire for devices that were already connected before
// manager registration in all launch paths. Seed initial state proactively.
if let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice> {
if let initialDevice = devices.first(where: { isSupportedControllerDevice($0) }) {
handleHIDDeviceConnected(initialDevice)
}
}
}

func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: value)
}
Expand Down Expand Up @@ -313,4 +391,149 @@ class AKPlugin: NSObject, Plugin {
}
return decoded
}()

private func handleHIDDeviceConnected(_ device: IOHIDDevice) {
if !isSupportedControllerDevice(device) {
return
}

if hidPrimaryDevice == nil {
hidPrimaryDevice = device
hidOnConnected?()
}
}

private func handleHIDDeviceRemoved(_ device: IOHIDDevice) {
guard let primary = hidPrimaryDevice, sameDevice(primary, device) else {
return
}
hidPrimaryDevice = nil
hidOnDisconnected?()

if let manager = hidManager,
let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let another = devices.first(where: { isSupportedControllerDevice($0) }) {
hidPrimaryDevice = another
hidOnConnected?()
}
}

private func handleHIDInputValue(_ value: IOHIDValue) {
let element = IOHIDValueGetElement(value)
let device = IOHIDElementGetDevice(element)
guard let primary = hidPrimaryDevice else {
return
}
if !sameDevice(primary, device) {
return
}

let usagePage = Int(IOHIDElementGetUsagePage(element))
let usage = Int(IOHIDElementGetUsage(element))
let rawValue = IOHIDValueGetIntegerValue(value)

switch usagePage {
case 0x09: // Button page
hidOnButton?(usage, rawValue != 0)
case 0x01: // Generic Desktop page
if usage == 0x39 {
hidOnHat?(Int(rawValue))
return
}
if [0x30, 0x31, 0x32, 0x33, 0x34, 0x35].contains(usage) {
let normalized = normalizeAxis(rawValue: rawValue, element: element)
hidOnAxis?(usage, normalized)
}
case 0x02: // Simulation Controls page (e.g. accelerator / brake triggers)
if Self.hidSimulationAxisUsages.contains(usage) {
let normalized = normalizeAxis(rawValue: rawValue, element: element)
hidOnAxis?(usage, normalized)
}
case 0x0C:
break
default:
break
}
}

private func normalizeAxis(rawValue: CFIndex, element: IOHIDElement) -> CGFloat {
let logicalMin = IOHIDElementGetLogicalMin(element)
let logicalMax = IOHIDElementGetLogicalMax(element)
if logicalMax <= logicalMin {
return 0
}

let clamped = min(max(rawValue, logicalMin), logicalMax)
let range = Double(logicalMax - logicalMin)
let shifted = Double(clamped - logicalMin)
let normalized = (shifted / range) * 2.0 - 1.0
return CGFloat(normalized)
}

private func sameDevice(_ lhs: IOHIDDevice, _ rhs: IOHIDDevice) -> Bool {
CFEqual(lhs, rhs)
}

private func hidDevicePropertyInt(_ device: IOHIDDevice, key: CFString) -> Int? {
if let number = IOHIDDeviceGetProperty(device, key) as? NSNumber {
return number.intValue
}
return nil
}

private func isSupportedControllerDevice(_ device: IOHIDDevice) -> Bool {
let usagePage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsagePageKey as CFString) ?? -1
let usage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsageKey as CFString) ?? -1

if usagePage == 0x01 && (usage == 0x04 || usage == 0x05) {
return true
}

return hasGamepadLikeElements(device)
}

private func hasGamepadLikeElements(_ device: IOHIDDevice) -> Bool {
guard let elements = IOHIDDeviceCopyMatchingElements(device, nil, IOOptionBits(kIOHIDOptionsTypeNone))
as? [IOHIDElement] else {
return false
}

var buttonUsages = Set<Int>()
var axisUsages = Set<Int>()
var hasHat = false

for element in elements {
let type = IOHIDElementGetType(element)
switch type {
case kIOHIDElementTypeInput_Button:
let usagePage = Int(IOHIDElementGetUsagePage(element))
let usage = Int(IOHIDElementGetUsage(element))
if usagePage == 0x09 {
buttonUsages.insert(usage)
}
case kIOHIDElementTypeInput_Misc, kIOHIDElementTypeInput_Axis:
let usagePage = Int(IOHIDElementGetUsagePage(element))
let usage = Int(IOHIDElementGetUsage(element))
switch usagePage {
case 0x01:
if usage == Self.hidHatUsage {
hasHat = true
} else if Self.hidAxisUsages.contains(usage) {
axisUsages.insert(usage)
}
case 0x02:
if Self.hidSimulationAxisUsages.contains(usage) {
axisUsages.insert(usage)
}
default:
continue
}
default:
continue
}
}

// Reject non-controller HID devices (headsets, media keys, etc.).
return buttonUsages.count >= 4 && (axisUsages.count >= 2 || hasHat)
}
}
2 changes: 1 addition & 1 deletion PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ - (BOOL) hook_requiresFullScreen {
return NO;
}

- (void) hook_setCurrentSubscription:(VSSubscription *)currentSubscription {
- (void) hook_setCurrentSubscription:(id)currentSubscription {
// do nothing
}

Expand Down
Loading