From 394ff78431268bf411edf27e4a1b3c840252692f Mon Sep 17 00:00:00 2001 From: casona Date: Sun, 25 Oct 2026 21:10:00 +0300 Subject: [PATCH] feat: remap physical Caps to F19 for reliable switching --- .gitignore | 1 + Package.swift | 11 +- README.md | 73 +++-- Sources/Capswitch/CapsInputRouter.swift | 53 ++++ Sources/Capswitch/HIDMapping.swift | 143 ++++++++++ Sources/CapswitchApp/AppDelegate.swift | 11 + .../CapswitchApp/HIDRemappingController.swift | 135 +++++++++ .../CapswitchApp/MacOSInputController.swift | 268 ++++++++++-------- .../CapswitchApp/StatusBarController.swift | 85 +++++- Sources/CapswitchHID/CapswitchHID.c | 61 ++++ Sources/CapswitchHID/include/CapswitchHID.h | 11 + Taskfile.yml | 15 +- .../CapsGestureMachineTests.swift | 8 +- .../CapsInputRouterTests.swift | 56 ++++ .../HIDMappingCodecTests.swift | 58 ++++ 15 files changed, 826 insertions(+), 163 deletions(-) create mode 100644 Sources/Capswitch/CapsInputRouter.swift create mode 100644 Sources/Capswitch/HIDMapping.swift create mode 100644 Sources/CapswitchApp/HIDRemappingController.swift create mode 100644 Sources/CapswitchHID/CapswitchHID.c create mode 100644 Sources/CapswitchHID/include/CapswitchHID.h create mode 100644 Tests/CapswitchCoreTests/CapsInputRouterTests.swift create mode 100644 Tests/CapswitchCoreTests/HIDMappingCodecTests.swift diff --git a/.gitignore b/.gitignore index ecba98b..47590c0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ .DS_Store .swiftpm/ .task/ +.build/ diff --git a/Package.swift b/Package.swift index d673990..05ff329 100644 --- a/Package.swift +++ b/Package.swift @@ -14,8 +14,15 @@ let package = Package( ), .executableTarget( name: "Capswitch", - dependencies: ["CapswitchCore"], - path: "Sources/CapswitchApp" + dependencies: ["CapswitchCore", "CapswitchHID"], + path: "Sources/CapswitchApp", + linkerSettings: [.linkedFramework("IOKit")] + ), + .target( + name: "CapswitchHID", + path: "Sources/CapswitchHID", + publicHeadersPath: "include", + linkerSettings: [.linkedFramework("IOKit")] ), .testTarget( name: "CapswitchCoreTests", diff --git a/README.md b/README.md index 0000ace..ba4ddf0 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,74 @@ # Capswitch -A small native macOS menu-bar app that uses Caps Lock to switch to the next enabled keyboard input source. +Small native macOS menu-bar app. Physical Caps Lock is HID-remapped to F19, which Capswitch intercepts to select the next input source **on release**, including during continuous fast typing. Caps+Option retains the system Caps Lock function. + +## Requirements + +- macOS 14 or newer and Apple Command Line Tools (install with xcode-select --install). +- Task (Taskfile runner). +- Accessibility permission under **System Settings -> Privacy & Security -> Accessibility**. +- Disable **Keyboard -> Text Input -> Edit -> Use Caps Lock to switch to/from ABC**. +- Keep the Caps Lock key set to its normal role in **Keyboard -> Keyboard Shortcuts -> Modifier Keys**, not "No Action". +- No Xcode project, root helper, Karabiner or third-party driver is needed. + +## Build and test on your Mac + +**Quit the currently running Capswitch from its menu first.** Opening an already running app may activate an old process even if a new bundle was created. The Taskfile refuses to replace the app bundle while a Capswitch process is running. + +~~~bash +git pull origin main +task test +task bundle +open build/Capswitch.app +~~~ + +Grant Accessibility permission and restart Capswitch if macOS prompts you to do so. ## Behavior -- Plain Caps: switch once on release. -- Caps+Option in either order: preserve real Caps Lock. -- Stock Caps/input-source events are suppressed. -- The app has no Dock icon and reports Accessibility permission in its menu. +- Plain physical Caps emits F19 keyDown/keyUp. Capswitch swallows both events and on F19 keyUp selects the next enabled keyboard input source. +- Caps+Option in either order toggles the actual system Caps Lock state through IOKit after Option has been released. +- Pause from the menu disables the event tap and restores the Caps key to its original purpose. +- Quit normally restores only Capswitch's Caps->F19 mapping, leaving unrelated mappings alone. +- On wake or keyboard reconnection, Capswitch checks whether its HID mapping is still present. -## Build on macOS +## Diagnostics -The Linux RSB host cannot compile AppKit or run Swift/XCTest. On macOS 14+ with Apple's Command Line Tools installed: +Open the status-bar menu while testing quick typing. It displays: -```bash -xcode-select --install -task bundle -open build/Capswitch.app -``` + F19 ↓N ↑N · switches N -No Xcode project or full Xcode.app is required. +No typed text is recorded. If the F19 counts do not change, the HID remap is not producing F19 events. If they change but the switch count does not, inspect input source selection. -Grant the app access at **System Settings → Privacy & Security → Accessibility**. Keep the bundle identifier and installation path stable so macOS can retain the permission. +Caps+Option through IOKit and its LED behavior still require verification on an actual Mac. -## Development commands +## HID remapping safety -```bash +HID key mapping is global. Capswitch first reads existing mappings, refuses collisions involving Caps or F19, and preserves unrelated remaps. It removes only its own rule on normal Quit/Pause. A small ownership marker allows a later launch to restore a rule that Capswitch left behind after a crash. + +If the app is forcibly terminated, Caps may remain mapped to F19 until the app is relaunched and Quit normally, or until reboot. To inspect mappings: + +~~~bash +hidutil property --get UserKeyMapping +~~~ + +**Do not clear all UserKeyMapping rules if you use other remappers.** Only if the sole entry is the one Capswitch inserted may you reset mappings manually: + +~~~bash +hidutil property --set '{"UserKeyMapping":[]}' +~~~ + +hidutil remapping normally resets after restart or after the last keyboard service disappears. + +## Development + +~~~bash task lint task test task build task bundle task clean task verify -``` +~~~ -The authoritative checks are `task test` and `task bundle` on macOS. Linux runs only host-independent checks and reports the missing Swift/macOS toolchain explicitly. +Linux RSB runs host-independent static checks only. SwiftPM builds, XCTest, IOKit and actual keyboard input behavior must be verified on macOS with the Apple SDK. diff --git a/Sources/Capswitch/CapsInputRouter.swift b/Sources/Capswitch/CapsInputRouter.swift new file mode 100644 index 0000000..b4e537f --- /dev/null +++ b/Sources/Capswitch/CapsInputRouter.swift @@ -0,0 +1,53 @@ +// Routes ordinary F19 down/up events (physical Caps after HID remap) and +// Option modifier changes into the pure gesture state machine. +public struct CapsInputRouter { + public static let f19VirtualKeyCode: UInt16 = 80 + + private var machine = CapsGestureMachine() + private var optionHeld = false + + public init() {} + + public mutating func reset() { + _ = machine.handle(.reset) + optionHeld = false + } + + public mutating func flagsChanged(optionDown: Bool) -> GestureAction { + updateOptionState(optionDown) + } + + public mutating func keyDown( + code: UInt16, + optionDown: Bool, + isRepeat: Bool = false + ) -> GestureAction { + let modifierAction = updateOptionState(optionDown) + guard code == Self.f19VirtualKeyCode, !isRepeat else { + return modifierAction + } + let keyAction = machine.handle(.capsDown(optionDown: optionDown)) + return modifierAction == .none ? keyAction : modifierAction + } + + public mutating func keyUp( + code: UInt16, + optionDown: Bool + ) -> GestureAction { + let modifierAction = updateOptionState(optionDown) + guard code == Self.f19VirtualKeyCode else { + return modifierAction + } + let keyAction = machine.handle(.capsUp(optionDown: optionDown)) + return modifierAction == .none ? keyAction : modifierAction + } + + private mutating func updateOptionState(_ down: Bool) -> GestureAction { + guard down != optionHeld else { + return .none + } + + optionHeld = down + return machine.handle(down ? .optionDown : .optionUp) + } +} diff --git a/Sources/Capswitch/HIDMapping.swift b/Sources/Capswitch/HIDMapping.swift new file mode 100644 index 0000000..14fc122 --- /dev/null +++ b/Sources/Capswitch/HIDMapping.swift @@ -0,0 +1,143 @@ +import Foundation + +public struct HIDKeyMapping: Equatable, Hashable { + public let source: UInt64 + public let destination: UInt64 + + public init(source: UInt64, destination: UInt64) { + self.source = source + self.destination = destination + } +} + +public enum HIDMappingCodecError: Error, Equatable { + case unrecognizedOutput + case invalidEntry +} + +// Parse OpenStep-style hidutil --get output. Unknown entries are rejected +// rather than risking deletion of someone else's keyboard remappings. +public enum HIDMappingCodec { + public static let capsUsage: UInt64 = 0x700000039 + public static let f19Usage: UInt64 = 0x70000006E + public static let capsToF19 = HIDKeyMapping( + source: capsUsage, + destination: f19Usage + ) + + public static func parse(_ output: String) throws -> [HIDKeyMapping] { + var value = output.trimmingCharacters(in: .whitespacesAndNewlines) + + if value.hasPrefix("UserKeyMapping:") { + value = String(value.dropFirst("UserKeyMapping:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + guard value.first == "(", value.last == ")" else { + throw HIDMappingCodecError.unrecognizedOutput + } + + let inner = String(value.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if inner.isEmpty || inner == "null" { + return [] + } + + let entryPattern = try NSRegularExpression(pattern: #"\{([^{}]*)\}"#) + let matches = entryPattern.matches( + in: inner, + range: NSRange(inner.startIndex.. String { + let entries: [[String: NSNumber]] = mappings.map { + [ + "HIDKeyboardModifierMappingSrc": NSNumber(value: $0.source), + "HIDKeyboardModifierMappingDst": NSNumber(value: $0.destination), + ] + } + let data = try JSONSerialization.data( + withJSONObject: ["UserKeyMapping": entries], + options: [.sortedKeys] + ) + return String(decoding: data, as: UTF8.self) + } + + private static func parseEntry(_ body: String) throws -> HIDKeyMapping { + let fieldPattern = try NSRegularExpression( + pattern: #"([A-Za-z][A-Za-z0-9_]*)\s*=\s*([^;]+);"# + ) + let matches = fieldPattern.matches( + in: body, + range: NSRange(body.startIndex.. F19 mapping. + if let bundleID = Bundle.main.bundleIdentifier, + NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .contains(where: { + $0.processIdentifier != ProcessInfo.processInfo.processIdentifier + }) { + NSApp.terminate(nil) + return + } + statusBarController = StatusBarController() } diff --git a/Sources/CapswitchApp/HIDRemappingController.swift b/Sources/CapswitchApp/HIDRemappingController.swift new file mode 100644 index 0000000..bba75ab --- /dev/null +++ b/Sources/CapswitchApp/HIDRemappingController.swift @@ -0,0 +1,135 @@ +import Foundation +import CapswitchCore + +enum HIDRemapError: LocalizedError { + case conflictingMapping + case unreadableMapping + case verificationFailed + case commandFailed(String) + + var errorDescription: String? { + switch self { + case .conflictingMapping: + return "Caps Lock or F19 is already remapped. Disable the conflicting mapping first." + case .unreadableMapping: + return "Cannot safely read the existing HID key mappings; no changes were made." + case .verificationFailed: + return "hidutil did not confirm Caps Lock to F19 remapping." + case let .commandFailed(details): + return "hidutil failed: \(details)" + } + } +} + +/// Owns only the Caps -> F19 rule. Other mappings are preserved both when +/// installing and when restoring on quit or Pause. +final class HIDRemappingController { + private let markerKey = "CapswitchOwnsCapsF19Mapping" + private let ourMapping = HIDMappingCodec.capsToF19 + + private(set) var isInstalled = false + private var ownsMapping = false + + func ensureInstalled() throws { + let current = try readMappings() + + if current.contains(ourMapping) { + // Adopt a rule left behind after Capswitch crashed. A pre-existing + // user-managed Caps -> F19 rule has no marker and is never removed. + ownsMapping = ownsMapping || UserDefaults.standard.bool(forKey: markerKey) + isInstalled = true + return + } + + // Do not overwrite Caps remapped to something else, F19 remapped away, + // or a different physical key remapped to F19. + if current.contains(where: { + $0.source == HIDMappingCodec.capsUsage + || $0.source == HIDMappingCodec.f19Usage + || $0.destination == HIDMappingCodec.f19Usage + }) { + throw HIDRemapError.conflictingMapping + } + + // The marker is written before modifying global HID state so the next + // launch can clean up an interrupted installation after a crash. + UserDefaults.standard.set(true, forKey: markerKey) + try writeMappings(current + [ourMapping]) + + guard try readMappings().contains(ourMapping) else { + throw HIDRemapError.verificationFailed + } + + ownsMapping = true + isInstalled = true + } + + func restoreIfOwned() throws { + guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else { + isInstalled = false + return + } + + let current = try readMappings() + + if current.contains(ourMapping) { + // Remove only our exact pair, preserving any changes made by + // another remapper while Capswitch was running. + try writeMappings(current.filter { $0 != ourMapping }) + + if try readMappings().contains(ourMapping) { + throw HIDRemapError.verificationFailed + } + } + + ownsMapping = false + isInstalled = false + UserDefaults.standard.removeObject(forKey: markerKey) + } + + private func readMappings() throws -> [HIDKeyMapping] { + let output = try runHidutil(["property", "--get", "UserKeyMapping"]) + + do { + return try HIDMappingCodec.parse(output) + } catch { + throw HIDRemapError.unreadableMapping + } + } + + private func writeMappings(_ mappings: [HIDKeyMapping]) throws { + let argument = try HIDMappingCodec.setArgument(mappings) + _ = try runHidutil(["property", "--set", argument]) + } + + private func runHidutil(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hidutil") + process.arguments = arguments + + let output = Pipe() + let errors = Pipe() + process.standardOutput = output + process.standardError = errors + + do { + try process.run() + } catch { + throw HIDRemapError.commandFailed(error.localizedDescription) + } + + process.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + let errorData = errors.fileHandleForReading.readDataToEndOfFile() + let message = String(decoding: errorData, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard process.terminationStatus == 0 else { + throw HIDRemapError.commandFailed( + message.isEmpty ? "exit code \(process.terminationStatus)" : message + ) + } + + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift index 1a40432..7f1fffd 100644 --- a/Sources/CapswitchApp/MacOSInputController.swift +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -1,5 +1,6 @@ import AppKit import CapswitchCore +import CapswitchHID import ApplicationServices import Carbon import CoreGraphics @@ -82,11 +83,24 @@ final class InputSourceManager { return Source(inputSource: inputSource, id: id) } - let byID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) }) + var byID: [String: Source] = [:] + for source in sources { + byID[source.id] = source + } + let configuredIDs = configuredInputSourceIDs() - var ordered = configuredIDs.compactMap { byID[$0] } - let configuredSet = Set(configuredIDs) - ordered.append(contentsOf: sources.filter { !configuredSet.contains($0.id) }) + var usedIDs: Set = [] + var ordered: [Source] = [] + + for id in configuredIDs { + if let source = byID[id], usedIDs.insert(id).inserted { + ordered.append(source) + } + } + + ordered.append(contentsOf: sources.filter { + usedIDs.insert($0.id).inserted + }) return ordered } @@ -95,9 +109,8 @@ final class InputSourceManager { } private func configuredInputSourceIDs() -> [String] { - guard let entries = UserDefaults.standard.array( - forKey: "AppleEnabledInputSources" - ) as? [[String: Any]] else { + guard let entries = UserDefaults(suiteName: "com.apple.HIToolbox")? + .array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { return [] } @@ -130,23 +143,25 @@ final class InputSourceManager { } final class MacOSInputController { - private static let capsLockKeyCode: CGKeyCode = 57 - private static let syntheticEventMarker: Int64 = 0x4361707353776974 - private let inputSources = InputSourceManager() + private let remapper = HIDRemappingController() + private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - private var gestureMachine = CapsGestureMachine() - private var alphaShiftDown = false - private var optionDown = false - private var isPostingSyntheticCaps = false + private var inputRouter = CapsInputRouter() + + private(set) var observedF19Down = 0 + private(set) var observedF19Up = 0 + private(set) var successfulSwitches = 0 + private(set) var capsLockToggles = 0 var isPaused = false + var lastError: String? + var onStateChanged: (() -> Void)? var isRunning: Bool { - eventTap != nil + eventTap != nil && remapper.isInstalled } - var onStateChanged: (() -> Void)? var currentInputSourceName: String { inputSources.currentDisplayName @@ -160,27 +175,32 @@ final class MacOSInputController { AXIsProcessTrusted() } + var diagnosticsSummary: String { + "F19 ↓\(observedF19Down) ↑\(observedF19Up) · switches \(successfulSwitches)" + } + func start() throws { - guard eventTap == nil else { + guard !isPaused, eventTap == nil else { return } - guard AXIsProcessTrusted() else { + guard accessibilityTrusted else { throw InputControllerError.accessibilityRequired } - // Build the source order before the event tap starts. Enumerating TIS - // sources from inside a keyboard callback can block long enough for - // macOS to disable the tap while the user is typing quickly. + // Enumerate input sources before entering the event tap callback. inputSources.refresh() - let eventMask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) + let mask = (CGEventMask(1) << CGEventType.keyDown.rawValue) + | (CGEventMask(1) << CGEventType.keyUp.rawValue) + | (CGEventMask(1) << CGEventType.flagsChanged.rawValue) + let refcon = Unmanaged.passUnretained(self).toOpaque() guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, - eventsOfInterest: eventMask, + eventsOfInterest: mask, callback: Self.eventTapCallback, userInfo: refcon ) else { @@ -201,9 +221,19 @@ final class MacOSInputController { .commonModes ) } - CGEvent.tapEnable(tap: tap, enable: true) resetGestureState() + + // Only remap the physical Caps once we know our intercept is active. + do { + try remapper.ensureInstalled() + lastError = nil + } catch { + lastError = error.localizedDescription + stop() + throw error + } + onStateChanged?() } @@ -223,12 +253,30 @@ final class MacOSInputController { eventTap = nil runLoopSource = nil resetGestureState() + + do { + try remapper.restoreIfOwned() + } catch { + lastError = "Cannot restore Caps remap: \(error.localizedDescription)" + } onStateChanged?() } func setPaused(_ paused: Bool) { + guard paused != isPaused else { + return + } + isPaused = paused - resetGestureState() + if paused { + stop() + } else { + do { + try start() + } catch { + lastError = error.localizedDescription + } + } onStateChanged?() } @@ -236,6 +284,21 @@ final class MacOSInputController { inputSources.refresh() } + func repairMappingIfNeeded() { + guard !isPaused, eventTap != nil else { + return + } + + do { + try remapper.ensureInstalled() + lastError = nil + } catch { + lastError = error.localizedDescription + // Never keep swallowing F19 if our mapping is no longer healthy. + stop() + } + } + func openAccessibilitySettings() { guard let url = URL( string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" @@ -247,6 +310,7 @@ final class MacOSInputController { private static let eventTapCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } @@ -262,11 +326,6 @@ final class MacOSInputController { type: CGEventType, event: CGEvent ) -> Unmanaged? { - if event.getIntegerValueField(.eventSourceUserData) - == Self.syntheticEventMarker { - return Unmanaged.passUnretained(event) - } - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { resetGestureState() if let eventTap { @@ -275,123 +334,88 @@ final class MacOSInputController { return Unmanaged.passUnretained(event) } - guard type == .flagsChanged else { + let optionDown = event.flags.contains(.maskAlternate) + + if type == .flagsChanged { + apply(inputRouter.flagsChanged(optionDown: optionDown)) return Unmanaged.passUnretained(event) } - let flags = event.flags - let newAlphaShiftDown = flags.contains(.maskAlphaShift) - let newOptionDown = flags.contains(.maskAlternate) - let isCapsEvent = event.getIntegerValueField(.keyboardEventKeycode) - == Int64(Self.capsLockKeyCode) + guard type == .keyDown || type == .keyUp else { + return Unmanaged.passUnretained(event) + } - if isPaused { - if isCapsEvent { - alphaShiftDown = newAlphaShiftDown + let keyCode = UInt16( + truncatingIfNeeded: event.getIntegerValueField(.keyboardEventKeycode) + ) + + // We own F19. Both the down and up must be suppressed so they do not + // leak into the foreground app or its own keyboard shortcuts. + guard keyCode == CapsInputRouter.f19VirtualKeyCode else { + return Unmanaged.passUnretained(event) + } + + if type == .keyDown { + let repeated = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + if !repeated { + observedF19Down += 1 } - optionDown = newOptionDown - return Unmanaged.passUnretained(event) + apply(inputRouter.keyDown( + code: keyCode, + optionDown: optionDown, + isRepeat: repeated + )) + } else { + observedF19Up += 1 + apply(inputRouter.keyUp(code: keyCode, optionDown: optionDown)) } - // Alpha-shift is also used by other keyboard events. Use the physical - // key code so a fast sequence of unrelated flagsChanged events cannot - // be mistaken for a Caps Lock edge. - let capsDown = isCapsEvent && newAlphaShiftDown && !alphaShiftDown - let capsUp = isCapsEvent && !newAlphaShiftDown && alphaShiftDown - let optionPressed = newOptionDown && !optionDown - let optionReleased = !newOptionDown && optionDown - - var action: GestureAction = .none - - if optionPressed { - action = combine(action, gestureMachine.handle(.optionDown)) - } - - if capsDown { - action = combine( - action, - gestureMachine.handle(.capsDown(optionDown: newOptionDown)) - ) - } - - if capsUp { - action = combine( - action, - gestureMachine.handle(.capsUp(optionDown: newOptionDown)) - ) - } - - if optionReleased { - action = combine(action, gestureMachine.handle(.optionUp)) - } - - if isCapsEvent { - alphaShiftDown = newAlphaShiftDown - } - optionDown = newOptionDown - apply(action) - - if capsDown || capsUp { - return nil - } - - return Unmanaged.passUnretained(event) + return nil } private func apply(_ action: GestureAction) { switch action { case .none: break + case .switchToNextInputSource: - // The source list is cached before the tap starts, so this path is - // deliberately synchronous: the next key must see the new source - // even when the user is typing continuously. + // Synchronous: the next typed letter must see the new layout. if inputSources.selectNextInputSource() { - onStateChanged?() + successfulSwitches += 1 + DispatchQueue.main.async { [weak self] in + self?.onStateChanged?() + } + } else { + lastError = "Unable to select the next enabled input source." } + case .toggleCapsLock: - postSyntheticCapsLock() + toggleRealCapsLock() } } - private func postSyntheticCapsLock() { - guard !isPostingSyntheticCaps, - let down = CGEvent( - keyboardEventSource: nil, - virtualKey: Self.capsLockKeyCode, - keyDown: true - ), - let up = CGEvent( - keyboardEventSource: nil, - virtualKey: Self.capsLockKeyCode, - keyDown: false - ) else { - return + private func toggleRealCapsLock() { + var locked = false + let readStatus = CapswitchReadCapsLock(&locked) + + if readStatus != 0 { + locked = CGEventSource.flagsState(.combinedSessionState) + .contains(.maskAlphaShift) } - isPostingSyntheticCaps = true - for event in [down, up] { - event.flags = [] - event.setIntegerValueField( - .eventSourceUserData, - value: Self.syntheticEventMarker - ) - event.post(tap: .cghidEventTap) + let writeStatus = CapswitchWriteCapsLock(!locked) + if writeStatus == 0 { + capsLockToggles += 1 + } else { + lastError = "IOKit Caps Lock toggle failed (code \(writeStatus))." + } + + DispatchQueue.main.async { [weak self] in + self?.onStateChanged?() } - isPostingSyntheticCaps = false } private func resetGestureState() { - _ = gestureMachine.handle(.reset) - alphaShiftDown = false - optionDown = false - isPostingSyntheticCaps = false - } - - private func combine( - _ first: GestureAction, - _ second: GestureAction - ) -> GestureAction { - first == .none ? second : first + inputRouter.reset() } } diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift index ea8e2c4..76d3d01 100644 --- a/Sources/CapswitchApp/StatusBarController.swift +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -11,6 +11,16 @@ final class StatusBarController: NSObject, NSMenuDelegate { action: nil, keyEquivalent: "" ) + private let diagnosticsItem = NSMenuItem( + title: "", + action: nil, + keyEquivalent: "" + ) + private let errorItem = NSMenuItem( + title: "", + action: nil, + keyEquivalent: "" + ) private let permissionItem = NSMenuItem( title: "", action: #selector(openAccessibilitySettings), @@ -23,6 +33,8 @@ final class StatusBarController: NSObject, NSMenuDelegate { ) private var refreshTimer: Timer? + private var nextStartAttempt = Date.distantPast + private var lastDeepRefresh = Date.distantPast init(inputController: MacOSInputController = MacOSInputController()) { self.inputController = inputController @@ -43,11 +55,19 @@ final class StatusBarController: NSObject, NSMenuDelegate { self?.refreshController() } + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(didWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + refreshController() } deinit { refreshTimer?.invalidate() + NSWorkspace.shared.notificationCenter.removeObserver(self) inputController.stop() statusItem.menu = nil NSStatusBar.system.removeStatusItem(statusItem) @@ -56,6 +76,8 @@ final class StatusBarController: NSObject, NSMenuDelegate { private func configureMenu() { menu.delegate = self menu.addItem(currentLayoutItem) + menu.addItem(diagnosticsItem) + menu.addItem(errorItem) menu.addItem(NSMenuItem.separator()) menu.addItem(pauseItem) menu.addItem(permissionItem) @@ -77,32 +99,61 @@ final class StatusBarController: NSObject, NSMenuDelegate { } private func refreshController() { - if inputController.accessibilityTrusted { - if !inputController.isRunning { - try? inputController.start() - } - inputController.refreshInputSources() + let now = Date() + + if !inputController.accessibilityTrusted && inputController.isRunning { + inputController.stop() } + + if inputController.accessibilityTrusted, + !inputController.isPaused, + !inputController.isRunning, + now >= nextStartAttempt { + nextStartAttempt = now.addingTimeInterval(5) + + do { + try inputController.start() + } catch { + inputController.lastError = error.localizedDescription + } + } + + if now.timeIntervalSince(lastDeepRefresh) >= 10 { + lastDeepRefresh = now + inputController.refreshInputSources() + if inputController.isRunning { + inputController.repairMappingIfNeeded() + } + } + update() } private func update() { - let shortName = inputController.currentInputSourceShortName - statusItem.button?.title = shortName - statusItem.button?.toolTip = "Capswitch - \(inputController.currentInputSourceName)" + let name = inputController.currentInputSourceName + statusItem.button?.title = inputController.isRunning + ? inputController.currentInputSourceShortName + : "Caps!" + statusItem.button?.toolTip = "Capswitch - \(name)" - currentLayoutItem.title = - "Current layout: \(inputController.currentInputSourceName)" + currentLayoutItem.title = "Current layout: \(name)" + diagnosticsItem.title = inputController.diagnosticsSummary + diagnosticsItem.isEnabled = false + + errorItem.isHidden = inputController.lastError == nil + errorItem.title = "Error: \(inputController.lastError ?? "")" + errorItem.toolTip = inputController.lastError + errorItem.isEnabled = false pauseItem.title = inputController.isPaused - ? "Resume switching" - : "Pause switching" + ? "Resume switching (restore F19)" + : "Pause switching (restore Caps)" pauseItem.state = inputController.isPaused ? .on : .off if inputController.accessibilityTrusted { permissionItem.title = inputController.isRunning - ? "Accessibility: Granted" - : "Accessibility: Granted (tap stopped)" + ? "Accessibility: Granted · Caps → F19 active" + : "Accessibility: Granted · switching inactive" } else { permissionItem.title = "Grant Accessibility permission…" } @@ -116,8 +167,14 @@ final class StatusBarController: NSObject, NSMenuDelegate { refreshController() } + @objc private func didWake(_ notification: Notification) { + inputController.repairMappingIfNeeded() + refreshController() + } + @objc private func togglePaused() { inputController.setPaused(!inputController.isPaused) + nextStartAttempt = .distantPast update() } diff --git a/Sources/CapswitchHID/CapswitchHID.c b/Sources/CapswitchHID/CapswitchHID.c new file mode 100644 index 0000000..947f32d --- /dev/null +++ b/Sources/CapswitchHID/CapswitchHID.c @@ -0,0 +1,61 @@ +#include "CapswitchHID.h" + +#include +#include +#include +#include + +static kern_return_t OpenHIDSystem(io_connect_t *connection) { + io_service_t service = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching(kIOHIDSystemClass) + ); + if (service == IO_OBJECT_NULL) { + return KERN_FAILURE; + } + + kern_return_t result = IOServiceOpen( + service, + mach_task_self(), + kIOHIDParamConnectType, + connection + ); + IOObjectRelease(service); + return result; +} + +int32_t CapswitchReadCapsLock(bool *enabled) { + if (enabled == NULL) { + return KERN_INVALID_ARGUMENT; + } + + io_connect_t connection = IO_OBJECT_NULL; + kern_return_t result = OpenHIDSystem(&connection); + if (result != KERN_SUCCESS) { + return result; + } + + result = IOHIDGetModifierLockState( + connection, + kIOHIDCapsLockState, + enabled + ); + IOServiceClose(connection); + return result; +} + +int32_t CapswitchWriteCapsLock(bool enabled) { + io_connect_t connection = IO_OBJECT_NULL; + kern_return_t result = OpenHIDSystem(&connection); + if (result != KERN_SUCCESS) { + return result; + } + + result = IOHIDSetModifierLockState( + connection, + kIOHIDCapsLockState, + enabled + ); + IOServiceClose(connection); + return result; +} diff --git a/Sources/CapswitchHID/include/CapswitchHID.h b/Sources/CapswitchHID/include/CapswitchHID.h new file mode 100644 index 0000000..ed3f379 --- /dev/null +++ b/Sources/CapswitchHID/include/CapswitchHID.h @@ -0,0 +1,11 @@ +#ifndef CAPSWITCH_HID_H +#define CAPSWITCH_HID_H + +#include +#include + +// Returns KERN_SUCCESS (0) or an IOKit/Mach error code. +int32_t CapswitchReadCapsLock(bool *enabled); +int32_t CapswitchWriteCapsLock(bool enabled); + +#endif diff --git a/Taskfile.yml b/Taskfile.yml index 59ed65d..16dff39 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,6 +39,10 @@ tasks: echo "SKIP: macOS app bundle requires Darwin with Command Line Tools" exit 0 fi + if pgrep -x Capswitch >/dev/null 2>&1; then + echo "ERROR: Quit running Capswitch from its menu before rebuilding." >&2 + exit 1 + fi rm -rf "{{.APP_DIR}}" mkdir -p "{{.APP_DIR}}/Contents/MacOS" "{{.APP_DIR}}/Contents/Resources" cp "{{.RELEASE_BINARY}}" "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" @@ -54,10 +58,15 @@ tasks: - test -f Sources/Capswitch/CapsGestureMachine.swift - test -f Sources/CapswitchApp/MacOSInputController.swift - grep -q "LSUIElement" Resources/Info.plist - - grep -q ".flagsChanged" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CGEventType.keyDown" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CGEventType.keyUp" Sources/CapswitchApp/MacOSInputController.swift - grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift - - grep -q "orderedSources" Sources/CapswitchApp/MacOSInputController.swift - - grep -q "inputSources.refresh" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift + - test -f Sources/CapswitchApp/HIDRemappingController.swift + - test -f Sources/Capswitch/HIDMapping.swift + - test -f Sources/CapswitchHID/CapswitchHID.c + - test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift + - test -f Tests/CapswitchCoreTests/HIDMappingCodecTests.swift - grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift - test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 diff --git a/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift b/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift index b0a25dd..742a7e1 100644 --- a/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift +++ b/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift @@ -14,8 +14,8 @@ final class CapsGestureMachineTests: XCTestCase { XCTAssertEqual(machine.handle(.optionDown), .none) XCTAssertEqual(machine.handle(.capsDown(optionDown: true)), .none) - XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock) - XCTAssertEqual(machine.handle(.optionUp), .none) + XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none) + XCTAssertEqual(machine.handle(.optionUp), .toggleCapsLock) } func testCapsBeforeOptionTogglesRealCapsLock() { @@ -23,8 +23,8 @@ final class CapsGestureMachineTests: XCTestCase { XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none) XCTAssertEqual(machine.handle(.optionDown), .none) - XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock) - XCTAssertEqual(machine.handle(.optionUp), .none) + XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none) + XCTAssertEqual(machine.handle(.optionUp), .toggleCapsLock) } func testOptionReleasedAfterCapsCompletesPendingToggle() { diff --git a/Tests/CapswitchCoreTests/CapsInputRouterTests.swift b/Tests/CapswitchCoreTests/CapsInputRouterTests.swift new file mode 100644 index 0000000..d16749b --- /dev/null +++ b/Tests/CapswitchCoreTests/CapsInputRouterTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import CapswitchCore + +final class CapsInputRouterTests: XCTestCase { + func testPhysicalCapsToF19UnderContinuousTyping() { + var router = CapsInputRouter() + + for _ in 0..<100 { + XCTAssertEqual(router.keyDown(code: 0, optionDown: false), .none) + XCTAssertEqual(router.keyDown(code: 80, optionDown: false), .none) + XCTAssertEqual(router.keyDown(code: 80, optionDown: false, isRepeat: true), .none) + XCTAssertEqual(router.keyUp(code: 0, optionDown: false), .none) + XCTAssertEqual(router.keyUp(code: 80, optionDown: false), .switchToNextInputSource) + } + } + + func testOptionBeforeF19TogglesOnlyAfterOptionRelease() { + var router = CapsInputRouter() + + XCTAssertEqual(router.flagsChanged(optionDown: true), .none) + XCTAssertEqual(router.keyDown(code: 80, optionDown: true), .none) + XCTAssertEqual(router.keyUp(code: 80, optionDown: true), .none) + XCTAssertEqual(router.flagsChanged(optionDown: false), .toggleCapsLock) + } + + func testOptionDuringF19TogglesNotSwitches() { + var router = CapsInputRouter() + + XCTAssertEqual(router.keyDown(code: 80, optionDown: false), .none) + XCTAssertEqual(router.flagsChanged(optionDown: true), .none) + XCTAssertEqual(router.flagsChanged(optionDown: false), .none) + XCTAssertEqual(router.keyUp(code: 80, optionDown: false), .toggleCapsLock) + } + + func testOptionReleaseOnNextF19DownKeepsPendingCapsToggle() { + var router = CapsInputRouter() + + XCTAssertEqual(router.flagsChanged(optionDown: true), .none) + XCTAssertEqual(router.keyDown(code: 80, optionDown: true), .none) + XCTAssertEqual(router.keyUp(code: 80, optionDown: true), .none) + + // When the Option-up event is folded into the following key event, + // complete the previous toggle without losing it. + XCTAssertEqual(router.keyDown(code: 80, optionDown: false), .toggleCapsLock) + XCTAssertEqual(router.keyUp(code: 80, optionDown: false), .switchToNextInputSource) + } + + func testOrphanReleaseAndResetDoNotSwitch() { + var router = CapsInputRouter() + + XCTAssertEqual(router.keyUp(code: 80, optionDown: false), .none) + XCTAssertEqual(router.keyDown(code: 80, optionDown: false), .none) + router.reset() + XCTAssertEqual(router.keyUp(code: 80, optionDown: false), .none) + } +} diff --git a/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift b/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift new file mode 100644 index 0000000..7ce9a69 --- /dev/null +++ b/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift @@ -0,0 +1,58 @@ +import Foundation +import XCTest +@testable import CapswitchCore + +final class HIDMappingCodecTests: XCTestCase { + func testMissingMappingsReadAsEmpty() throws { + XCTAssertEqual(try HIDMappingCodec.parse("(null)\n"), []) + XCTAssertEqual(try HIDMappingCodec.parse("UserKeyMapping:(\n)\n"), []) + } + + func testReadsDecimalMappingsAndPreservesTheirOrder() throws { + let output = """ + UserKeyMapping:( + { + HIDKeyboardModifierMappingDst = 30064771125; + HIDKeyboardModifierMappingSrc = 30064771172; + }, + { + HIDKeyboardModifierMappingSrc = 30064771129; + HIDKeyboardModifierMappingDst = 30064771182; + } + ) + """ + let mappings = try HIDMappingCodec.parse(output) + + XCTAssertEqual(mappings, [ + HIDKeyMapping(source: 30064771172, destination: 30064771125), + .init(source: 30064771129, destination: 30064771182), + ]) + XCTAssertEqual(mappings[1], HIDMappingCodec.capsToF19) + } + + func testEncodeMappingPreservesOtherEntries() throws { + let original = [HIDKeyMapping(source: 30064771302, destination: 30064771300)] + let installed = original + [HIDMappingCodec.capsToF19] + let remaining = installed.filter { $0 != HIDMappingCodec.capsToF19 } + XCTAssertEqual(remaining, original) + + let payload = try HIDMappingCodec.setArgument(installed) + let dictionary = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any] + ) + let entries = try XCTUnwrap(dictionary["UserKeyMapping"] as? [[String: NSNumber]]) + XCTAssertEqual(entries.count, 2) + XCTAssertEqual( + entries[1]["HIDKeyboardModifierMappingDst"]?.uint64Value, + HIDMappingCodec.f19Usage + ) + } + + func testUnknownFieldsOrGarbageAreRejected() { + XCTAssertThrowsError(try HIDMappingCodec.parse("hidutil failed")) + XCTAssertThrowsError(try HIDMappingCodec.parse("({ Foo = 123; })")) + XCTAssertThrowsError(try HIDMappingCodec.parse( + "({ HIDKeyboardModifierMappingSrc = 1; HIDKeyboardModifierMappingDst = 2; Mystery = 3; })" + )) + } +}