macoc/Sources/CapswitchApp/HIDRemappingController.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)
}
}