feat: remap physical Caps to F19 for reliable switching

This commit is contained in:
casona 2026-10-25 21:10:00 +03:00
parent 8224d78967
commit 394ff78431
15 changed files with 826 additions and 163 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ build/
.DS_Store .DS_Store
.swiftpm/ .swiftpm/
.task/ .task/
.build/

View file

@ -14,8 +14,15 @@ let package = Package(
), ),
.executableTarget( .executableTarget(
name: "Capswitch", name: "Capswitch",
dependencies: ["CapswitchCore"], dependencies: ["CapswitchCore", "CapswitchHID"],
path: "Sources/CapswitchApp" path: "Sources/CapswitchApp",
linkerSettings: [.linkedFramework("IOKit")]
),
.target(
name: "CapswitchHID",
path: "Sources/CapswitchHID",
publicHeadersPath: "include",
linkerSettings: [.linkedFramework("IOKit")]
), ),
.testTarget( .testTarget(
name: "CapswitchCoreTests", name: "CapswitchCoreTests",

View file

@ -1,37 +1,74 @@
# Capswitch # 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 ## Behavior
- Plain Caps: switch once on release. - 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: preserve real Caps Lock. - Caps+Option in either order toggles the actual system Caps Lock state through IOKit after Option has been released.
- Stock Caps/input-source events are suppressed. - Pause from the menu disables the event tap and restores the Caps key to its original purpose.
- The app has no Dock icon and reports Accessibility permission in its menu. - 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 F19 ↓N ↑N · switches N
xcode-select --install
task bundle
open build/Capswitch.app
```
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 lint
task test task test
task build task build
task bundle task bundle
task clean task clean
task verify 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.

View file

@ -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)
}
}

View file

@ -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..<inner.endIndex, in: inner)
)
guard !matches.isEmpty else {
throw HIDMappingCodecError.unrecognizedOutput
}
let remainder = NSMutableString(string: inner)
var mappings: [HIDKeyMapping] = []
for match in matches.reversed() {
guard let bodyRange = Range(match.range(at: 1), in: inner) else {
throw HIDMappingCodecError.invalidEntry
}
mappings.insert(try parseEntry(String(inner[bodyRange])), at: 0)
remainder.deleteCharacters(in: match.range)
}
let separators = (remainder as String).replacingOccurrences(
of: #"[\s,]"#,
with: "",
options: .regularExpression
)
guard separators.isEmpty else {
throw HIDMappingCodecError.unrecognizedOutput
}
return mappings
}
public static func setArgument(_ mappings: [HIDKeyMapping]) throws -> 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..<body.endIndex, in: body)
)
guard matches.count == 2 else {
throw HIDMappingCodecError.invalidEntry
}
let remainder = NSMutableString(string: body)
var fields: [String: UInt64] = [:]
for match in matches.reversed() {
guard let nameRange = Range(match.range(at: 1), in: body),
let valueRange = Range(match.range(at: 2), in: body) else {
throw HIDMappingCodecError.invalidEntry
}
let name = String(body[nameRange])
let literal = body[valueRange]
.trimmingCharacters(in: .whitespacesAndNewlines)
let number: UInt64?
if literal.lowercased().hasPrefix("0x") {
number = UInt64(literal.dropFirst(2), radix: 16)
} else {
number = UInt64(literal)
}
guard let number, fields[name] == nil else {
throw HIDMappingCodecError.invalidEntry
}
fields[name] = number
remainder.deleteCharacters(in: match.range)
}
guard (remainder as String)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
fields.count == 2,
let source = fields["HIDKeyboardModifierMappingSrc"],
let destination = fields["HIDKeyboardModifierMappingDst"] else {
throw HIDMappingCodecError.invalidEntry
}
return HIDKeyMapping(source: source, destination: destination)
}
}

View file

@ -5,6 +5,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
// Only one process may own the global Caps -> 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() statusBarController = StatusBarController()
} }

View file

@ -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)
}
}

View file

@ -1,5 +1,6 @@
import AppKit import AppKit
import CapswitchCore import CapswitchCore
import CapswitchHID
import ApplicationServices import ApplicationServices
import Carbon import Carbon
import CoreGraphics import CoreGraphics
@ -82,11 +83,24 @@ final class InputSourceManager {
return Source(inputSource: inputSource, id: id) 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() let configuredIDs = configuredInputSourceIDs()
var ordered = configuredIDs.compactMap { byID[$0] } var usedIDs: Set<String> = []
let configuredSet = Set(configuredIDs) var ordered: [Source] = []
ordered.append(contentsOf: sources.filter { !configuredSet.contains($0.id) })
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 return ordered
} }
@ -95,9 +109,8 @@ final class InputSourceManager {
} }
private func configuredInputSourceIDs() -> [String] { private func configuredInputSourceIDs() -> [String] {
guard let entries = UserDefaults.standard.array( guard let entries = UserDefaults(suiteName: "com.apple.HIToolbox")?
forKey: "AppleEnabledInputSources" .array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else {
) as? [[String: Any]] else {
return [] return []
} }
@ -130,23 +143,25 @@ final class InputSourceManager {
} }
final class MacOSInputController { final class MacOSInputController {
private static let capsLockKeyCode: CGKeyCode = 57
private static let syntheticEventMarker: Int64 = 0x4361707353776974
private let inputSources = InputSourceManager() private let inputSources = InputSourceManager()
private let remapper = HIDRemappingController()
private var eventTap: CFMachPort? private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource? private var runLoopSource: CFRunLoopSource?
private var gestureMachine = CapsGestureMachine() private var inputRouter = CapsInputRouter()
private var alphaShiftDown = false
private var optionDown = false private(set) var observedF19Down = 0
private var isPostingSyntheticCaps = false private(set) var observedF19Up = 0
private(set) var successfulSwitches = 0
private(set) var capsLockToggles = 0
var isPaused = false var isPaused = false
var lastError: String?
var onStateChanged: (() -> Void)?
var isRunning: Bool { var isRunning: Bool {
eventTap != nil eventTap != nil && remapper.isInstalled
} }
var onStateChanged: (() -> Void)?
var currentInputSourceName: String { var currentInputSourceName: String {
inputSources.currentDisplayName inputSources.currentDisplayName
@ -160,27 +175,32 @@ final class MacOSInputController {
AXIsProcessTrusted() AXIsProcessTrusted()
} }
var diagnosticsSummary: String {
"F19 ↓\(observedF19Down) ↑\(observedF19Up) · switches \(successfulSwitches)"
}
func start() throws { func start() throws {
guard eventTap == nil else { guard !isPaused, eventTap == nil else {
return return
} }
guard AXIsProcessTrusted() else { guard accessibilityTrusted else {
throw InputControllerError.accessibilityRequired throw InputControllerError.accessibilityRequired
} }
// Build the source order before the event tap starts. Enumerating TIS // Enumerate input sources before entering the event tap callback.
// sources from inside a keyboard callback can block long enough for
// macOS to disable the tap while the user is typing quickly.
inputSources.refresh() 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() let refcon = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEvent.tapCreate( guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap, tap: .cgSessionEventTap,
place: .headInsertEventTap, place: .headInsertEventTap,
options: .defaultTap, options: .defaultTap,
eventsOfInterest: eventMask, eventsOfInterest: mask,
callback: Self.eventTapCallback, callback: Self.eventTapCallback,
userInfo: refcon userInfo: refcon
) else { ) else {
@ -201,9 +221,19 @@ final class MacOSInputController {
.commonModes .commonModes
) )
} }
CGEvent.tapEnable(tap: tap, enable: true) CGEvent.tapEnable(tap: tap, enable: true)
resetGestureState() 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?() onStateChanged?()
} }
@ -223,12 +253,30 @@ final class MacOSInputController {
eventTap = nil eventTap = nil
runLoopSource = nil runLoopSource = nil
resetGestureState() resetGestureState()
do {
try remapper.restoreIfOwned()
} catch {
lastError = "Cannot restore Caps remap: \(error.localizedDescription)"
}
onStateChanged?() onStateChanged?()
} }
func setPaused(_ paused: Bool) { func setPaused(_ paused: Bool) {
guard paused != isPaused else {
return
}
isPaused = paused isPaused = paused
resetGestureState() if paused {
stop()
} else {
do {
try start()
} catch {
lastError = error.localizedDescription
}
}
onStateChanged?() onStateChanged?()
} }
@ -236,6 +284,21 @@ final class MacOSInputController {
inputSources.refresh() 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() { func openAccessibilitySettings() {
guard let url = URL( guard let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
@ -247,6 +310,7 @@ final class MacOSInputController {
private static let eventTapCallback: CGEventTapCallBack = { private static let eventTapCallback: CGEventTapCallBack = {
_, type, event, refcon in _, type, event, refcon in
guard let refcon else { guard let refcon else {
return Unmanaged.passUnretained(event) return Unmanaged.passUnretained(event)
} }
@ -262,11 +326,6 @@ final class MacOSInputController {
type: CGEventType, type: CGEventType,
event: CGEvent event: CGEvent
) -> Unmanaged<CGEvent>? { ) -> Unmanaged<CGEvent>? {
if event.getIntegerValueField(.eventSourceUserData)
== Self.syntheticEventMarker {
return Unmanaged.passUnretained(event)
}
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
resetGestureState() resetGestureState()
if let eventTap { if let eventTap {
@ -275,123 +334,88 @@ final class MacOSInputController {
return Unmanaged.passUnretained(event) 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) return Unmanaged.passUnretained(event)
} }
let flags = event.flags guard type == .keyDown || type == .keyUp else {
let newAlphaShiftDown = flags.contains(.maskAlphaShift)
let newOptionDown = flags.contains(.maskAlternate)
let isCapsEvent = event.getIntegerValueField(.keyboardEventKeycode)
== Int64(Self.capsLockKeyCode)
if isPaused {
if isCapsEvent {
alphaShiftDown = newAlphaShiftDown
}
optionDown = newOptionDown
return Unmanaged.passUnretained(event) return Unmanaged.passUnretained(event)
} }
// Alpha-shift is also used by other keyboard events. Use the physical let keyCode = UInt16(
// key code so a fast sequence of unrelated flagsChanged events cannot truncatingIfNeeded: event.getIntegerValueField(.keyboardEventKeycode)
// 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))
) )
// 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 capsUp { if type == .keyDown {
action = combine( let repeated = event.getIntegerValueField(.keyboardEventAutorepeat) != 0
action, if !repeated {
gestureMachine.handle(.capsUp(optionDown: newOptionDown)) observedF19Down += 1
) }
apply(inputRouter.keyDown(
code: keyCode,
optionDown: optionDown,
isRepeat: repeated
))
} else {
observedF19Up += 1
apply(inputRouter.keyUp(code: keyCode, optionDown: optionDown))
} }
if optionReleased {
action = combine(action, gestureMachine.handle(.optionUp))
}
if isCapsEvent {
alphaShiftDown = newAlphaShiftDown
}
optionDown = newOptionDown
apply(action)
if capsDown || capsUp {
return nil return nil
} }
return Unmanaged.passUnretained(event)
}
private func apply(_ action: GestureAction) { private func apply(_ action: GestureAction) {
switch action { switch action {
case .none: case .none:
break break
case .switchToNextInputSource: case .switchToNextInputSource:
// The source list is cached before the tap starts, so this path is // Synchronous: the next typed letter must see the new layout.
// deliberately synchronous: the next key must see the new source
// even when the user is typing continuously.
if inputSources.selectNextInputSource() { 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: case .toggleCapsLock:
postSyntheticCapsLock() toggleRealCapsLock()
} }
} }
private func postSyntheticCapsLock() { private func toggleRealCapsLock() {
guard !isPostingSyntheticCaps, var locked = false
let down = CGEvent( let readStatus = CapswitchReadCapsLock(&locked)
keyboardEventSource: nil,
virtualKey: Self.capsLockKeyCode, if readStatus != 0 {
keyDown: true locked = CGEventSource.flagsState(.combinedSessionState)
), .contains(.maskAlphaShift)
let up = CGEvent(
keyboardEventSource: nil,
virtualKey: Self.capsLockKeyCode,
keyDown: false
) else {
return
} }
isPostingSyntheticCaps = true let writeStatus = CapswitchWriteCapsLock(!locked)
for event in [down, up] { if writeStatus == 0 {
event.flags = [] capsLockToggles += 1
event.setIntegerValueField( } else {
.eventSourceUserData, lastError = "IOKit Caps Lock toggle failed (code \(writeStatus))."
value: Self.syntheticEventMarker }
)
event.post(tap: .cghidEventTap) DispatchQueue.main.async { [weak self] in
self?.onStateChanged?()
} }
isPostingSyntheticCaps = false
} }
private func resetGestureState() { private func resetGestureState() {
_ = gestureMachine.handle(.reset) inputRouter.reset()
alphaShiftDown = false
optionDown = false
isPostingSyntheticCaps = false
}
private func combine(
_ first: GestureAction,
_ second: GestureAction
) -> GestureAction {
first == .none ? second : first
} }
} }

View file

@ -11,6 +11,16 @@ final class StatusBarController: NSObject, NSMenuDelegate {
action: nil, action: nil,
keyEquivalent: "" keyEquivalent: ""
) )
private let diagnosticsItem = NSMenuItem(
title: "",
action: nil,
keyEquivalent: ""
)
private let errorItem = NSMenuItem(
title: "",
action: nil,
keyEquivalent: ""
)
private let permissionItem = NSMenuItem( private let permissionItem = NSMenuItem(
title: "", title: "",
action: #selector(openAccessibilitySettings), action: #selector(openAccessibilitySettings),
@ -23,6 +33,8 @@ final class StatusBarController: NSObject, NSMenuDelegate {
) )
private var refreshTimer: Timer? private var refreshTimer: Timer?
private var nextStartAttempt = Date.distantPast
private var lastDeepRefresh = Date.distantPast
init(inputController: MacOSInputController = MacOSInputController()) { init(inputController: MacOSInputController = MacOSInputController()) {
self.inputController = inputController self.inputController = inputController
@ -43,11 +55,19 @@ final class StatusBarController: NSObject, NSMenuDelegate {
self?.refreshController() self?.refreshController()
} }
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(didWake),
name: NSWorkspace.didWakeNotification,
object: nil
)
refreshController() refreshController()
} }
deinit { deinit {
refreshTimer?.invalidate() refreshTimer?.invalidate()
NSWorkspace.shared.notificationCenter.removeObserver(self)
inputController.stop() inputController.stop()
statusItem.menu = nil statusItem.menu = nil
NSStatusBar.system.removeStatusItem(statusItem) NSStatusBar.system.removeStatusItem(statusItem)
@ -56,6 +76,8 @@ final class StatusBarController: NSObject, NSMenuDelegate {
private func configureMenu() { private func configureMenu() {
menu.delegate = self menu.delegate = self
menu.addItem(currentLayoutItem) menu.addItem(currentLayoutItem)
menu.addItem(diagnosticsItem)
menu.addItem(errorItem)
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
menu.addItem(pauseItem) menu.addItem(pauseItem)
menu.addItem(permissionItem) menu.addItem(permissionItem)
@ -77,32 +99,61 @@ final class StatusBarController: NSObject, NSMenuDelegate {
} }
private func refreshController() { private func refreshController() {
if inputController.accessibilityTrusted { let now = Date()
if !inputController.isRunning {
try? inputController.start() 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() inputController.refreshInputSources()
if inputController.isRunning {
inputController.repairMappingIfNeeded()
} }
}
update() update()
} }
private func update() { private func update() {
let shortName = inputController.currentInputSourceShortName let name = inputController.currentInputSourceName
statusItem.button?.title = shortName statusItem.button?.title = inputController.isRunning
statusItem.button?.toolTip = "Capswitch - \(inputController.currentInputSourceName)" ? inputController.currentInputSourceShortName
: "Caps!"
statusItem.button?.toolTip = "Capswitch - \(name)"
currentLayoutItem.title = currentLayoutItem.title = "Current layout: \(name)"
"Current layout: \(inputController.currentInputSourceName)" 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 pauseItem.title = inputController.isPaused
? "Resume switching" ? "Resume switching (restore F19)"
: "Pause switching" : "Pause switching (restore Caps)"
pauseItem.state = inputController.isPaused ? .on : .off pauseItem.state = inputController.isPaused ? .on : .off
if inputController.accessibilityTrusted { if inputController.accessibilityTrusted {
permissionItem.title = inputController.isRunning permissionItem.title = inputController.isRunning
? "Accessibility: Granted" ? "Accessibility: Granted · Caps → F19 active"
: "Accessibility: Granted (tap stopped)" : "Accessibility: Granted · switching inactive"
} else { } else {
permissionItem.title = "Grant Accessibility permission…" permissionItem.title = "Grant Accessibility permission…"
} }
@ -116,8 +167,14 @@ final class StatusBarController: NSObject, NSMenuDelegate {
refreshController() refreshController()
} }
@objc private func didWake(_ notification: Notification) {
inputController.repairMappingIfNeeded()
refreshController()
}
@objc private func togglePaused() { @objc private func togglePaused() {
inputController.setPaused(!inputController.isPaused) inputController.setPaused(!inputController.isPaused)
nextStartAttempt = .distantPast
update() update()
} }

View file

@ -0,0 +1,61 @@
#include "CapswitchHID.h"
#include <IOKit/IOKitLib.h>
#include <IOKit/hidsystem/IOHIDLib.h>
#include <IOKit/hidsystem/IOHIDParameter.h>
#include <mach/mach.h>
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;
}

View file

@ -0,0 +1,11 @@
#ifndef CAPSWITCH_HID_H
#define CAPSWITCH_HID_H
#include <stdbool.h>
#include <stdint.h>
// Returns KERN_SUCCESS (0) or an IOKit/Mach error code.
int32_t CapswitchReadCapsLock(bool *enabled);
int32_t CapswitchWriteCapsLock(bool enabled);
#endif

View file

@ -39,6 +39,10 @@ tasks:
echo "SKIP: macOS app bundle requires Darwin with Command Line Tools" echo "SKIP: macOS app bundle requires Darwin with Command Line Tools"
exit 0 exit 0
fi 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}}" rm -rf "{{.APP_DIR}}"
mkdir -p "{{.APP_DIR}}/Contents/MacOS" "{{.APP_DIR}}/Contents/Resources" mkdir -p "{{.APP_DIR}}/Contents/MacOS" "{{.APP_DIR}}/Contents/Resources"
cp "{{.RELEASE_BINARY}}" "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" cp "{{.RELEASE_BINARY}}" "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}"
@ -54,10 +58,15 @@ tasks:
- test -f Sources/Capswitch/CapsGestureMachine.swift - test -f Sources/Capswitch/CapsGestureMachine.swift
- test -f Sources/CapswitchApp/MacOSInputController.swift - test -f Sources/CapswitchApp/MacOSInputController.swift
- grep -q "LSUIElement" Resources/Info.plist - 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 "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "orderedSources" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "inputSources.refresh" 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.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift
- test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0

View file

@ -14,8 +14,8 @@ final class CapsGestureMachineTests: XCTestCase {
XCTAssertEqual(machine.handle(.optionDown), .none) XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsDown(optionDown: true)), .none) XCTAssertEqual(machine.handle(.capsDown(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock) XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.optionUp), .none) XCTAssertEqual(machine.handle(.optionUp), .toggleCapsLock)
} }
func testCapsBeforeOptionTogglesRealCapsLock() { func testCapsBeforeOptionTogglesRealCapsLock() {
@ -23,8 +23,8 @@ final class CapsGestureMachineTests: XCTestCase {
XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none) XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none)
XCTAssertEqual(machine.handle(.optionDown), .none) XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock) XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.optionUp), .none) XCTAssertEqual(machine.handle(.optionUp), .toggleCapsLock)
} }
func testOptionReleasedAfterCapsCompletesPendingToggle() { func testOptionReleasedAfterCapsCompletesPendingToggle() {

View file

@ -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)
}
}

View file

@ -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; })"
))
}
}