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
.swiftpm/
.task/
.build/

View file

@ -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",

View file

@ -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.

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) {
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()
}

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 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<String> = []
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<CGEvent>? {
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()
}
}

View file

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

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"
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

View file

@ -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() {

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