135 lines
4.6 KiB
Swift
135 lines
4.6 KiB
Swift
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)
|
|
}
|
|
}
|