import AppKit import CapswitchCore import CapswitchHID import ApplicationServices import Carbon import CoreGraphics import Foundation enum InputControllerError: LocalizedError { case accessibilityRequired case eventTapUnavailable var errorDescription: String? { switch self { case .accessibilityRequired: return "Accessibility permission is required to intercept Caps Lock." case .eventTapUnavailable: return "macOS did not allow the global keyboard event tap to start." } } } final class InputSourceManager { private struct Source { let inputSource: TISInputSource let id: String } private var orderedSources: [Source] = [] var currentDisplayName: String { guard let source = currentSource() else { return "—" } return property(source, key: kTISPropertyLocalizedName) ?? property(source, key: kTISPropertyInputSourceID) ?? "—" } var currentShortName: String { let name = currentDisplayName if let first = name.split(whereSeparator: { $0 == " " || $0 == "-" }).first, !first.isEmpty { return String(first).uppercased() } return name } func refresh() { orderedSources = enabledKeyboardSourcesInUserOrder() } @discardableResult func selectNextInputSource() -> Bool { let sources = orderedSources guard sources.count > 1 else { return false } let currentID = currentSource().flatMap { property($0, key: kTISPropertyInputSourceID) } let currentIndex = currentID.flatMap { id in sources.firstIndex { $0.id == id } } let nextIndex = ((currentIndex ?? -1) + 1) % sources.count return TISSelectInputSource(sources[nextIndex].inputSource) == noErr } private func enabledKeyboardSourcesInUserOrder() -> [Source] { let allSources = TISCreateInputSourceList(nil, false) .takeRetainedValue() as NSArray as! [TISInputSource] let sources = allSources.compactMap { inputSource -> Source? in guard isEnabled(inputSource), property(inputSource, key: kTISPropertyInputSourceCategory) == (kTISCategoryKeyboardInputSource as String), let id = property(inputSource, key: kTISPropertyInputSourceID) else { return nil } return Source(inputSource: inputSource, id: id) } var byID: [String: Source] = [:] for source in sources { byID[source.id] = source } let configuredIDs = configuredInputSourceIDs() var usedIDs: Set = [] 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 } private func currentSource() -> TISInputSource? { TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() } private func configuredInputSourceIDs() -> [String] { guard let entries = UserDefaults(suiteName: "com.apple.HIToolbox")? .array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { return [] } return entries.compactMap { $0["InputSourceID"] as? String } } private func isEnabled(_ inputSource: TISInputSource) -> Bool { guard let pointer = TISGetInputSourceProperty( inputSource, kTISPropertyInputSourceIsEnabled ) else { return false } let value = Unmanaged.fromOpaque(pointer).takeUnretainedValue() return CFBooleanGetValue(value) } private func property( _ inputSource: TISInputSource, key: CFString ) -> String? { guard let pointer = TISGetInputSourceProperty(inputSource, key) else { return nil } return Unmanaged.fromOpaque(pointer) .takeUnretainedValue() as String } } final class MacOSInputController { private let inputSources = InputSourceManager() private let remapper = HIDRemappingController() private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? 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 && remapper.isInstalled } var currentInputSourceName: String { inputSources.currentDisplayName } var currentInputSourceShortName: String { inputSources.currentShortName } var accessibilityTrusted: Bool { AXIsProcessTrusted() } var diagnosticsSummary: String { "F19 ↓\(observedF19Down) ↑\(observedF19Up) · switches \(successfulSwitches)" } func start() throws { guard !isPaused, eventTap == nil else { return } guard accessibilityTrusted else { throw InputControllerError.accessibilityRequired } // Enumerate input sources before entering the event tap callback. inputSources.refresh() 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: mask, callback: Self.eventTapCallback, userInfo: refcon ) else { throw InputControllerError.eventTapUnavailable } eventTap = tap runLoopSource = CFMachPortCreateRunLoopSource( kCFAllocatorDefault, tap, 0 ) if let runLoopSource { CFRunLoopAddSource( CFRunLoopGetMain(), runLoopSource, .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?() } func stop() { if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } if let runLoopSource { CFRunLoopRemoveSource( CFRunLoopGetMain(), runLoopSource, .commonModes ) } 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 if paused { stop() } else { do { try start() } catch { lastError = error.localizedDescription } } onStateChanged?() } func refreshInputSources() { 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" ) else { return } NSWorkspace.shared.open(url) } private static let eventTapCallback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon else { return Unmanaged.passUnretained(event) } let controller = Unmanaged .fromOpaque(refcon) .takeUnretainedValue() return controller.handle(type: type, event: event) } private func handle( type: CGEventType, event: CGEvent ) -> Unmanaged? { if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { resetGestureState() if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } return Unmanaged.passUnretained(event) } let optionDown = event.flags.contains(.maskAlternate) if type == .flagsChanged { apply(inputRouter.flagsChanged(optionDown: optionDown)) return Unmanaged.passUnretained(event) } guard type == .keyDown || type == .keyUp else { return Unmanaged.passUnretained(event) } 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 } apply(inputRouter.keyDown( code: keyCode, optionDown: optionDown, isRepeat: repeated )) } else { observedF19Up += 1 apply(inputRouter.keyUp(code: keyCode, optionDown: optionDown)) } return nil } private func apply(_ action: GestureAction) { switch action { case .none: break case .switchToNextInputSource: // Synchronous: the next typed letter must see the new layout. if inputSources.selectNextInputSource() { successfulSwitches += 1 DispatchQueue.main.async { [weak self] in self?.onStateChanged?() } } else { lastError = "Unable to select the next enabled input source." } case .toggleCapsLock: toggleRealCapsLock() } } private func toggleRealCapsLock() { var locked = false let readStatus = CapswitchReadCapsLock(&locked) if readStatus != 0 { locked = CGEventSource.flagsState(.combinedSessionState) .contains(.maskAlphaShift) } 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?() } } private func resetGestureState() { inputRouter.reset() } }