feat: remap physical Caps to F19 for reliable switching
This commit is contained in:
parent
f4606e44a4
commit
0361377417
17 changed files with 894 additions and 163 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
.task/
|
.task/
|
||||||
|
.build/
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
73
README.md
73
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
53
Sources/Capswitch/CapsInputRouter.swift
Normal file
53
Sources/Capswitch/CapsInputRouter.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Sources/Capswitch/HIDMapping.swift
Normal file
143
Sources/Capswitch/HIDMapping.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
135
Sources/CapswitchApp/HIDRemappingController.swift
Normal file
135
Sources/CapswitchApp/HIDRemappingController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
return Unmanaged.passUnretained(event)
|
||||||
let newOptionDown = flags.contains(.maskAlternate)
|
}
|
||||||
let isCapsEvent = event.getIntegerValueField(.keyboardEventKeycode)
|
|
||||||
== Int64(Self.capsLockKeyCode)
|
|
||||||
|
|
||||||
if isPaused {
|
let keyCode = UInt16(
|
||||||
if isCapsEvent {
|
truncatingIfNeeded: event.getIntegerValueField(.keyboardEventKeycode)
|
||||||
alphaShiftDown = newAlphaShiftDown
|
)
|
||||||
|
|
||||||
|
// 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
|
apply(inputRouter.keyDown(
|
||||||
return Unmanaged.passUnretained(event)
|
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
|
return nil
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
inputController.refreshInputSources()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
61
Sources/CapswitchHID/CapswitchHID.c
Normal file
61
Sources/CapswitchHID/CapswitchHID.c
Normal 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;
|
||||||
|
}
|
||||||
11
Sources/CapswitchHID/include/CapswitchHID.h
Normal file
11
Sources/CapswitchHID/include/CapswitchHID.h
Normal 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
|
||||||
15
Taskfile.yml
15
Taskfile.yml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
56
Tests/CapswitchCoreTests/CapsInputRouterTests.swift
Normal file
56
Tests/CapswitchCoreTests/CapsInputRouterTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Tests/CapswitchCoreTests/HIDMappingCodecTests.swift
Normal file
58
Tests/CapswitchCoreTests/HIDMappingCodecTests.swift
Normal 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; })"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -107,3 +107,14 @@
|
||||||
- [ ] Commit on \`main\`.
|
- [ ] Commit on \`main\`.
|
||||||
- [ ] Push \`main\` to \`origin\`.
|
- [ ] Push \`main\` to \`origin\`.
|
||||||
- [ ] Report commit, remote branch, and Mac-only checks still required.
|
- [ ] 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.
|
||||||
|
|
|
||||||
57
docs/superpowers/specs/2026-10-25-caps-f19-remap.md
Normal file
57
docs/superpowers/specs/2026-10-25-caps-f19-remap.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Reference in a new issue