From 0361377417ca62b20d5725f26a0c17082e7ff683 Mon Sep 17 00:00:00 2001 From: root 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 ++++ .../superpowers/plans/2026-10-25-capswitch.md | 11 + .../specs/2026-10-25-caps-f19-remap.md | 57 ++++ 17 files changed, 894 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 create mode 100644 docs/superpowers/specs/2026-10-25-caps-f19-remap.md 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 4ed0e3a..fc13821 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 a418ab4..a5b082a 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; })" + )) + } +} diff --git a/docs/superpowers/plans/2026-10-25-capswitch.md b/docs/superpowers/plans/2026-10-25-capswitch.md index bc8e985..3d6087a 100644 --- a/docs/superpowers/plans/2026-10-25-capswitch.md +++ b/docs/superpowers/plans/2026-10-25-capswitch.md @@ -107,3 +107,14 @@ - [ ] Commit on \`main\`. - [ ] Push \`main\` to \`origin\`. - [ ] Report commit, remote branch, and Mac-only checks still required. + + +## 2026-10-25 follow-up: HID remediation + +The event-tap flagsChanged design is superseded for physical Caps by: +- docs/superpowers/specs/2026-10-25-caps-f19-remap.md + +The original Caps Lock event-tap approach was not reliable during fast typing. +The code now relies on a Caps-to-F19 HID mapping, direct F19 keyDown/keyUp, +a managed remap lifecycle, and a small IOKit bridge for true Caps Lock state. +The original spec is retained for historical debugging context. diff --git a/docs/superpowers/specs/2026-10-25-caps-f19-remap.md b/docs/superpowers/specs/2026-10-25-caps-f19-remap.md new file mode 100644 index 0000000..eab5ff0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-caps-f19-remap.md @@ -0,0 +1,57 @@ +# Capswitch HID-Level Caps Lock Remapping + +## Problem + +macOS filters and toggles physical Caps Lock in the HID / WindowServer path. +A Quartz flagsChanged event does not reliably represent physical key-down and +key-up, especially for very short presses interleaved with normal typing. +Fixing CGEventTap callback timing alone cannot recover events already discarded. + +## Chosen approach + +- Use Apple's hidutil UserKeyMapping to translate HID Caps Lock usage + 0x700000039 into HID F19 usage 0x70000006E. +- Handle ordinary virtual F19 (keycode 80) keyDown and keyUp through a + session-level CGEventTap, suppressing both. +- Retain Option modifier tracking through flagsChanged and the pure state + machine. Switching occurs synchronously on F19 release, never on F19 down. +- Keep TIS source enumeration outside the event tap callback and periodically + refresh the list. The TISSelectInputSource call remains synchronous. +- Use a small IOKit C shim from SwiftPM to set the system Caps Lock lock state + for the Option combination. +- Expose F19 down/up and input-switch counts in the status bar for real-Mac + acceptance testing without logging typed characters. + +## Lifecycle and safety + +- Create and enable the event tap before applying the global HID remap. +- Read and validate all existing mappings first; refuse Caps or F19 conflicts. +- Merge with any unrelated mappings and verify the result after writing. +- On Pause or Quit, disable interception and remove only Capswitch's exact pair. +- Persist an ownership marker for crash recovery; do not remove pre-existing + Caps -> F19 mappings not owned by Capswitch. +- Reconcile HID state on wake and periodically, since keyboard disconnects or + reboot can reset UserKeyMapping. +- Run only one Capswitch instance for the same bundle ID. +- Refuse app bundle replacement while a Capswitch process is running. +- Treat nonstandard/unparseable hidutil output as a hard error, not permission + to erase foreign mappings. + +## Tests + +XCTest covers rapid F19 presses mixed with normal keys, key repeat suppression, +Caps+Option in either order, pending Option release, router reset/orphan release, +hidutil OpenStep mapping output, and foreign mapping preservation in JSON. + +The real macOS acceptance suite must verify: + +- Clean Command Line Tools compilation with no Xcode project. +- Short Caps taps during continuous typing are observed as F19 down/up. +- On Caps release, the next typed letter uses the next configured layout. +- No stock input-source animation or HUD is triggered. +- Option+Caps toggles real Caps Lock on and off including the LED. +- Pause and Quit restore only the managed mapping. +- USB/Bluetooth devices and wake/reconnect recovery work. +- All Accessibility/TCC and old-process conditions are surfaced to the user. + +Current Linux host cannot run AppKit or HID hardware tests.