-1

In the code below, A key is remapped to B key, and vice versa. The remapping is activated via a SwiftUI toggle switch.


In example presented here the same block of code is used in three different functions.

Additionally, the loop that iterates through the function call is also used in all three of these functions.

I've been struggling to simplify this code and make it less redundant for more than a day. Any help would be greatly appreciated.


let aKey: UInt64 = 0x700000004
let bKey: UInt64 = 0x700000005

func isKeyboardServiceClientForUsagePage(_ serviceClient: IOHIDServiceClient, _ usagePage: UInt32, _ usage: UInt32) -> Bool {
    return IOHIDServiceClientConformsTo(serviceClient, usagePage, usage) == 1
}

func updateKeyboardKeyMapping(_ keyMap: [[String: UInt64]]) {
    let eventSystemClient = IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)

    guard let serviceClients = IOHIDEventSystemClientCopyServices(eventSystemClient) as? [IOHIDServiceClient] else {
        return
    }

    for serviceClient in serviceClients {
        let usagePage = UInt32(kHIDPage_GenericDesktop)
        let usage = UInt32(kHIDUsage_GD_Keyboard)

        if isKeyboardServiceClientForUsagePage(serviceClient, usagePage, usage) {
            IOHIDServiceClientSetProperty(serviceClient, kIOHIDUserKeyUsageMapKey as CFString, keyMap as CFArray)
        }
    }
}

func areKeysMappedOnAnyServiceClient() -> Bool {
    let eventSystemClient = IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)

    guard let serviceClients = IOHIDEventSystemClientCopyServices(eventSystemClient) as? [IOHIDServiceClient] else {
        return false
    }

    for serviceClient in serviceClients {
        let usagePage = UInt32(kHIDPage_GenericDesktop)
        let usage = UInt32(kHIDUsage_GD_Keyboard)

        if isKeyboardServiceClientForUsagePage(serviceClient, usagePage, usage) {
            guard let keyMapping = IOHIDServiceClientCopyProperty(serviceClient, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: UInt64]] else {
                return false
            }

            if keyMapping.contains(where: { $0[kIOHIDKeyboardModifierMappingSrcKey] == aKey && $0[kIOHIDKeyboardModifierMappingDstKey] == bKey }) &&
                keyMapping.contains(where: { $0[kIOHIDKeyboardModifierMappingSrcKey] == bKey && $0[kIOHIDKeyboardModifierMappingDstKey] == aKey })
            {
                return true
            }
        }
    }

    return false
}

func remapABBA() {
    let keyMap: [[String: UInt64]] = [
        [
            kIOHIDKeyboardModifierMappingSrcKey: aKey,
            kIOHIDKeyboardModifierMappingDstKey: bKey,
        ],
        [
            kIOHIDKeyboardModifierMappingSrcKey: bKey,
            kIOHIDKeyboardModifierMappingDstKey: aKey,
        ],
    ]

    updateKeyboardKeyMapping(keyMap)
}

func resetKeyMapping() {
    updateKeyboardKeyMapping([])
}

And here’s the SwiftUI part if you would like to try the app:

import SwiftUI

struct ContentView: View {
    @State private var remapKeys = areKeysMappedOnAnyServiceClient()

    var body: some View {
        HStack {
            Spacer()
            Toggle(isOn: $remapKeys, label: { Text("Remap A → B and B → A.") })
                .toggleStyle(SwitchToggleStyle())
                .onChange(of: remapKeys, perform: toggleKeyboardRemapping)
            Spacer()
        }
    }
}

private func toggleKeyboardRemapping(_ remapKeys: Bool) {
    if remapKeys {
        remapABBA()
    } else {
        resetKeyMapping()
    }
}
Tzar
  • 5,132
  • 4
  • 23
  • 57

1 Answers1

3

OK... this is going to take some time to answer.

It seems like you're lacking in a place to store things. That's why you have to use the same block of code over and over. We can solve that with a view model...

In here I'm going to hide away the logic of what is happening from the view and only expose what the view needs access to in order to display itself.

// we make it observable so the view can subscribe to it.
class KeyMappingViewModel: ObservableObject {
  private let aKey: UInt64 = 0x700000004
  private let bKey: UInt64 = 0x700000005

  private let srcKey = kIOHIDKeyboardModifierMappingSrcKey
  private let dstKey = kIOHIDKeyboardModifierMappingDstKey

  private var keyMap: [[String: UInt64]] {
    [
      [
        srcKey: aKey,
        dstKey: bKey,
      ],
      [
        srcKey: bKey,
        dstKey: aKey,
      ],
    ]
  }

  // A more concise way to get hold of the client ref
  private var client: IOHIDEventSystemClientRef {
    IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)
  }

  // Making this published means the view can use it as state in the Toggle
  @Published var toggleState: Bool {
    didSet {
      if toggleState {
        client.updateKeyMapping(keyMap)
      } else {
        client.updateKeyMapping([])
      }
    }
  }

  init() {
    // set the initial value by asking the client if it has any keys mapped
    toggleState = client.areKeysMappedOnAnyServiceClient(aKey: aKey, bKey: bKey)
  }
}

I'm going to make extensions of IOHIDServiceClient and IOHIDEventSystemClientRef to encapsulate your logic...

extension IOHIDEventSystemClientRef {
  private var srcKey: String { kIOHIDKeyboardModifierMappingSrcKey }
  private var dstKey: String { kIOHIDKeyboardModifierMappingDstKey }

  // Make this an optional var on the client ref itself.
  private var serviceClients: [IOHIDServiceClient]? {
    IOHIDEventSystemClientCopyServices(self) as? [IOHIDServiceClient]
  }

  func areKeysMappedOnAnyServiceClient(aKey: UInt64, bKey: UInt64) -> Bool {
    // Nice Swift 5.7 syntax with the optional var
    guard let serviceClients else {
      return false
    }
    // I made this more concise with a filter and map.
    // Also, using the extension we can make use of keyPaths to get the values.
    return serviceClients.filter(\.isForGDKeyboard)
      .compactMap(\.keyMapping)
      .map { keyMapping in
        keyMapping.contains(where: { $0[srcKey] == aKey && $0[dstKey] == bKey }) &&
        keyMapping.contains(where: { $0[srcKey] == bKey && $0[dstKey] == aKey })
      }
      .contains(true)
  }

  func updateKeyMapping(_ keyMap: [[String: UInt64]]) {
    // serviceClients is optional so we can just ? it.
    // if it's nil, nothing after the ? happens.
    serviceClients?.filter(\.isForGDKeyboard)
      .forEach {
        IOHIDServiceClientSetProperty($0, kIOHIDUserKeyUsageMapKey as CFString, keyMap as CFArray)
      }
  }
}

extension IOHIDServiceClient {
  var isForGDKeyboard: Bool {
    let usagePage = UInt32(kHIDPage_GenericDesktop)
    let usage = UInt32(kHIDUsage_GD_Keyboard)
    return IOHIDServiceClientConformsTo(self, usagePage, usage) == 1
  }

  var keyMapping: [[String: UInt64]]? {
    IOHIDServiceClientCopyProperty(self, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: UInt64]]
  }
}

Doing all of this means that your view can look something like this...

import SwiftUI

struct ContentView: View {
  @ObservedObject var viewModel: KeyMappingViewModel = .init()

  var body: some View {
    HStack {
      Spacer()
      Toggle(isOn: $viewModel.toggleState) {
        Text("Remap A → B and B → A.")
      }
      .toggleStyle(SwitchToggleStyle())
      Spacer()
    }
  }
}

This contains all your same logic and TBH wasn't too bad already.

My main changes were to take the free functions and vars and add them to their respective types.

So, the update and areKeysMapped... functions now belong to the IOHIDEventSystemClientRef type.

The isForGDKeyboard and keyMapping vars now belong to the IOHIDServiceClient type.

Doing this removed a lot of the repeated code you had as you no longer had to continuously call free functions. It also meant we unlocked some very Swifty keyPath usage which helped make some of the logic more concise.

Then we made a view model. This allowed us to keep all the moving parts of the view in one place. It had a place to easily get hold of the client. It also meant we could hide a lot of the stuff inside the view model by making it private.

This meant that the view only had one thing it could do. Which is to use the binding to the toggleState. Everything else was behind closed doors to the view.

Fogmeister
  • 76,236
  • 42
  • 207
  • 306
  • 1
    One of the greatest answers I've seen here. Incredible stuff! For whatever reason, it won't compile on my machine (M2, Ventura, Xcode 14.2). I get the following errors: first, an easily fixable one: `IOHIDEventSystemClientRef` has been renamed to `IOHIDEventSystemClient`. The others are: `Cannot use instance member 'aKey' within property initializer; property initializers run before 'self' is available` (same error for `bKey`). And `Extensions must not contain stored properties` for `private let srcKey = kIOHIDKeyboardModifierMappingSrcKey` (and same for `dstKey`). – Tzar Dec 28 '22 at 12:08
  • 1
    Ah ok, I’ll update. I was writing in the browser so might have some compile issues. – Fogmeister Dec 28 '22 at 12:55
  • 1
    @Tzar I've updated now. Hopefully that should be good. – Fogmeister Dec 28 '22 at 13:08
  • Hey, first, thanks for caring so much to resolve this issue. I still get errors `Cannot use instance member 'aKey' within property initializer; property initializers run before 'self' is available` and same for `bKey`. – Tzar Dec 28 '22 at 13:16
  • Oh!!! One sec, I realise what I did. – Fogmeister Dec 28 '22 at 13:16
  • Try that. I thought the error was in a different place. I've updated now to use a computed var for the keymap. – Fogmeister Dec 28 '22 at 13:19
  • Sorry, but new errors now: `Cannot find 'eventSystemClient' in scope` for `IOHIDEventSystemClientCopyServices(eventSystemClient) as? [IOHIDServiceClient]` and `Cannot find 'serviceClient' in scope` for `return IOHIDServiceClientConformsTo(serviceClient, usagePage, usage) == 1`. – Tzar Dec 28 '22 at 13:22
  • 1
    Ah! I forgot to change those to use `self` as they're now a part of the type that is required in those functions. Updated now. We'll get there eventually. – Fogmeister Dec 28 '22 at 13:25
  • I've been trying to compile the code for the last two hours, and I can't solve error `'self' used in property access 'client' before all stored properties are initialized` for `toggleState = client.areKeysMappedOnAnyServiceClient(aKey: aKey, bKey: bKey)`. It's killing me. Infinitely grateful for you effort. – Tzar Dec 28 '22 at 15:53
  • Hmm… what version of Xcode are you using? – Fogmeister Dec 28 '22 at 16:00
  • Xcode 14.2 on Ventura 13.1 on M2 Air. – Tzar Dec 28 '22 at 16:02
  • 1
    You could always remove the client computed var and turn it to a let and then create it in the init. – Fogmeister Dec 28 '22 at 16:03
  • 1
    Oliver, thanks for all your time! I finally managed to build and run it following your most recent advice. But I give up. The toggle doesn't read correctly the state of current key mapping on app launch. Thank you again for everything! – Tzar Dec 28 '22 at 16:17
  • If you push it to GitHub I can have a look. – Fogmeister Dec 28 '22 at 16:35
  • 1
    No need. I managed to fix it. Thanks again! – Tzar Dec 28 '22 at 17:35
  • 1
    Ah! Great! I just put this up, tested on Xcode and it works for me too! :D https://gist.github.com/oliverfoggin/b1a002c0ee665d7a3c4d8bf0a563c32a – Fogmeister Dec 28 '22 at 17:37