From 37afb3b492d349bb173733526a4baa32400cedfc Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 02:33:01 +0200 Subject: [PATCH 01/12] refactor: extract device-key resolution from AppState Move canonical-key matching, configured-source resolution and the active-device check into DeviceKeyResolver as pure functions that take every input as a parameter. No behaviour change; AppState drops from 1,115 to 1,037 lines before the 1.1.0 features land in it. --- InputPilot/App/AppState.swift | 112 +++----------------- InputPilot/Services/DeviceKeyResolver.swift | 109 +++++++++++++++++++ 2 files changed, 125 insertions(+), 96 deletions(-) create mode 100644 InputPilot/Services/DeviceKeyResolver.swift diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 5e2ec7b..0b0a9df 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -753,7 +753,7 @@ final class AppState: ObservableObject { private func registerDetectedDevice(_ device: ActiveKeyboardDevice) -> KeyboardDeviceKey { let observedDeviceKey = KeyboardDeviceKey(device: device) - let canonicalDeviceKey = canonicalDeviceKey( + let canonicalDeviceKey = DeviceKeyResolver.canonicalKey( for: observedDeviceKey, among: knownDeviceKeys ) @@ -870,18 +870,14 @@ final class AppState: ObservableObject { private func resolvedTargetInputSourceId(for deviceKey: KeyboardDeviceKey) -> String? { if let mappedInputSourceId = resolvedConfiguredInputSourceId( for: deviceKey, - sourceResolver: { [mappingStore] candidateKey in - mappingStore.getMapping(for: candidateKey) - } + sourceFor: mappingStore.getMapping(for:) ) { return mappedInputSourceId } if let perDeviceFallbackInputSourceId = resolvedConfiguredInputSourceId( for: deviceKey, - sourceResolver: { [mappingStore] candidateKey in - mappingStore.getPerDeviceFallback(for: candidateKey) - } + sourceFor: mappingStore.getPerDeviceFallback(for:) ) { return perDeviceFallbackInputSourceId } @@ -895,102 +891,26 @@ final class AppState: ObservableObject { } private func resolvedConfiguredInputSourceId( - for observedDeviceKey: KeyboardDeviceKey, - sourceResolver: (KeyboardDeviceKey) -> String? + for deviceKey: KeyboardDeviceKey, + sourceFor: (KeyboardDeviceKey) -> String? ) -> String? { - if let exactSourceId = sourceResolver(observedDeviceKey), - inputSourceService.existsEnabledInputSource(id: exactSourceId) { - return exactSourceId - } - - let primaryCandidates = mappingStore - .allKnownDeviceKeys() - .filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } - .filter { $0 != observedDeviceKey } - - guard !primaryCandidates.isEmpty else { - return nil - } - - let resolvedCandidates: [(key: KeyboardDeviceKey, sourceId: String)] = primaryCandidates.compactMap { candidateKey in - guard let sourceId = sourceResolver(candidateKey), - inputSourceService.existsEnabledInputSource(id: sourceId) else { - return nil - } - - return (key: candidateKey, sourceId: sourceId) - } - - guard !resolvedCandidates.isEmpty else { - return nil - } - - if resolvedCandidates.count == 1 { - return resolvedCandidates[0].sourceId - } - - if let observedLocationId = observedDeviceKey.locationId, - let locationMatchedCandidate = resolvedCandidates.first(where: { $0.key.locationId == observedLocationId }) { - return locationMatchedCandidate.sourceId - } - - if let locationAgnosticCandidate = resolvedCandidates.first(where: { $0.key.locationId == nil }) { - return locationAgnosticCandidate.sourceId - } - - return resolvedCandidates - .sorted(by: { lhs, rhs in - lhs.key.id.localizedCaseInsensitiveCompare(rhs.key.id) == .orderedAscending - }) - .first? - .sourceId - } - - private func canonicalDeviceKey( - for observedDeviceKey: KeyboardDeviceKey, - among existingKeys: [KeyboardDeviceKey] - ) -> KeyboardDeviceKey { - let primaryMatches = existingKeys.filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } - - if let exactMatch = primaryMatches.first(where: { $0 == observedDeviceKey }) { - return exactMatch - } - - if primaryMatches.count == 1, let existingPrimaryMatch = primaryMatches.first { - return existingPrimaryMatch - } - - if let observedLocationId = observedDeviceKey.locationId, - let locationMatchedKey = primaryMatches.first(where: { $0.locationId == observedLocationId }) { - return locationMatchedKey - } - - if let locationAgnosticKey = primaryMatches.first(where: { $0.locationId == nil }) { - return locationAgnosticKey - } - - return observedDeviceKey + DeviceKeyResolver.configuredInputSourceId( + for: deviceKey, + knownKeys: mappingStore.allKnownDeviceKeys(), + sourceFor: sourceFor, + isEnabled: inputSourceService.existsEnabledInputSource(id:) + ) } private func isCurrentActiveDeviceRepresented( by deviceKey: KeyboardDeviceKey, activeKeyboardDevice: ActiveKeyboardDevice ) -> Bool { - let activeDeviceKey = KeyboardDeviceKey(device: activeKeyboardDevice) - if activeDeviceKey == deviceKey { - return true - } - - guard activeDeviceKey.matchesPrimary(of: deviceKey.fingerprint) else { - return false - } - - let primaryMatches = knownDeviceKeys.filter { $0.matchesPrimary(of: activeDeviceKey.fingerprint) } - if primaryMatches.count <= 1 { - return true - } - - return activeDeviceKey.locationId == deviceKey.locationId + DeviceKeyResolver.isActiveDevice( + KeyboardDeviceKey(device: activeKeyboardDevice), + representedBy: deviceKey, + knownKeys: knownDeviceKeys + ) } private func refreshMappingConflicts(usingEnabledSources sources: [InputSourceInfo]) { diff --git a/InputPilot/Services/DeviceKeyResolver.swift b/InputPilot/Services/DeviceKeyResolver.swift new file mode 100644 index 0000000..30fdb9f --- /dev/null +++ b/InputPilot/Services/DeviceKeyResolver.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Rules for matching an observed keyboard against the keys InputPilot already +/// knows. Every input arrives as a parameter, so the rules carry no state. +enum DeviceKeyResolver { + /// The input source configured for a device, through `sourceFor` (a + /// mapping or a fallback). Falls back to another known key with the same + /// primary fingerprint, so a re-plugged keyboard keeps its configuration. + static func configuredInputSourceId( + for observedDeviceKey: KeyboardDeviceKey, + knownKeys: [KeyboardDeviceKey], + sourceFor: (KeyboardDeviceKey) -> String?, + isEnabled: (String) -> Bool + ) -> String? { + if let exactSourceId = sourceFor(observedDeviceKey), isEnabled(exactSourceId) { + return exactSourceId + } + + let primaryCandidates = knownKeys + .filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } + .filter { $0 != observedDeviceKey } + + guard !primaryCandidates.isEmpty else { + return nil + } + + let resolvedCandidates: [(key: KeyboardDeviceKey, sourceId: String)] = primaryCandidates.compactMap { candidateKey in + guard let sourceId = sourceFor(candidateKey), isEnabled(sourceId) else { + return nil + } + + return (key: candidateKey, sourceId: sourceId) + } + + guard !resolvedCandidates.isEmpty else { + return nil + } + + if resolvedCandidates.count == 1 { + return resolvedCandidates[0].sourceId + } + + if let observedLocationId = observedDeviceKey.locationId, + let locationMatchedCandidate = resolvedCandidates.first(where: { $0.key.locationId == observedLocationId }) { + return locationMatchedCandidate.sourceId + } + + if let locationAgnosticCandidate = resolvedCandidates.first(where: { $0.key.locationId == nil }) { + return locationAgnosticCandidate.sourceId + } + + return resolvedCandidates + .sorted(by: { lhs, rhs in + lhs.key.id.localizedCaseInsensitiveCompare(rhs.key.id) == .orderedAscending + }) + .first? + .sourceId + } + + /// The known key an observed device should be filed under, or the observed + /// key itself when it is a keyboard InputPilot has not seen before. + static func canonicalKey( + for observedDeviceKey: KeyboardDeviceKey, + among existingKeys: [KeyboardDeviceKey] + ) -> KeyboardDeviceKey { + let primaryMatches = existingKeys.filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } + + if let exactMatch = primaryMatches.first(where: { $0 == observedDeviceKey }) { + return exactMatch + } + + if primaryMatches.count == 1, let existingPrimaryMatch = primaryMatches.first { + return existingPrimaryMatch + } + + if let observedLocationId = observedDeviceKey.locationId, + let locationMatchedKey = primaryMatches.first(where: { $0.locationId == observedLocationId }) { + return locationMatchedKey + } + + if let locationAgnosticKey = primaryMatches.first(where: { $0.locationId == nil }) { + return locationAgnosticKey + } + + return observedDeviceKey + } + + /// Whether `deviceKey` stands for the keyboard currently being typed on. + static func isActiveDevice( + _ activeDeviceKey: KeyboardDeviceKey, + representedBy deviceKey: KeyboardDeviceKey, + knownKeys: [KeyboardDeviceKey] + ) -> Bool { + if activeDeviceKey == deviceKey { + return true + } + + guard activeDeviceKey.matchesPrimary(of: deviceKey.fingerprint) else { + return false + } + + let primaryMatches = knownKeys.filter { $0.matchesPrimary(of: activeDeviceKey.fingerprint) } + if primaryMatches.count <= 1 { + return true + } + + return activeDeviceKey.locationId == deviceKey.locationId + } +} From 5c4cb80fcf34a50eac6302d680bfd494178e2af9 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 02:35:59 +0200 Subject: [PATCH 02/12] fix: give two keyboards of the same model their own mappings A keyboard seen at a new location was folded into the one known key of the same model, so two identical keyboards (say a US and a German Magic Keyboard) shared one Settings row and one mapping. Fold a keyboard into a known key only if that key's recorded location no longer holds a keyboard of the same model - that is the keyboard that moved. If every recorded location is still occupied, it is another keyboard and gets its own key. This also lets one of two same-model keyboards change ports without becoming a third entry. The attached devices are only queried on this slow path, never for a known keyboard. --- InputPilot/App/AppState.swift | 3 +- InputPilot/Services/DeviceKeyResolver.swift | 25 +++-- InputPilot/Services/HIDKeyboardMonitor.swift | 8 ++ InputPilot/Services/ServiceProtocols.swift | 2 + InputPilotTests/AppStateAutoSwitchTests.swift | 73 +++++++++++++++ InputPilotTests/DeviceKeyResolverTests.swift | 92 +++++++++++++++++++ InputPilotTests/ServiceMocks.swift | 5 + 7 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 InputPilotTests/DeviceKeyResolverTests.swift diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 0b0a9df..e5052e2 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -755,7 +755,8 @@ final class AppState: ObservableObject { let observedDeviceKey = KeyboardDeviceKey(device: device) let canonicalDeviceKey = DeviceKeyResolver.canonicalKey( for: observedDeviceKey, - among: knownDeviceKeys + among: knownDeviceKeys, + connectedDevices: { [hidKeyboardMonitor] in hidKeyboardMonitor.connectedDevices() } ) detectedDevicesByKey[canonicalDeviceKey] = device diff --git a/InputPilot/Services/DeviceKeyResolver.swift b/InputPilot/Services/DeviceKeyResolver.swift index 30fdb9f..f0c1031 100644 --- a/InputPilot/Services/DeviceKeyResolver.swift +++ b/InputPilot/Services/DeviceKeyResolver.swift @@ -61,7 +61,8 @@ enum DeviceKeyResolver { /// key itself when it is a keyboard InputPilot has not seen before. static func canonicalKey( for observedDeviceKey: KeyboardDeviceKey, - among existingKeys: [KeyboardDeviceKey] + among existingKeys: [KeyboardDeviceKey], + connectedDevices: () -> [ActiveKeyboardDevice] ) -> KeyboardDeviceKey { let primaryMatches = existingKeys.filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } @@ -69,10 +70,6 @@ enum DeviceKeyResolver { return exactMatch } - if primaryMatches.count == 1, let existingPrimaryMatch = primaryMatches.first { - return existingPrimaryMatch - } - if let observedLocationId = observedDeviceKey.locationId, let locationMatchedKey = primaryMatches.first(where: { $0.locationId == observedLocationId }) { return locationMatchedKey @@ -82,7 +79,23 @@ enum DeviceKeyResolver { return locationAgnosticKey } - return observedDeviceKey + guard !primaryMatches.isEmpty else { + return observedDeviceKey + } + + // A known model shows up at a new location. If exactly one known key's + // recorded location no longer holds a keyboard of that model, that is + // the keyboard that moved. If every recorded location is still occupied, + // this is another keyboard of the same model and gets its own key. + // Only evaluated here, off the hot path of an already-known keyboard. + let connected = connectedDevices() + let vacatedKeys = primaryMatches.filter { key in + !connected.contains { device in + device.locationId == key.locationId && key.matchesPrimary(of: device.fingerprint) + } + } + + return vacatedKeys.count == 1 ? vacatedKeys[0] : observedDeviceKey } /// Whether `deviceKey` stands for the keyboard currently being typed on. diff --git a/InputPilot/Services/HIDKeyboardMonitor.swift b/InputPilot/Services/HIDKeyboardMonitor.swift index e7a6b74..b9b896e 100644 --- a/InputPilot/Services/HIDKeyboardMonitor.swift +++ b/InputPilot/Services/HIDKeyboardMonitor.swift @@ -95,6 +95,14 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring { stop() } + func connectedDevices() -> [ActiveKeyboardDevice] { + guard let manager, let devices = IOHIDManagerCopyDevices(manager) as? Set else { + return [] + } + + return devices.map(makeActiveDevice(from:)) + } + private func handleInputValue(_ value: IOHIDValue) { guard IOHIDValueGetIntegerValue(value) > 0 else { return diff --git a/InputPilot/Services/ServiceProtocols.swift b/InputPilot/Services/ServiceProtocols.swift index b1c874f..6f3bf86 100644 --- a/InputPilot/Services/ServiceProtocols.swift +++ b/InputPilot/Services/ServiceProtocols.swift @@ -12,6 +12,8 @@ protocol HIDKeyboardMonitoring: AnyObject { func start(onEvent: @escaping (ActiveKeyboardDevice, KeyboardEventKind) -> Void) -> Bool func stop() var onDeviceRemoved: ((ActiveKeyboardDevice) -> Void)? { get set } + /// Keyboards currently attached, as the running monitor sees them. + func connectedDevices() -> [ActiveKeyboardDevice] var isRunning: Bool { get } var lastStartErrorMessage: String? { get } } diff --git a/InputPilotTests/AppStateAutoSwitchTests.swift b/InputPilotTests/AppStateAutoSwitchTests.swift index 4d3b602..5619090 100644 --- a/InputPilotTests/AppStateAutoSwitchTests.swift +++ b/InputPilotTests/AppStateAutoSwitchTests.swift @@ -535,6 +535,79 @@ struct AppStateAutoSwitchTests { #expect(appState.activeKeyboardDevice == secondDevice) } + @Test + func twoKeyboardsOfTheSameModelAreMappedSeparately() async throws { + let suiteName = "AppStateAutoSwitchTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw AppStateTestError.failedToCreateUserDefaultsSuite + } + + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let usKeyboard = ActiveKeyboardDevice( + vendorId: 1452, + productId: 641, + productName: "Magic Keyboard", + transport: "USB", + locationId: 100 + ) + let germanKeyboard = ActiveKeyboardDevice( + vendorId: 1452, + productId: 641, + productName: "Magic Keyboard", + transport: "USB", + locationId: 200 + ) + + let keyboardMonitor = MockHIDKeyboardMonitor() + keyboardMonitor.connected = [usKeyboard, germanKeyboard] + let inputSourceService = MockInputSourceService() + inputSourceService.enabledSources = [ + InputSourceInfo(id: "com.apple.keylayout.US", name: "U.S.", isSelectable: true), + InputSourceInfo(id: "com.apple.keylayout.German", name: "German", isSelectable: true) + ] + inputSourceService.currentInputSourceIdValue = "com.apple.keylayout.German" + + let clock = ImmediateClock() + let appState = AppState( + permissionService: MockPermissionService(accessType: kIOHIDAccessTypeGranted), + hidKeyboardMonitor: keyboardMonitor, + inputSourceService: inputSourceService, + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: clock + ) + + // Detected the way a user would: a key press on each keyboard. + keyboardMonitor.emit(usKeyboard) + await waitFor { appState.knownDeviceKeys.count == 1 } + keyboardMonitor.emit(germanKeyboard) + await waitFor { appState.knownDeviceKeys.count == 2 } + #expect(appState.knownDeviceKeys.count == 2) + + // Until it has its own mapping, the second keyboard inherits the first's. + appState.setMapping(for: KeyboardDeviceKey(device: usKeyboard), inputSourceId: "com.apple.keylayout.US") + keyboardMonitor.emit(germanKeyboard) + await waitFor { inputSourceService.selectCalls.count == 1 } + #expect(inputSourceService.selectCalls == ["com.apple.keylayout.US"]) + + clock.advance(by: .seconds(2)) + appState.setMapping(for: KeyboardDeviceKey(device: germanKeyboard), inputSourceId: "com.apple.keylayout.German") + await waitFor { inputSourceService.selectCalls.count == 2 } + + clock.advance(by: .seconds(2)) + keyboardMonitor.emit(usKeyboard) + await waitFor { inputSourceService.selectCalls.count == 3 } + + #expect(inputSourceService.selectCalls == [ + "com.apple.keylayout.US", + "com.apple.keylayout.German", + "com.apple.keylayout.US" + ]) + } + private func waitFor( maxAttempts: Int = 300, condition: @escaping () -> Bool diff --git a/InputPilotTests/DeviceKeyResolverTests.swift b/InputPilotTests/DeviceKeyResolverTests.swift new file mode 100644 index 0000000..7902157 --- /dev/null +++ b/InputPilotTests/DeviceKeyResolverTests.swift @@ -0,0 +1,92 @@ +import Testing +@testable import InputPilot + +struct DeviceKeyResolverTests { + @Test + func secondKeyboardOfSameModelGetsItsOwnKey() { + let first = magicKeyboard(at: 100) + let second = magicKeyboard(at: 200) + + let key = DeviceKeyResolver.canonicalKey( + for: KeyboardDeviceKey(device: second), + among: [KeyboardDeviceKey(device: first)], + connectedDevices: { [first, second] } + ) + + #expect(key == KeyboardDeviceKey(device: second)) + } + + @Test + func keyboardMovedToAnotherPortKeepsItsKey() { + let known = KeyboardDeviceKey(device: magicKeyboard(at: 100)) + let moved = magicKeyboard(at: 300) + + let key = DeviceKeyResolver.canonicalKey( + for: KeyboardDeviceKey(device: moved), + among: [known], + connectedDevices: { [moved] } + ) + + #expect(key == known) + } + + @Test + func keyboardMovedToAnotherPortKeepsItsKeyWhenADifferentModelTakesTheOldPort() { + let known = KeyboardDeviceKey(device: magicKeyboard(at: 100)) + let moved = magicKeyboard(at: 300) + let otherModel = ActiveKeyboardDevice( + vendorId: 1133, + productId: 50475, + productName: "MX Keys", + transport: "USB", + locationId: 100 + ) + + let key = DeviceKeyResolver.canonicalKey( + for: KeyboardDeviceKey(device: moved), + among: [known], + connectedDevices: { [moved, otherModel] } + ) + + #expect(key == known) + } + + @Test + func knownKeyWithoutLocationAlwaysAbsorbsTheSameModel() { + let known = KeyboardDeviceKey(device: magicKeyboard(at: nil)) + let observed = magicKeyboard(at: 200) + + let key = DeviceKeyResolver.canonicalKey( + for: KeyboardDeviceKey(device: observed), + among: [known], + connectedDevices: { [observed] } + ) + + #expect(key == known) + } + + @Test + func oneOfTwoSameModelKeyboardsMovedFoldsIntoTheVacatedKey() { + let stayed = KeyboardDeviceKey(device: magicKeyboard(at: 100)) + let vacated = KeyboardDeviceKey(device: magicKeyboard(at: 200)) + let moved = magicKeyboard(at: 300) + + let key = DeviceKeyResolver.canonicalKey( + for: KeyboardDeviceKey(device: moved), + among: [stayed, vacated], + connectedDevices: { [magicKeyboard(at: 100), moved] } + ) + + #expect(key == vacated) + } + + private func magicKeyboard(at locationId: Int?) -> ActiveKeyboardDevice { + ActiveKeyboardDevice( + vendorId: 1452, + productId: 641, + productName: "Magic Keyboard", + transport: "USB", + locationId: locationId + ) + } +} diff --git a/InputPilotTests/ServiceMocks.swift b/InputPilotTests/ServiceMocks.swift index 297ff01..8434e0a 100644 --- a/InputPilotTests/ServiceMocks.swift +++ b/InputPilotTests/ServiceMocks.swift @@ -29,6 +29,7 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { private var onEvent: ((ActiveKeyboardDevice, KeyboardEventKind) -> Void)? var onDeviceRemoved: ((ActiveKeyboardDevice) -> Void)? + var connected: [ActiveKeyboardDevice] = [] var startResult = true var isRunning = false var lastStartErrorMessage: String? @@ -51,6 +52,10 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { isRunning = false } + func connectedDevices() -> [ActiveKeyboardDevice] { + connected + } + func emit(_ device: ActiveKeyboardDevice, kind: KeyboardEventKind = .keyDown(isModifier: false)) { onEvent?(device, kind) } From 32ee3d2ce224f397918e8f39c3d297f5e6dafdb7 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:07:42 +0200 Subject: [PATCH 03/12] feat: respect manual input-source changes, with a configurable policy Every key press re-applied the keyboard's mapping, so a layout picked by hand (Ctrl-Space, the menu bar, Undo, an input method's mode toggle) was reverted on the next key - a daily fight for multilingual users. InputPilot now remembers the source it last applied. If the current one differs when it is about to switch, someone else chose it, and a new setting decides what happens: - keep it until I switch keyboards (default) - keep it until I switch back myself - switch back on the next key press (the old behaviour) - turn off automatic switching, with the reason shown Details that make the detection reliable: - a switch that did not take effect (some CJK input methods) is retried, never mistaken for a manual choice; - moving to another keyboard re-arms from the current source, so a Ctrl-Space pressed during the 400 ms debounce is still respected; - only keys that may switch count as changing keyboards, so a lone Cmd on the other keyboard does not end a hold; - settings changes end any hold and apply at once, through one helper that uses the canonical device key instead of five raw-key copies. --- InputPilot/App/AppState.swift | 137 +++++++- InputPilot/Models/ManualChangePolicy.swift | 30 ++ .../AppStateFailurePathTests.swift | 4 + .../AppStateManualChangeTests.swift | 302 ++++++++++++++++++ InputPilotTests/ServiceMocks.swift | 33 +- 5 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 InputPilot/Models/ManualChangePolicy.swift create mode 100644 InputPilotTests/AppStateManualChangeTests.swift diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index e5052e2..482d61e 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -13,6 +13,7 @@ final class AppState: ObservableObject { private static let switchOnModifierOnlyKeysDefaultsKey = "switchOnModifierOnlyKeys" private static let pauseUntilDefaultsKey = "autoSwitchPauseUntil" private static let globalFallbackInputSourceIdDefaultsKey = "globalFallbackInputSourceId" + private static let manualChangePolicyDefaultsKey = "manualChangePolicy" private static let inputSourcePollIntervalSeconds: TimeInterval = 6 @Published private(set) var status = InputStatusSnapshot.placeholder @@ -28,6 +29,11 @@ final class AppState: ObservableObject { @Published private(set) var autoSwitchEnabled: Bool @Published private(set) var switchOnModifierOnlyKeys: Bool @Published private(set) var pauseUntil: Date? + @Published private(set) var manualChangePolicy: ManualChangePolicy + /// Set while a manual input-source change is being respected, for the menu. + @Published private(set) var manualHoldDescription: String? + /// Why automatic switching is off, when InputPilot turned it off itself. + @Published private(set) var autoSwitchOffReason: String? @Published private(set) var lastAction: SwitchAction? @Published private(set) var previousInputSourceIdBeforeLastSwitch: String? @Published private(set) var debugLogEntries: [LogEntry] = [] @@ -49,6 +55,12 @@ final class AppState: ObservableObject { private let permissionPollInterval: Duration private var lastMonitorStartFailure: String? private var lastInputSourceRefreshAt = Date.distantPast + /// The source InputPilot itself last applied. A different current source + /// means someone else chose it. Nil: nothing to compare against, so the + /// next switch goes ahead. + private var lastAppliedSourceId: String? + private var activeDeviceKey: KeyboardDeviceKey? + private var lastSwitchingDeviceKey: KeyboardDeviceKey? #if DEBUG private let logger = Logger(subsystem: "com.lucagerlich.InputPilot", category: "AppState") @@ -82,6 +94,8 @@ final class AppState: ObservableObject { default: false ) self.pauseUntil = appSettingsStore.date(forKey: Self.pauseUntilDefaultsKey) + self.manualChangePolicy = appSettingsStore.string(forKey: Self.manualChangePolicyDefaultsKey) + .flatMap(ManualChangePolicy.init(rawValue:)) ?? .default self.globalFallbackInputSourceId = Self.normalizedInputSourceId( appSettingsStore.string(forKey: Self.globalFallbackInputSourceIdDefaultsKey) ) @@ -290,10 +304,11 @@ final class AppState: ObservableObject { return } + autoSwitchOffReason = nil recordAction("Auto-switch enabled.") - if isAutoSwitchActive, let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) + if isAutoSwitchActive { + reevaluateActiveDevice() } } @@ -308,11 +323,22 @@ final class AppState: ObservableObject { ? "Modifier-only keys now trigger switching." : "Modifier-only keys no longer trigger switching.") - if enabled, isAutoSwitchActive, let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) + if enabled, isAutoSwitchActive { + reevaluateActiveDevice() } } + func setManualChangePolicy(_ policy: ManualChangePolicy) { + guard manualChangePolicy != policy else { + return + } + + manualChangePolicy = policy + appSettingsStore.set(policy.rawValue, forKey: Self.manualChangePolicyDefaultsKey) + recordAction("When you change the input source yourself: \(policy.title.lowercased()).") + reevaluateActiveDevice() + } + func pause(minutes: Int) { guard minutes > 0 else { return @@ -334,8 +360,8 @@ final class AppState: ObservableObject { appSettingsStore.set(Optional.none, forKey: Self.pauseUntilDefaultsKey) recordAction("Auto-switch resumed.") - if isAutoSwitchActive, let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) + if isAutoSwitchActive { + reevaluateActiveDevice() } } @@ -401,9 +427,7 @@ final class AppState: ObservableObject { recordAction("Global fallback cleared.") } - if let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) - } + reevaluateActiveDevice() } func useCurrentInputSourceAsGlobalFallback() { @@ -436,7 +460,7 @@ final class AppState: ObservableObject { if let activeKeyboardDevice, isCurrentActiveDeviceRepresented(by: deviceKey, activeKeyboardDevice: activeKeyboardDevice) { - evaluateSwitch(for: deviceKey, eventKind: .deviceStabilized) + reevaluateActiveDevice() } } @@ -458,7 +482,7 @@ final class AppState: ObservableObject { if let activeKeyboardDevice, isCurrentActiveDeviceRepresented(by: deviceKey, activeKeyboardDevice: activeKeyboardDevice) { - evaluateSwitch(for: deviceKey, eventKind: .deviceStabilized) + reevaluateActiveDevice() } } @@ -701,6 +725,7 @@ final class AppState: ObservableObject { private func clearActiveKeyboardDevice() { switchController.reset() + activeDeviceKey = nil if activeKeyboardDevice != nil { activeKeyboardDevice = nil } @@ -724,6 +749,7 @@ final class AppState: ObservableObject { private func handleDetectedKeyboard(_ detectedDevice: ActiveKeyboardDevice, eventKind: KeyboardEventKind) { let deviceKey = registerDetectedDevice(detectedDevice) + activeDeviceKey = deviceKey let hasChanged = activeKeyboardDevice != detectedDevice if hasChanged { @@ -748,6 +774,18 @@ final class AppState: ObservableObject { return } + // Counted only past the modifier gate, so a lone ⌘ on the other + // keyboard does not count as switching keyboards. + if deviceKey != lastSwitchingDeviceKey { + if lastSwitchingDeviceKey != nil, manualChangePolicy == .keepUntilKeyboardChange { + // Re-arm from what is selected now rather than from nothing, so + // a ⌃Space pressed while this keyboard's switch is still pending + // counts as a manual change instead of being reverted. + endManualHold(appliedSourceId: inputSourceService.currentInputSourceId()) + } + lastSwitchingDeviceKey = deviceKey + } + evaluateSwitch(for: deviceKey, eventKind: eventKind) } @@ -770,12 +808,19 @@ final class AppState: ObservableObject { private func evaluateSwitch(for deviceKey: KeyboardDeviceKey, eventKind: KeyboardEventKind) { let targetInputSourceId = resolvedTargetInputSourceId(for: deviceKey) + // Read fresh: the cached value goes stale when the user changes the + // source outside the app (menu bar, shortcuts) between polls. + let currentSourceId = inputSourceService.currentInputSourceId() + + // Already on this keyboard's source, whoever selected it: nothing is + // being held against the user any more. + if let targetInputSourceId, currentSourceId == targetInputSourceId { + endManualHold(appliedSourceId: currentSourceId) + } switchController.evaluateSwitch( device: deviceKey, - // Read fresh: the cached value goes stale when the user changes the - // source outside the app (menu bar, shortcuts) between polls. - currentSource: inputSourceService.currentInputSourceId(), + currentSource: currentSourceId, mapping: targetInputSourceId, isAutoSwitchActive: isAutoSwitchActive, eventKind: eventKind @@ -818,6 +863,11 @@ final class AppState: ObservableObject { return false } + if let lastAppliedSourceId, freshCurrentInputSourceId != lastAppliedSourceId, + !shouldOverrideManualChange(to: freshCurrentInputSourceId) { + return false + } + let previousInputSourceId = freshCurrentInputSourceId let targetSourceName = inputSourceDisplayName(for: targetInputSourceId) let deviceName = deviceTitle(for: deviceKey) @@ -829,6 +879,10 @@ final class AppState: ObservableObject { } refreshInputSourceState() + // Only a switch that took effect becomes the baseline. One that did not + // (seen with some CJK input methods) must be retried on the next key, + // not mistaken for the user picking the old source. + endManualHold(appliedSourceId: currentInputSourceId == targetInputSourceId ? targetInputSourceId : nil) previousInputSourceIdBeforeLastSwitch = previousInputSourceId lastAction = SwitchAction( timestamp: clock.now, @@ -842,6 +896,57 @@ final class AppState: ObservableObject { return true } + /// The input source changed since InputPilot last set it, so someone else + /// chose it. Applies the user's policy; returns whether to switch anyway. + private func shouldOverrideManualChange(to manualSourceId: String?) -> Bool { + let sourceName = manualSourceId.map(inputSourceDisplayName(for:)) ?? "your choice" + + switch manualChangePolicy { + case .revertOnNextKeyPress: + return true + case .keepUntilKeyboardChange: + beginManualHold("Keeping \(sourceName) until you switch keyboards.") + return false + case .keepUntilSwitchBack: + beginManualHold("Keeping \(sourceName) until you switch back yourself.") + return false + case .turnOffAutoSwitch: + setAutoSwitchEnabled(false) + autoSwitchOffReason = "Turned off because you changed the input source to \(sourceName) yourself." + logInfo(category: "manual-change", message: "Auto-switch turned off after a manual change to \(sourceName).") + return false + } + } + + /// Logs once per hold: every key press during a hold lands here again. + private func beginManualHold(_ description: String) { + guard manualHoldDescription != description else { + return + } + + manualHoldDescription = description + recordAction(description) + logInfo(category: "manual-change", message: description) + } + + private func endManualHold(appliedSourceId: String?) { + lastAppliedSourceId = appliedSourceId + if manualHoldDescription != nil { + manualHoldDescription = nil + } + } + + /// Settings changes are deliberate: they end any manual hold and apply to + /// the keyboard in use straight away. + private func reevaluateActiveDevice() { + guard let activeDeviceKey else { + return + } + + endManualHold(appliedSourceId: nil) + evaluateSwitch(for: activeDeviceKey, eventKind: .deviceStabilized) + } + private func sortKnownDevices() { knownDeviceKeys.sort { lhs, rhs in let leftName = deviceTitle(for: lhs) @@ -1006,8 +1111,8 @@ final class AppState: ObservableObject { appSettingsStore.set(Optional.none, forKey: Self.pauseUntilDefaultsKey) recordAction("Pause expired. Auto-switch active.") - if isAutoSwitchActive, let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) + if isAutoSwitchActive { + reevaluateActiveDevice() } } diff --git a/InputPilot/Models/ManualChangePolicy.swift b/InputPilot/Models/ManualChangePolicy.swift new file mode 100644 index 0000000..0bc5054 --- /dev/null +++ b/InputPilot/Models/ManualChangePolicy.swift @@ -0,0 +1,30 @@ +import Foundation + +/// What InputPilot does when the input source is changed by something other +/// than itself - the user pressing ⌃Space, picking a layout from the menu bar, +/// Undo, or an input method switching modes. +enum ManualChangePolicy: String, CaseIterable, Identifiable { + case keepUntilKeyboardChange + case keepUntilSwitchBack + case revertOnNextKeyPress + case turnOffAutoSwitch + + static let `default` = ManualChangePolicy.keepUntilKeyboardChange + + var id: String { + rawValue + } + + var title: String { + switch self { + case .keepUntilKeyboardChange: + return "Keep it until I switch keyboards" + case .keepUntilSwitchBack: + return "Keep it until I switch back myself" + case .revertOnNextKeyPress: + return "Switch back on the next key press" + case .turnOffAutoSwitch: + return "Turn off automatic switching" + } + } +} diff --git a/InputPilotTests/AppStateFailurePathTests.swift b/InputPilotTests/AppStateFailurePathTests.swift index 13acd51..e3ddfb0 100644 --- a/InputPilotTests/AppStateFailurePathTests.swift +++ b/InputPilotTests/AppStateFailurePathTests.swift @@ -335,6 +335,10 @@ struct AppStateFailurePathTests { appSettingsStore: AppSettingsStore(defaults: defaults), clock: clock ) + // Under the default policy a change made outside the app is respected. + // This test guards the fresh read itself (a stale cache once suppressed + // needed switches), so it uses the policy that always switches back. + appState.setManualChangePolicy(.revertOnNextKeyPress) keyboardMonitor.emit(device) await waitFor { diff --git a/InputPilotTests/AppStateManualChangeTests.swift b/InputPilotTests/AppStateManualChangeTests.swift new file mode 100644 index 0000000..0a59cc5 --- /dev/null +++ b/InputPilotTests/AppStateManualChangeTests.swift @@ -0,0 +1,302 @@ +import Foundation +import IOKit.hid +import Testing +@testable import InputPilot + +/// What happens when the input source changes outside InputPilot - ⌃Space, +/// the menu bar, Undo, an input method switching modes. +@MainActor +struct AppStateManualChangeTests { + private let us = "com.apple.keylayout.US" + private let german = "com.apple.keylayout.German" + private let french = "com.apple.keylayout.French" + + private let macBook = ActiveKeyboardDevice( + vendorId: 1452, + productId: 832, + productName: "Apple Keyboard", + transport: "USB", + locationId: 100 + ) + private let keychron = ActiveKeyboardDevice( + vendorId: 13364, + productId: 630, + productName: "Keychron K2", + transport: "Bluetooth", + locationId: 200 + ) + + @Test + func defaultPolicyKeepsAManualChangeUntilTheKeyboardChanges() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + #expect(harness.appState.manualChangePolicy == .keepUntilKeyboardChange) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + // The user picks French on the same keyboard, e.g. with ⌃Space. + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + #expect(harness.sources.selectCalls == [us]) + + // Typing on the other keyboard ends the hold, and so does coming back. + harness.monitor.emit(keychron) + await waitFor { harness.sources.selectCalls.count == 2 } + clock.advance(by: .seconds(2)) + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls.count == 3 } + + #expect(harness.sources.selectCalls == [us, german, us]) + #expect(harness.appState.manualHoldDescription == nil) + } + + @Test + func keepUntilSwitchBackHoldsAcrossKeyboardsUntilTheMappedSourceReturns() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock, policy: .keepUntilSwitchBack) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + + // Another keyboard does not end this hold. + harness.monitor.emit(keychron) + await waitFor { harness.appState.activeKeyboardDevice == keychron } + await settle() + #expect(harness.sources.selectCalls == [us]) + + // Switching to that keyboard's own layout by hand re-arms switching. + harness.sources.currentInputSourceIdValue = german + harness.monitor.emit(keychron) + await settle() + #expect(harness.appState.manualHoldDescription == nil) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls.count == 2 } + #expect(harness.sources.selectCalls == [us, us]) + } + + @Test + func revertPolicySwitchesBackOnTheNextKeyPress() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock, policy: .revertOnNextKeyPress) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls.count == 2 } + + #expect(harness.sources.selectCalls == [us, us]) + #expect(harness.appState.manualHoldDescription == nil) + } + + @Test + func turnOffPolicyDisablesAutoSwitchAndSaysWhy() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock, policy: .turnOffAutoSwitch) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { !harness.appState.autoSwitchEnabled } + + #expect(harness.sources.selectCalls == [us]) + #expect(harness.appState.autoSwitchOffReason != nil) + + harness.appState.setAutoSwitchEnabled(true) + #expect(harness.appState.autoSwitchOffReason == nil) + } + + @Test + func undoSticksUnderTheDefaultPolicy() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + harness.appState.undoLastSwitch() + #expect(harness.sources.selectCalls == [us, german]) + + clock.advance(by: .seconds(2)) + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + #expect(harness.sources.selectCalls == [us, german]) + } + + @Test + func changingAMappingEndsTheHoldAndAppliesAtOnce() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + + harness.appState.setMapping(for: KeyboardDeviceKey(device: macBook), inputSourceId: us) + await waitFor { harness.sources.selectCalls.count == 2 } + + #expect(harness.sources.selectCalls == [us, us]) + #expect(harness.appState.manualHoldDescription == nil) + } + + @Test + func policyPersistsAcrossLaunches() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + makeHarness(defaults: defaults, clock: ImmediateClock()) + .appState.setManualChangePolicy(.revertOnNextKeyPress) + let relaunched = makeHarness(defaults: defaults, clock: ImmediateClock()) + + #expect(relaunched.appState.manualChangePolicy == .revertOnNextKeyPress) + } + + @Test + func manualChangeInsideTheDebounceWindowAfterSwitchingKeyboardsIsKept() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = GatedClock() + let harness = makeHarness(defaults: defaults, clock: clock) + harness.sources.currentInputSourceIdValue = us + + harness.monitor.emit(keychron) + await waitFor { clock.hasSleepers } + clock.releaseSleepers() + await waitFor { harness.sources.selectCalls == [german] } + + // Move to the MacBook; its switch to U.S. is now waiting out the debounce... + clock.advance(by: 2) + harness.monitor.emit(macBook) + await waitFor { clock.hasSleepers } + + // ...and the user presses ⌃Space for French before it fires. + harness.sources.currentInputSourceIdValue = french + clock.releaseSleepers() + await waitFor { harness.appState.manualHoldDescription != nil } + + #expect(harness.sources.selectCalls == [german]) + } + + @Test + func aSwitchThatDoesNotTakeIsRetriedRatherThanTreatedAsManual() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + harness.sources.selectTakesEffect = false + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + + clock.advance(by: .seconds(2)) + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls.count == 2 } + + #expect(harness.sources.selectCalls == [us, us]) + #expect(harness.appState.manualHoldDescription == nil) + } + + // MARK: - Helpers + + private struct Harness { + let appState: AppState + let monitor: MockHIDKeyboardMonitor + let sources: MockInputSourceService + } + + private func makeHarness( + defaults: UserDefaults, + clock: ClockProviding, + policy: ManualChangePolicy? = nil + ) -> Harness { + let monitor = MockHIDKeyboardMonitor() + let sources = MockInputSourceService() + sources.enabledSources = [ + InputSourceInfo(id: us, name: "U.S.", isSelectable: true), + InputSourceInfo(id: german, name: "German", isSelectable: true), + InputSourceInfo(id: french, name: "French", isSelectable: true) + ] + sources.currentInputSourceIdValue = german + + let mappingStore = MockMappingStore() + mappingStore.setMapping(deviceKey: KeyboardDeviceKey(device: macBook), inputSourceId: us) + mappingStore.setMapping(deviceKey: KeyboardDeviceKey(device: keychron), inputSourceId: german) + + let appState = AppState( + permissionService: MockPermissionService(accessType: kIOHIDAccessTypeGranted), + hidKeyboardMonitor: monitor, + inputSourceService: sources, + mappingStore: mappingStore, + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: clock + ) + if let policy { + appState.setManualChangePolicy(policy) + } + + return Harness(appState: appState, monitor: monitor, sources: sources) + } + + private func makeDefaults() throws -> (UserDefaults, String) { + let suiteName = "AppStateManualChangeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw ManualChangeTestError.failedToCreateUserDefaultsSuite + } + + return (defaults, suiteName) + } + + private func waitFor( + maxAttempts: Int = 300, + condition: @escaping () -> Bool + ) async { + for _ in 0.. [InputSourceInfo] { @@ -96,7 +99,7 @@ final class MockInputSourceService: InputSourceServicing { func selectInputSource(id: String) -> Bool { selectCalls.append(id) - if selectInputSourceResult { + if selectInputSourceResult, selectTakesEffect { currentInputSourceIdValue = id } @@ -186,3 +189,31 @@ final class ImmediateClock: ClockProviding { return seconds + attoseconds } } + +/// Holds every `sleep` until the test releases it, so a test can act inside +/// the debounce window - between a switch being scheduled and carried out. +@MainActor +final class GatedClock: ClockProviding { + var now = Date() + private var sleepers: [CheckedContinuation] = [] + + var hasSleepers: Bool { + !sleepers.isEmpty + } + + func sleep(for duration: Duration) async throws { + await withCheckedContinuation { continuation in + sleepers.append(continuation) + } + } + + func releaseSleepers() { + let pending = sleepers + sleepers = [] + pending.forEach { $0.resume() } + } + + func advance(by seconds: TimeInterval) { + now = now.addingTimeInterval(seconds) + } +} From cc6b4efbd21b553dffb635d85e24d2ef27a0a0cd Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:26:48 +0200 Subject: [PATCH 04/12] feat: add an Open at login controller A menu bar utility that is not a login item silently stops working after every restart. LoginItemController wraps SMAppService.mainApp (macOS 13+) behind a small protocol, reports enabled / disabled / requires-approval, and surfaces registration errors instead of swallowing them. The UI uses it in the welcome window and in Settings. --- InputPilot/Services/LoginItemController.swift | 95 +++++++++++++++++++ .../LoginItemControllerTests.swift | 83 ++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 InputPilot/Services/LoginItemController.swift create mode 100644 InputPilotTests/LoginItemControllerTests.swift diff --git a/InputPilot/Services/LoginItemController.swift b/InputPilot/Services/LoginItemController.swift new file mode 100644 index 0000000..44e522c --- /dev/null +++ b/InputPilot/Services/LoginItemController.swift @@ -0,0 +1,95 @@ +import Combine +import Foundation +import ServiceManagement + +enum LoginItemStatus: Equatable { + case enabled + case disabled + case requiresApproval +} + +protocol LoginItemServicing: AnyObject { + var status: LoginItemStatus { get } + func register() throws + func unregister() throws + func openSystemSettingsLoginItems() +} + +/// The app itself as a login item, through ServiceManagement (macOS 13+). +final class MainAppLoginItemService: LoginItemServicing { + var status: LoginItemStatus { + switch SMAppService.mainApp.status { + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notRegistered, .notFound: + return .disabled + @unknown default: + return .disabled + } + } + + func register() throws { + try SMAppService.mainApp.register() + } + + func unregister() throws { + try SMAppService.mainApp.unregister() + } + + func openSystemSettingsLoginItems() { + SMAppService.openSystemSettingsLoginItems() + } +} + +/// "Open at login" for the welcome window and Settings. Without it a menu bar +/// utility silently stops working after every restart. +@MainActor +final class LoginItemController: ObservableObject { + @Published private(set) var status: LoginItemStatus + @Published private(set) var lastError: String? + private let service: LoginItemServicing + + init(service: LoginItemServicing = MainAppLoginItemService()) { + self.service = service + self.status = service.status + } + + var isEnabled: Bool { + status != .disabled + } + + func setEnabled(_ enabled: Bool) { + refresh() + guard enabled != isEnabled else { + return + } + + do { + if enabled { + try service.register() + } else { + try service.unregister() + } + lastError = nil + } catch { + lastError = enabled + ? "Could not add InputPilot to your login items (\(error.localizedDescription)). You can add it in System Settings → General → Login Items." + : "Could not remove InputPilot from your login items (\(error.localizedDescription))." + } + + refresh() + } + + func refresh() { + let current = service.status + if status != current { + status = current + } + } + + func openSystemSettingsLoginItems() { + service.openSystemSettingsLoginItems() + } +} diff --git a/InputPilotTests/LoginItemControllerTests.swift b/InputPilotTests/LoginItemControllerTests.swift new file mode 100644 index 0000000..4ebd03f --- /dev/null +++ b/InputPilotTests/LoginItemControllerTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import InputPilot + +@MainActor +struct LoginItemControllerTests { + @Test + func enablingRegistersAndReportsEnabled() { + let service = MockLoginItemService() + let controller = LoginItemController(service: service) + #expect(controller.status == .disabled) + + controller.setEnabled(true) + + #expect(service.registerCount == 1) + #expect(controller.status == .enabled) + #expect(controller.lastError == nil) + } + + @Test + func aFailedRegistrationIsReportedNotSwallowed() { + let service = MockLoginItemService() + service.registerError = LoginItemTestError.denied + let controller = LoginItemController(service: service) + + controller.setEnabled(true) + + #expect(controller.status == .disabled) + #expect(controller.lastError != nil) + } + + @Test + func pendingApprovalIsSurfaced() { + let service = MockLoginItemService() + service.statusAfterRegister = .requiresApproval + let controller = LoginItemController(service: service) + + controller.setEnabled(true) + + #expect(controller.status == .requiresApproval) + } + + @Test + func enablingWhenAlreadyEnabledDoesNotRegisterAgain() { + let service = MockLoginItemService() + service.status = .enabled + let controller = LoginItemController(service: service) + + controller.setEnabled(true) + controller.setEnabled(false) + + #expect(service.registerCount == 0) + #expect(service.unregisterCount == 1) + #expect(controller.status == .disabled) + } +} + +private final class MockLoginItemService: LoginItemServicing { + var status: LoginItemStatus = .disabled + var statusAfterRegister: LoginItemStatus = .enabled + var registerError: Error? + private(set) var registerCount = 0 + private(set) var unregisterCount = 0 + + func register() throws { + if let registerError { + throw registerError + } + registerCount += 1 + status = statusAfterRegister + } + + func unregister() throws { + unregisterCount += 1 + status = .disabled + } + + func openSystemSettingsLoginItems() {} +} + +private enum LoginItemTestError: Error { + case denied +} From 28e722dabba2df5790cf1a0b36e563a73f69ece2 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:26:48 +0200 Subject: [PATCH 05/12] fix: finish onboarding after the relaunch, and survive Karabiner-Elements Onboarding: - The welcome window only appeared while permission was missing. After the grant-and-relaunch macOS asks for, a new user got no window, no mappings and no hint. Onboarding now lasts until its last step is done (stored as onboardingCompleted) and returns whenever permission is missing or a relaunch is still needed. - relaunch() starts a fresh copy once this process has exited, for a Relaunch button instead of a bare Quit. The bundle path and PID are passed as arguments, never spliced into the shell script. - Settings booleans also read "YES"/"NO" strings, so UI tests can start onboarding with -onboardingCompleted NO. Karabiner-Elements: - Any keyboard held exclusively by another app made IOHIDManagerOpen fail and discarded the whole monitor, with advice to relaunch that could never help. On kIOReturnExclusiveAccess the monitor now keeps running for the other keyboards and explains how to release the held one. - Only when permission was granted at launch: the open error comes from an unordered set of devices, so right after a fresh grant it could hide the permission failure that a relaunch fixes. --- InputPilot/App/AppState.swift | 60 +++++++- InputPilot/Persistence/AppSettingsStore.swift | 4 +- InputPilot/Services/HIDKeyboardMonitor.swift | 17 ++- InputPilot/Services/ServiceProtocols.swift | 1 + InputPilotTests/AppStateOnboardingTests.swift | 137 ++++++++++++++++++ InputPilotTests/ServiceMocks.swift | 12 ++ 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 InputPilotTests/AppStateOnboardingTests.swift diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 482d61e..46d42d8 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -14,6 +14,7 @@ final class AppState: ObservableObject { private static let pauseUntilDefaultsKey = "autoSwitchPauseUntil" private static let globalFallbackInputSourceIdDefaultsKey = "globalFallbackInputSourceId" private static let manualChangePolicyDefaultsKey = "manualChangePolicy" + private static let onboardingCompletedDefaultsKey = "onboardingCompleted" private static let inputSourcePollIntervalSeconds: TimeInterval = 6 @Published private(set) var status = InputStatusSnapshot.placeholder @@ -34,6 +35,7 @@ final class AppState: ObservableObject { @Published private(set) var manualHoldDescription: String? /// Why automatic switching is off, when InputPilot turned it off itself. @Published private(set) var autoSwitchOffReason: String? + @Published private(set) var hasCompletedOnboarding: Bool @Published private(set) var lastAction: SwitchAction? @Published private(set) var previousInputSourceIdBeforeLastSwitch: String? @Published private(set) var debugLogEntries: [LogEntry] = [] @@ -96,11 +98,16 @@ final class AppState: ObservableObject { self.pauseUntil = appSettingsStore.date(forKey: Self.pauseUntilDefaultsKey) self.manualChangePolicy = appSettingsStore.string(forKey: Self.manualChangePolicyDefaultsKey) .flatMap(ManualChangePolicy.init(rawValue:)) ?? .default + self.hasCompletedOnboarding = appSettingsStore.bool(forKey: Self.onboardingCompletedDefaultsKey) self.globalFallbackInputSourceId = Self.normalizedInputSourceId( appSettingsStore.string(forKey: Self.globalFallbackInputSourceIdDefaultsKey) ) self.debugLogEntries = debugLogService.entries + // Decided once per process: a grant made while running only applies + // after a relaunch, so only a launch-time grant makes a partial open safe. + hidKeyboardMonitor.allowsPartialOpen = permissionService.isGranted + seedKnownDevicesFromMappings() refreshPauseStateIfNeeded() refreshPermissionStatus() @@ -213,6 +220,49 @@ final class AppState: ObservableObject { isInputMonitoringGranted && !hidKeyboardMonitor.isRunning } + /// The welcome window stays until permission works and the user has seen + /// the last step, so a relaunch after the grant does not end in silence. + var needsOnboarding: Bool { + needsPermissionOnboarding || needsRelaunchAfterGrant || !hasCompletedOnboarding + } + + /// Running, but without the keyboards another app holds exclusively. + var monitorHasPartialAccess: Bool { + hidKeyboardMonitor.isRunning && hidKeyboardMonitor.lastStartErrorMessage != nil + } + + func completeOnboarding() { + guard !hasCompletedOnboarding else { + return + } + + hasCompletedOnboarding = true + appSettingsStore.set(true, forKey: Self.onboardingCompletedDefaultsKey) + } + + /// Starts a fresh copy once this process has exited, then quits: macOS + /// applies a new Input Monitoring grant only to a new process. + func relaunch() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + // Paths and PIDs are passed as arguments, never spliced into the script. + process.arguments = [ + "-c", + "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.1; done; /usr/bin/open \"$0\"", + Bundle.main.bundlePath, + String(ProcessInfo.processInfo.processIdentifier) + ] + + do { + try process.run() + } catch { + recordFailure("Could not relaunch InputPilot (\(error.localizedDescription)). Quit it and open it again.") + return + } + + NSApp.terminate(nil) + } + var permissionWarningMessage: String { switch status.permissionStatus { case .denied: @@ -232,10 +282,11 @@ final class AppState: ObservableObject { return "Keyboard monitor paused: Input Monitoring permission is required." } + if let monitorError = hidKeyboardMonitor.lastStartErrorMessage { + return monitorError + } + if !hidKeyboardMonitor.isRunning { - if let monitorError = hidKeyboardMonitor.lastStartErrorMessage { - return monitorError - } return "Keyboard monitor is unavailable right now." } @@ -714,6 +765,9 @@ final class AppState: ObservableObject { if !wasRunning && hidKeyboardMonitor.isRunning { logInfo(category: "hid-monitor", message: "HID monitor started.") + if let partialAccessMessage = hidKeyboardMonitor.lastStartErrorMessage { + logWarn(category: "hid-monitor", message: partialAccessMessage) + } } if let lastMonitorStartFailure, lastAutoSwitchError == lastMonitorStartFailure { diff --git a/InputPilot/Persistence/AppSettingsStore.swift b/InputPilot/Persistence/AppSettingsStore.swift index cbd100b..4f08850 100644 --- a/InputPilot/Persistence/AppSettingsStore.swift +++ b/InputPilot/Persistence/AppSettingsStore.swift @@ -9,7 +9,9 @@ final class AppSettingsStore { } func bool(forKey key: String, default defaultValue: Bool = false) -> Bool { - defaults.object(forKey: key) as? Bool ?? defaultValue + // bool(forKey:) also reads "YES"/"NO" strings, as launch arguments + // such as `-onboardingCompleted NO` arrive. + defaults.object(forKey: key) == nil ? defaultValue : defaults.bool(forKey: key) } func set(_ value: Bool, forKey key: String) { diff --git a/InputPilot/Services/HIDKeyboardMonitor.swift b/InputPilot/Services/HIDKeyboardMonitor.swift index b9b896e..d86768b 100644 --- a/InputPilot/Services/HIDKeyboardMonitor.swift +++ b/InputPilot/Services/HIDKeyboardMonitor.swift @@ -12,6 +12,12 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring { var onDeviceRemoved: ((ActiveKeyboardDevice) -> Void)? private(set) var isRunning = false + /// Keep running when another app (Karabiner-Elements) holds a keyboard + /// exclusively. Only safe once Input Monitoring is known to apply to this + /// process: IOHIDManagerOpen reports the first per-device error of an + /// unordered set, so right after a fresh grant this error could mask the + /// permission failure that a relaunch fixes. + var allowsPartialOpen = false private(set) var lastStartError: IOReturn? #if DEBUG @@ -51,7 +57,8 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring { ) let openResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) - guard openResult == kIOReturnSuccess else { + let isPartialOpen = openResult == kIOReturnExclusiveAccess && allowsPartialOpen + guard openResult == kIOReturnSuccess || isPartialOpen else { IOHIDManagerUnscheduleFromRunLoop( manager, CFRunLoopGetMain(), @@ -62,10 +69,10 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring { return false } - lastStartError = nil + lastStartError = isPartialOpen ? openResult : nil self.manager = manager self.isRunning = true - debugLog("HID monitor started.") + debugLog(isPartialOpen ? "HID monitor started without one or more held keyboards." : "HID monitor started.") return true } @@ -212,6 +219,10 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring { return "HID monitor start blocked: process is not privileged (kIOReturnNotPrivileged)." case kIOReturnNoDevice: return "No matching keyboard HID device available." + case kIOReturnExclusiveAccess: + // A running manager never re-opens a keyboard that is released + // later, hence the relaunch. + return "Another app, such as Karabiner-Elements, has exclusive access to a keyboard, so InputPilot cannot tell when you type on it. In Karabiner-Elements → Devices, turn off \u{201C}Modify events\u{201D} for each keyboard InputPilot should tell apart, then relaunch InputPilot." default: return "HID monitor start failed with IOReturn \(lastStartError)." } diff --git a/InputPilot/Services/ServiceProtocols.swift b/InputPilot/Services/ServiceProtocols.swift index 6f3bf86..668c9bb 100644 --- a/InputPilot/Services/ServiceProtocols.swift +++ b/InputPilot/Services/ServiceProtocols.swift @@ -14,6 +14,7 @@ protocol HIDKeyboardMonitoring: AnyObject { var onDeviceRemoved: ((ActiveKeyboardDevice) -> Void)? { get set } /// Keyboards currently attached, as the running monitor sees them. func connectedDevices() -> [ActiveKeyboardDevice] + var allowsPartialOpen: Bool { get set } var isRunning: Bool { get } var lastStartErrorMessage: String? { get } } diff --git a/InputPilotTests/AppStateOnboardingTests.swift b/InputPilotTests/AppStateOnboardingTests.swift new file mode 100644 index 0000000..c72a572 --- /dev/null +++ b/InputPilotTests/AppStateOnboardingTests.swift @@ -0,0 +1,137 @@ +import Foundation +import IOKit.hid +import Testing +@testable import InputPilot + +/// The first run: asking for Input Monitoring, finishing onboarding, and the +/// states in between that once left new users with an inert app. +@MainActor +struct AppStateOnboardingTests { + private let exclusiveAccess = "Another app has exclusive access to a keyboard." + + @Test + func firstRunRequestsPermissionAndOnboardingLastsUntilCompleted() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let permission = MockPermissionService(accessType: kIOHIDAccessTypeUnknown) + let monitor = MockHIDKeyboardMonitor() + let appState = makeAppState(defaults: defaults, permission: permission, monitor: monitor) + + #expect(appState.needsOnboarding) + #expect(!monitor.isRunning) + + // 1.0.0 only ever checked the permission and never asked for it. + appState.requestInputMonitoringPermission() + + #expect(permission.requestCount == 1) + #expect(appState.status.permissionStatus == .granted) + #expect(monitor.isRunning) + #expect(appState.needsOnboarding) + + appState.completeOnboarding() + #expect(!appState.needsOnboarding) + + let relaunched = makeAppState(defaults: defaults, permission: permission, monitor: MockHIDKeyboardMonitor()) + #expect(!relaunched.needsOnboarding) + } + + @Test + func onboardingReturnsWhilePermissionIsMissingEvenAfterCompletion() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "onboardingCompleted") + + let appState = makeAppState( + defaults: defaults, + permission: MockPermissionService(accessType: kIOHIDAccessTypeDenied), + monitor: MockHIDKeyboardMonitor() + ) + + #expect(appState.needsOnboarding) + } + + @Test + func stringBooleansFromLaunchArgumentsAreRead() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AppSettingsStore(defaults: defaults) + + // `-onboardingCompleted NO` arrives as the string "NO". + defaults.set("NO", forKey: "flag") + #expect(store.bool(forKey: "flag", default: true) == false) + defaults.set("YES", forKey: "flag") + #expect(store.bool(forKey: "flag", default: false) == true) + #expect(store.bool(forKey: "missing", default: true) == true) + } + + @Test + func keyboardHeldByAnotherAppIsAPartialStartWhenPermissionWasAlreadyGranted() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let monitor = MockHIDKeyboardMonitor() + monitor.exclusiveAccessMessage = exclusiveAccess + + let appState = makeAppState( + defaults: defaults, + permission: MockPermissionService(accessType: kIOHIDAccessTypeGranted), + monitor: monitor + ) + + #expect(monitor.allowsPartialOpen) + #expect(monitor.isRunning) + #expect(!appState.needsRelaunchAfterGrant) + #expect(appState.keyboardMonitorHint == exclusiveAccess) + #expect(appState.monitorHasPartialAccess) + } + + @Test + func keyboardHeldByAnotherAppRightAfterAFreshGrantStillAsksForARelaunch() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let monitor = MockHIDKeyboardMonitor() + monitor.exclusiveAccessMessage = exclusiveAccess + + // A fresh grant may not apply to this process yet, and the open error + // comes from an unordered set of devices - it could hide that. + let appState = makeAppState( + defaults: defaults, + permission: MockPermissionService(accessType: kIOHIDAccessTypeUnknown), + monitor: monitor + ) + appState.requestInputMonitoringPermission() + + #expect(!monitor.allowsPartialOpen) + #expect(!monitor.isRunning) + #expect(appState.needsRelaunchAfterGrant) + } + + // MARK: - Helpers + + private func makeAppState( + defaults: UserDefaults, + permission: MockPermissionService, + monitor: MockHIDKeyboardMonitor + ) -> AppState { + AppState( + permissionService: permission, + hidKeyboardMonitor: monitor, + inputSourceService: MockInputSourceService(), + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: ImmediateClock() + ) + } + + private func makeDefaults() throws -> (UserDefaults, String) { + let suiteName = "AppStateOnboardingTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw OnboardingTestError.failedToCreateUserDefaultsSuite + } + + return (defaults, suiteName) + } +} + +private enum OnboardingTestError: Error { + case failedToCreateUserDefaultsSuite +} diff --git a/InputPilotTests/ServiceMocks.swift b/InputPilotTests/ServiceMocks.swift index d0b7071..14699cf 100644 --- a/InputPilotTests/ServiceMocks.swift +++ b/InputPilotTests/ServiceMocks.swift @@ -5,6 +5,7 @@ import IOKit.hid @MainActor final class MockPermissionService: PermissionServicing { var accessType: IOHIDAccessType + private(set) var requestCount = 0 init(accessType: IOHIDAccessType) { self.accessType = accessType @@ -15,6 +16,7 @@ final class MockPermissionService: PermissionServicing { } func requestInputMonitoring() -> Bool { + requestCount += 1 accessType = kIOHIDAccessTypeGranted return true } @@ -30,6 +32,10 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { var onDeviceRemoved: ((ActiveKeyboardDevice) -> Void)? var connected: [ActiveKeyboardDevice] = [] + var allowsPartialOpen = false + /// Models IOHIDManagerOpen returning kIOReturnExclusiveAccess because + /// another app (Karabiner-Elements) holds one of the keyboards. + var exclusiveAccessMessage: String? var startResult = true var isRunning = false var lastStartErrorMessage: String? @@ -40,6 +46,12 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { self.onEvent = onEvent startCallCount += 1 + if let exclusiveAccessMessage { + lastStartErrorMessage = exclusiveAccessMessage + isRunning = allowsPartialOpen + return allowsPartialOpen + } + if startResult { isRunning = true lastStartErrorMessage = nil From 2e9dfe6f2aaf1d39dd0a61f95e99a51d9a6f049f Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:31:51 +0200 Subject: [PATCH 06/12] fix: offer only keyboard input sources as mapping targets Emoji & Symbols, Dictation, Press and Hold and the emoji Touch Bar item are enabled, selectable input sources of the palette category. They appeared in every picker; mapped to a keyboard, selecting one opened a panel and never became the current keyboard source, so it was selected again after every cooldown. Lists and the enabled check now keep only the keyboard category. --- InputPilot/Services/InputSourceService.swift | 13 ++++++++-- InputPilotTests/InputSourceServiceTests.swift | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 InputPilotTests/InputSourceServiceTests.swift diff --git a/InputPilot/Services/InputSourceService.swift b/InputPilot/Services/InputSourceService.swift index 64380c1..5fe6462 100644 --- a/InputPilot/Services/InputSourceService.swift +++ b/InputPilot/Services/InputSourceService.swift @@ -25,7 +25,7 @@ final class InputSourceService: InputSourceServicing { kTISPropertyInputSourceIsEnabled as String: kCFBooleanTrue as Any, kTISPropertyInputSourceID as String: id ] - return !inputSources(matching: filter).isEmpty + return inputSources(matching: filter).contains(where: isKeyboardInputSource) } func currentInputSourceId() -> String? { @@ -64,7 +64,7 @@ final class InputSourceService: InputSourceServicing { private func makeInputSourceInfos(from sources: [TISInputSource]) -> [InputSourceInfo] { var infos: [InputSourceInfo] = [] - for source in sources { + for source in sources where isKeyboardInputSource(source) { guard let id = stringProperty(for: source, key: kTISPropertyInputSourceID) else { continue } @@ -79,6 +79,15 @@ final class InputSourceService: InputSourceServicing { } } + /// Excludes palette sources (Emoji & Symbols, Dictation, Press and Hold): + /// they are "selectable" but not layouts, so as a mapping target they open + /// a panel on every switch and never become the current keyboard source. + /// Filtered here rather than in the TIS query, which with a category key + /// also returns orphaned modes of disabled input methods. + private func isKeyboardInputSource(_ source: TISInputSource) -> Bool { + stringProperty(for: source, key: kTISPropertyInputSourceCategory) == (kTISCategoryKeyboardInputSource as String) + } + private func inputSources(matching filter: [String: Any]?) -> [TISInputSource] { guard let unmanagedList = TISCreateInputSourceList(filter as CFDictionary?, false) else { return [] diff --git a/InputPilotTests/InputSourceServiceTests.swift b/InputPilotTests/InputSourceServiceTests.swift new file mode 100644 index 0000000..8faa506 --- /dev/null +++ b/InputPilotTests/InputSourceServiceTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import InputPilot + +/// Runs against the real Text Input Sources API of the machine under test. +struct InputSourceServiceTests { + // Selectable, enabled on most Macs, but not keyboard layouts: mapping a + // keyboard to one opened the emoji viewer or dictation on every switch. + private let paletteSourceIds: Set = [ + "com.apple.CharacterPaletteIM", + "com.apple.inputmethod.ironwood", + "com.apple.PressAndHold", + "com.apple.inputmethod.EmojiFunctionRowItem" + ] + + @Test + func onlyKeyboardInputSourcesAreOffered() { + let service = InputSourceService() + let enabledIds = Set(service.listEnabledInputSources().map(\.id)) + let allIds = Set(service.listAllInputSources().map(\.id)) + + #expect(enabledIds.isDisjoint(with: paletteSourceIds)) + #expect(allIds.isDisjoint(with: paletteSourceIds)) + #expect(paletteSourceIds.allSatisfy { !service.existsEnabledInputSource(id: $0) }) + } +} From 4042e3e7b752a425a7134169c740bd64e4aaa44f Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:45:15 +0200 Subject: [PATCH 07/12] feat: plain-language status, device details and a quick-map action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status reads "Input Monitoring: not granted yet" and "Keyboard detection: running" instead of "Permission: unknown" / "Monitor". - Device details read "USB · Vendor 1452 · Product 832"; the location only appears when it tells two keyboards of the same model apart. - Conflicts explain themselves instead of "missing/disabled". - useCurrentInputSourceForActiveDevice() maps the keyboard in use to the source selected right now, for a one-click action in the menu. - The view-facing strings move into AppState+Display.swift, so AppState shrinks to 1,150 lines instead of growing with the new UI. --- InputPilot/App/AppState+Display.swift | 101 +++++++++++++++ InputPilot/App/AppState.swift | 83 +++---------- InputPilot/Models/ActiveKeyboardDevice.swift | 2 +- InputPilot/Models/InputStatusSnapshot.swift | 11 ++ InputPilot/Models/MappingConflict.swift | 7 ++ InputPilotTests/AppStateDisplayTests.swift | 124 +++++++++++++++++++ 6 files changed, 262 insertions(+), 66 deletions(-) create mode 100644 InputPilot/App/AppState+Display.swift create mode 100644 InputPilotTests/AppStateDisplayTests.swift diff --git a/InputPilot/App/AppState+Display.swift b/InputPilot/App/AppState+Display.swift new file mode 100644 index 0000000..80a05af --- /dev/null +++ b/InputPilot/App/AppState+Display.swift @@ -0,0 +1,101 @@ +import Foundation + +/// What the menu and Settings show, phrased for someone who has never heard +/// of a vendor ID. Derived from AppState; holds no state of its own. +extension AppState { + var permissionLine: String { + "Input Monitoring: \(status.permissionStatus.displayName)" + } + + var monitorLine: String { + guard isKeyboardMonitorRunning else { + return "Keyboard detection: stopped" + } + + return monitorHasPartialAccess + ? "Keyboard detection: running, without a keyboard held by another app" + : "Keyboard detection: running" + } + + var activeKeyboardLine: String { + "Typing on: \(status.activeKeyboard)" + } + + var activeInputSourceLine: String { + "Input source: \(status.activeInputSource)" + } + + var activeKeyboardTitle: String? { + activeDeviceKey.map(deviceTitle(for:)) + } + + /// The source the keyboard in use switches to, or nil if it has none. + var activeKeyboardTargetName: String? { + activeDeviceKey + .flatMap(resolvedTargetInputSourceId(for:)) + .map(inputSourceName(for:)) + } + + var canUseCurrentSourceForActiveDevice: Bool { + guard let activeDeviceKey, let currentInputSourceId else { + return false + } + + return mappedInputSourceId(for: activeDeviceKey) != currentInputSourceId + && selectableInputSources.contains(where: { $0.id == currentInputSourceId }) + } + + func deviceTitle(for deviceKey: KeyboardDeviceKey) -> String { + if let detectedDevice = detectedDevicesByKey[deviceKey] { + return detectedDevice.displayName + } + + if let productName = deviceKey.productName, !productName.isEmpty { + return productName + } + + if deviceKey.isBuiltIn { + return "Built-in keyboard" + } + + return "Keyboard (vendor \(KeyboardFingerprint.hardwareIdLabel(deviceKey.vendorId)), product \(KeyboardFingerprint.hardwareIdLabel(deviceKey.productId)))" + } + + func deviceSubtitle(for deviceKey: KeyboardDeviceKey) -> String { + var parts: [String] = [] + + if deviceKey.isBuiltIn { + parts.append("Built-in") + } else if let transport = deviceKey.transport, !transport.isEmpty { + parts.append(transport) + } + + parts.append("Vendor \(KeyboardFingerprint.hardwareIdLabel(deviceKey.vendorId))") + parts.append("Product \(KeyboardFingerprint.hardwareIdLabel(deviceKey.productId))") + + // The location is meaningless on its own; it only tells two keyboards + // of the same model apart. + let twins = knownDeviceKeys.filter { $0.matchesPrimary(of: deviceKey.fingerprint) } + if twins.count > 1, let locationId = detectedDevicesByKey[deviceKey]?.locationId ?? deviceKey.locationId { + parts.append("Location \(locationId)") + } + + return parts.joined(separator: " · ") + } + + func mappingConflictSourceName(for conflict: MappingConflict) -> String { + inputSourceName(for: conflict.mappedSourceId) + } + + func mappingConflictReasonText(for conflict: MappingConflict) -> String { + conflict.reason.explanation + } + + /// "Paused until 14:32", with the date when it is not today. + func pausedUntilText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = Calendar.current.isDateInToday(date) ? .none : .medium + return "Paused until \(formatter.string(from: date))" + } +} diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 46d42d8..2afb99b 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -51,7 +51,7 @@ final class AppState: ObservableObject { private let debugLogService: DebugLogServicing private let switchController: SwitchController - private var detectedDevicesByKey: [KeyboardDeviceKey: ActiveKeyboardDevice] = [:] + private(set) var detectedDevicesByKey: [KeyboardDeviceKey: ActiveKeyboardDevice] = [:] private var permissionRefreshTask: Task? private var inputSourceChangeObserver: (any NSObjectProtocol)? private let permissionPollInterval: Duration @@ -61,7 +61,7 @@ final class AppState: ObservableObject { /// means someone else chose it. Nil: nothing to compare against, so the /// next switch goes ahead. private var lastAppliedSourceId: String? - private var activeDeviceKey: KeyboardDeviceKey? + private(set) var activeDeviceKey: KeyboardDeviceKey? private var lastSwitchingDeviceKey: KeyboardDeviceKey? #if DEBUG @@ -164,20 +164,8 @@ final class AppState: ObservableObject { } } - var permissionLine: String { - "Permission: \(status.permissionStatus.rawValue)" - } - - var monitorLine: String { - "Monitor: \(hidKeyboardMonitor.isRunning ? "running" : "stopped")" - } - - var activeKeyboardLine: String { - "Active device: \(status.activeKeyboard)" - } - - var activeInputSourceLine: String { - "Current source: \(status.activeInputSource)" + var isKeyboardMonitorRunning: Bool { + hidKeyboardMonitor.isRunning } var isInputMonitoringGranted: Bool { @@ -455,6 +443,17 @@ final class AppState: ObservableObject { recordAction("Undid last switch: \(lastAction.deviceDisplayName) -> \(previousSourceName).") } + /// "Use U.S. for Keychron K2" in the menu: map the keyboard in use to the + /// source selected right now, without opening Settings. + func useCurrentInputSourceForActiveDevice() { + guard let activeDeviceKey, let currentInputSourceId, + selectableInputSources.contains(where: { $0.id == currentInputSourceId }) else { + return + } + + setMapping(for: activeDeviceKey, inputSourceId: currentInputSourceId) + } + func mappedInputSourceId(for deviceKey: KeyboardDeviceKey) -> String? { mappingStore.getMapping(for: deviceKey) } @@ -550,56 +549,10 @@ final class AppState: ObservableObject { clearLastError() } - func deviceTitle(for deviceKey: KeyboardDeviceKey) -> String { - if let detectedDevice = detectedDevicesByKey[deviceKey] { - return detectedDevice.displayName - } - - if let productName = deviceKey.productName, !productName.isEmpty { - return productName - } - - if deviceKey.isBuiltIn { - return "Built-in keyboard" - } - - return "Keyboard VID \(KeyboardFingerprint.hardwareIdLabel(deviceKey.vendorId)), PID \(KeyboardFingerprint.hardwareIdLabel(deviceKey.productId))" - } - - func deviceSubtitle(for deviceKey: KeyboardDeviceKey) -> String { - var parts = [ - "VID \(KeyboardFingerprint.hardwareIdLabel(deviceKey.vendorId))", - "PID \(KeyboardFingerprint.hardwareIdLabel(deviceKey.productId))" - ] - - if let transport = deviceKey.transport, !transport.isEmpty { - parts.append(transport) - } - - if deviceKey.isBuiltIn { - parts.append("Built-in") - } - - let locationId = detectedDevicesByKey[deviceKey]?.locationId ?? deviceKey.locationId - if let locationId { - parts.append("Location \(locationId)") - } - - return parts.joined(separator: " | ") - } - func inputSourceName(for id: String) -> String { inputSourceDisplayName(for: id) } - func mappingConflictSourceName(for conflict: MappingConflict) -> String { - inputSourceDisplayName(for: conflict.mappedSourceId) - } - - func mappingConflictReasonText(for conflict: MappingConflict) -> String { - conflict.reason.rawValue - } - func openMappingFix(for deviceKey: KeyboardDeviceKey) { if !knownDeviceKeys.contains(deviceKey) { knownDeviceKeys.append(deviceKey) @@ -751,7 +704,7 @@ final class AppState: ObservableObject { } guard didStart else { - let failureMessage = hidKeyboardMonitor.lastStartErrorMessage ?? "HID monitor could not start although permission is granted." + let failureMessage = hidKeyboardMonitor.lastStartErrorMessage ?? "Keyboard detection could not start although permission is granted." // The permission poll retries every second; log each distinct // failure once so it cannot crowd everything else out of the log. if lastMonitorStartFailure != failureMessage { @@ -1018,7 +971,7 @@ final class AppState: ObservableObject { return "Built-in keyboard" } - return "VID \(KeyboardFingerprint.hardwareIdLabel(device.vendorId)), PID \(KeyboardFingerprint.hardwareIdLabel(device.productId))" + return "Keyboard (vendor \(KeyboardFingerprint.hardwareIdLabel(device.vendorId)), product \(KeyboardFingerprint.hardwareIdLabel(device.productId)))" } private func inputSourceDisplayName(for id: String) -> String { @@ -1027,7 +980,7 @@ final class AppState: ObservableObject { ?? id } - private func resolvedTargetInputSourceId(for deviceKey: KeyboardDeviceKey) -> String? { + func resolvedTargetInputSourceId(for deviceKey: KeyboardDeviceKey) -> String? { if let mappedInputSourceId = resolvedConfiguredInputSourceId( for: deviceKey, sourceFor: mappingStore.getMapping(for:) diff --git a/InputPilot/Models/ActiveKeyboardDevice.swift b/InputPilot/Models/ActiveKeyboardDevice.swift index f060372..f17c528 100644 --- a/InputPilot/Models/ActiveKeyboardDevice.swift +++ b/InputPilot/Models/ActiveKeyboardDevice.swift @@ -48,6 +48,6 @@ struct ActiveKeyboardDevice: Equatable { return "Unknown keyboard" } - return "VID \(KeyboardFingerprint.hardwareIdLabel(vendorId)), PID \(KeyboardFingerprint.hardwareIdLabel(productId))" + return "Keyboard (vendor \(KeyboardFingerprint.hardwareIdLabel(vendorId)), product \(KeyboardFingerprint.hardwareIdLabel(productId)))" } } diff --git a/InputPilot/Models/InputStatusSnapshot.swift b/InputPilot/Models/InputStatusSnapshot.swift index 1b5e79f..dca37c8 100644 --- a/InputPilot/Models/InputStatusSnapshot.swift +++ b/InputPilot/Models/InputStatusSnapshot.swift @@ -4,6 +4,17 @@ enum PermissionStatus: String { case unknown case denied case granted + + var displayName: String { + switch self { + case .unknown: + return "not granted yet" + case .denied: + return "denied" + case .granted: + return "granted" + } + } } struct InputStatusSnapshot { diff --git a/InputPilot/Models/MappingConflict.swift b/InputPilot/Models/MappingConflict.swift index 62b01cf..705bc74 100644 --- a/InputPilot/Models/MappingConflict.swift +++ b/InputPilot/Models/MappingConflict.swift @@ -3,6 +3,13 @@ import Foundation struct MappingConflict: Identifiable, Equatable { enum Reason: String, Equatable { case missingOrDisabled = "missing/disabled" + + var explanation: String { + switch self { + case .missingOrDisabled: + return "The input source was removed or disabled." + } + } } let deviceKey: KeyboardDeviceKey diff --git a/InputPilotTests/AppStateDisplayTests.swift b/InputPilotTests/AppStateDisplayTests.swift new file mode 100644 index 0000000..4290ed4 --- /dev/null +++ b/InputPilotTests/AppStateDisplayTests.swift @@ -0,0 +1,124 @@ +import Foundation +import IOKit.hid +import Testing +@testable import InputPilot + +/// What the menu and Settings show, in the words a user reads. +@MainActor +struct AppStateDisplayTests { + private let us = "com.apple.keylayout.US" + private let german = "com.apple.keylayout.German" + private let keyboard = ActiveKeyboardDevice( + vendorId: 1452, + productId: 832, + productName: "Apple Keyboard", + transport: "USB", + locationId: 100 + ) + + @Test + func menuOffersToMapTheActiveKeyboardToTheCurrentSource() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let (appState, monitor, _) = makeAppState(defaults: defaults) + + monitor.emit(keyboard) + await waitFor { appState.activeKeyboardTitle != nil } + + #expect(appState.activeKeyboardTitle == "Apple Keyboard") + #expect(appState.activeKeyboardTargetName == nil) + #expect(appState.canUseCurrentSourceForActiveDevice) + + appState.useCurrentInputSourceForActiveDevice() + + #expect(appState.mappedInputSourceId(for: KeyboardDeviceKey(device: keyboard)) == german) + #expect(appState.activeKeyboardTargetName == "German") + #expect(!appState.canUseCurrentSourceForActiveDevice) + } + + @Test + func deviceDetailsUsePlainWordsAndShowALocationOnlyToTellTwinsApart() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let (appState, monitor, _) = makeAppState(defaults: defaults) + let twin = ActiveKeyboardDevice( + vendorId: 1452, + productId: 832, + productName: "Apple Keyboard", + transport: "USB", + locationId: 200 + ) + + monitor.emit(keyboard) + await waitFor { appState.knownDeviceKeys.count == 1 } + #expect(appState.deviceSubtitle(for: KeyboardDeviceKey(device: keyboard)) == "USB · Vendor 1452 · Product 832") + + monitor.connected = [keyboard, twin] + monitor.emit(twin) + await waitFor { appState.knownDeviceKeys.count == 2 } + #expect(appState.deviceSubtitle(for: KeyboardDeviceKey(device: twin)) == "USB · Vendor 1452 · Product 832 · Location 200") + } + + @Test + func statusLinesAvoidJargon() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let (appState, _, _) = makeAppState(defaults: defaults, access: kIOHIDAccessTypeUnknown) + + #expect(appState.permissionLine == "Input Monitoring: not granted yet") + #expect(appState.monitorLine == "Keyboard detection: stopped") + #expect(MappingConflict.Reason.missingOrDisabled.explanation == "The input source was removed or disabled.") + } + + // MARK: - Helpers + + private func makeAppState( + defaults: UserDefaults, + access: IOHIDAccessType = kIOHIDAccessTypeGranted + ) -> (AppState, MockHIDKeyboardMonitor, MockInputSourceService) { + let monitor = MockHIDKeyboardMonitor() + let sources = MockInputSourceService() + sources.enabledSources = [ + InputSourceInfo(id: us, name: "U.S.", isSelectable: true), + InputSourceInfo(id: german, name: "German", isSelectable: true) + ] + sources.currentInputSourceIdValue = german + + let appState = AppState( + permissionService: MockPermissionService(accessType: access), + hidKeyboardMonitor: monitor, + inputSourceService: sources, + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: ImmediateClock() + ) + return (appState, monitor, sources) + } + + private func makeDefaults() throws -> (UserDefaults, String) { + let suiteName = "AppStateDisplayTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw DisplayTestError.failedToCreateUserDefaultsSuite + } + + return (defaults, suiteName) + } + + private func waitFor( + maxAttempts: Int = 300, + condition: @escaping () -> Bool + ) async { + for _ in 0.. Date: Sun, 11 Oct 2026 16:45:15 +0200 Subject: [PATCH 08/12] feat: task-first menu, reordered Settings, and onboarding that ends Menu: what you are typing on and what it maps to comes first, with a one-click "Use for "; then switching, pause and undo. Hardware IDs and the debug log move into a Diagnostics submenu, Quit goes last, and every window it opens is brought to the front - a menu bar app is never frontmost, so Settings could open unreachable behind the active app. Settings: keyboards first (with an empty state that explains the second keyboard), then switching with Open at login and the new manual-change policy, fallbacks, updates with a toggle for Sparkle's daily check, and status. The 365-day "Pause" toggle becomes a plain on/off switch. Welcome: a denied permission gets System Settings as the main button, the post-grant state gets a Relaunch button instead of Quit, and the last step offers Open at login, pre-checked, before Done. Accessibility: the menu bar icon is labelled, per-keyboard controls name their keyboard, decorative images are hidden from VoiceOver. The empty launch UI test is replaced by a regression test for 1.0.0's missing first-run window. --- InputPilot/App/InputPilotApp.swift | 8 +- InputPilot/Services/UpdaterService.swift | 6 + InputPilot/UI/AboutSection.swift | 1 + InputPilot/UI/MenuBarMenuView.swift | 268 ++++++++------- InputPilot/UI/SettingsView.swift | 317 ++++++++++++------ InputPilot/UI/WelcomeView.swift | 159 ++++++--- .../InputPilotUITestsLaunchTests.swift | 24 +- 7 files changed, 489 insertions(+), 294 deletions(-) diff --git a/InputPilot/App/InputPilotApp.swift b/InputPilot/App/InputPilotApp.swift index 7824a76..ca963f3 100644 --- a/InputPilot/App/InputPilotApp.swift +++ b/InputPilot/App/InputPilotApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct InputPilotApp: App { @StateObject private var appState = AppState() + @StateObject private var loginItem = LoginItemController() private let updaterService = UpdaterService() var body: some Scene { @@ -17,6 +18,7 @@ struct InputPilotApp: App { Window("Welcome to InputPilot", id: "welcome") { WelcomeView() .environmentObject(appState) + .environmentObject(loginItem) } .windowResizability(.contentSize) @@ -27,8 +29,9 @@ struct InputPilotApp: App { .defaultSize(width: 600, height: 500) Window("Settings", id: "settings") { - SettingsView() + SettingsView(updaterService: updaterService) .environmentObject(appState) + .environmentObject(loginItem) } .defaultSize(width: 600, height: 600) .keyboardShortcut(",", modifiers: .command) @@ -43,8 +46,9 @@ private struct MenuBarLabel: View { var body: some View { Image(systemName: "keyboard") + .accessibilityLabel("InputPilot") .onAppear { - guard appState.needsPermissionOnboarding else { + guard appState.needsOnboarding else { return } diff --git a/InputPilot/Services/UpdaterService.swift b/InputPilot/Services/UpdaterService.swift index fdc59da..2efbed7 100644 --- a/InputPilot/Services/UpdaterService.swift +++ b/InputPilot/Services/UpdaterService.swift @@ -14,4 +14,10 @@ final class UpdaterService { func checkForUpdates() { updaterController.checkForUpdates(nil) } + + /// Sparkle's own setting: on, it asks GitHub about once a day. + var automaticallyChecksForUpdates: Bool { + get { updaterController.updater.automaticallyChecksForUpdates } + set { updaterController.updater.automaticallyChecksForUpdates = newValue } + } } diff --git a/InputPilot/UI/AboutSection.swift b/InputPilot/UI/AboutSection.swift index 88cc440..89ae197 100644 --- a/InputPilot/UI/AboutSection.swift +++ b/InputPilot/UI/AboutSection.swift @@ -45,6 +45,7 @@ struct AboutSection: View { Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 48, height: 48) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(appName) diff --git a/InputPilot/UI/MenuBarMenuView.swift b/InputPilot/UI/MenuBarMenuView.swift index 0af1ca2..81c5103 100644 --- a/InputPilot/UI/MenuBarMenuView.swift +++ b/InputPilot/UI/MenuBarMenuView.swift @@ -1,5 +1,7 @@ import SwiftUI +/// The menu bar menu, task first: which keyboard is in use and what it maps +/// to, then switching controls. Hardware details live under Diagnostics. struct MenuBarMenuView: View { let updaterService: UpdaterService @@ -13,171 +15,199 @@ struct MenuBarMenuView: View { }() var body: some View { - Section("Auto-Switch") { - Toggle( - "Auto-Switch", - isOn: Binding( - get: { appState.autoSwitchEnabled }, - set: { appState.setAutoSwitchEnabled($0) } - ) - ) + if !appState.isInputMonitoringGranted { + permissionSection + Divider() + } - Button("Pause 15 min") { - appState.pause(minutes: 15) - } - .disabled(!appState.autoSwitchEnabled) + keyboardSection - Button("Pause 60 min") { - appState.pause(minutes: 60) - } - .disabled(!appState.autoSwitchEnabled) + Divider() - if appState.isAutoSwitchPaused { - Button("Resume") { - appState.resume() - } - } + autoSwitchSection + + if appState.hasMappingConflicts { + Divider() - if let pauseUntil = appState.pauseUntil, appState.isAutoSwitchPaused { - Text("Paused until \(Self.timeFormatter.string(from: pauseUntil))") - .foregroundStyle(.secondary) + Label( + "\(appState.mappingConflicts.count) mapping(s) point to a removed input source", + systemImage: "exclamationmark.triangle.fill" + ) + Button("Fix in Settings…") { + open("settings") } + } - Text("Last action: \(appState.lastAutoSwitchAction)") + Divider() - if let error = appState.lastAutoSwitchError { - Text("Error: \(error)") - .foregroundStyle(.red) - } + Button { + open("settings") + } label: { + Label("Settings…", systemImage: "gearshape") } + .keyboardShortcut(",", modifiers: .command) - if appState.hasMappingConflicts { - Divider() + Button { + updaterService.checkForUpdates() + } label: { + Label("Check for Updates…", systemImage: "arrow.triangle.2.circlepath") + } - Section("Warnings") { - Label( - "\(appState.mappingConflicts.count) mapping conflict(s): source missing or disabled", - systemImage: "exclamationmark.triangle.fill" - ) - .foregroundStyle(.orange) - - Button { - openWindow(id: "settings") - } label: { - Text("Fix in Settings...") - } - } + Menu("Diagnostics") { + diagnostics } Divider() - Section("Last switch") { - if let lastAction = appState.lastAction { - let targetName = appState.inputSourceName(for: lastAction.toInputSourceId) - Text("\(lastAction.deviceDisplayName) → \(targetName) (\(Self.timeFormatter.string(from: lastAction.timestamp)))") + Button("Quit InputPilot") { + NSApp.terminate(nil) + } + .keyboardShortcut("q") + } - if appState.canUndoLastSwitch { - Button("Undo") { - appState.undoLastSwitch() - } - } - } else { - Text("No switch action yet.") - .foregroundStyle(.secondary) + @ViewBuilder + private var permissionSection: some View { + Label( + appState.status.permissionStatus == .denied + ? "Input Monitoring is switched off for InputPilot" + : "InputPilot needs Input Monitoring", + systemImage: "exclamationmark.triangle.fill" + ) + + // macOS prompts only once; after a denial only System Settings helps. + if appState.status.permissionStatus == .unknown { + Button("Grant Access…") { + appState.requestInputMonitoringPermission() } } - Divider() + Button("Open Input Monitoring Settings…") { + appState.openInputMonitoringSettings() + } + } - if !appState.isInputMonitoringGranted { - Section("Permission Required") { - Label(appState.permissionWarningMessage, systemImage: "exclamationmark.triangle.fill") + @ViewBuilder + private var keyboardSection: some View { + if let keyboard = appState.activeKeyboardTitle { + Text("Typing on \(keyboard) → \(appState.activeKeyboardTargetName ?? "not mapped")") - Button("Request Permission") { - appState.requestInputMonitoringPermission() - } + if let hold = appState.manualHoldDescription { + Text(hold) + } - Button("Open Input Monitoring Settings") { - appState.openInputMonitoringSettings() + if appState.canUseCurrentSourceForActiveDevice, let source = appState.currentInputSourceName { + Button("Use \(source) for \(keyboard)") { + appState.useCurrentInputSourceForActiveDevice() } } + } else if appState.isInputMonitoringGranted, appState.isKeyboardMonitorRunning { + Text("Press any key to detect your keyboard") + } - Divider() + if appState.isInputMonitoringGranted, !appState.isKeyboardMonitorRunning { + Text("Keyboard detection isn't running") + relaunchButton + } else if appState.monitorHasPartialAccess { + Text("A keyboard is held by another app – see Settings") + relaunchButton } + } - Section("Status") { - Text(appState.permissionLine) - Text(appState.monitorLine) - Text(appState.activeKeyboardLine) - Text(appState.activeInputSourceLine) + @ViewBuilder + private var autoSwitchSection: some View { + Toggle( + "Automatic Switching", + isOn: Binding( + get: { appState.autoSwitchEnabled }, + set: { appState.setAutoSwitchEnabled($0) } + ) + ) - Button("Open Debug") { - openWindow(id: "debug-log") - } + if let reason = appState.autoSwitchOffReason { + Text(reason) } - Section("Active Keyboard Device") { - if let keyboard = appState.activeKeyboardDevice { - Text("Vendor ID: \(KeyboardFingerprint.hardwareIdLabel(keyboard.vendorId))") - Text("Product ID: \(KeyboardFingerprint.hardwareIdLabel(keyboard.productId))") - Text("Product: \(keyboard.productName ?? "unknown")") - Text("Transport: \(keyboard.transport ?? "unknown")") - Text("Location ID: \(keyboard.locationId.map(String.init) ?? "unknown")") - } else { - Text("No active keyboard detected.") + if appState.isAutoSwitchPaused, let pauseUntil = appState.pauseUntil { + Text(appState.pausedUntilText(pauseUntil)) + + Button("Resume") { + appState.resume() + } + } else { + Button("Pause for 15 Minutes") { + appState.pause(minutes: 15) } + .disabled(!appState.autoSwitchEnabled) - if let hint = appState.keyboardMonitorHint { - Text(hint) - .foregroundStyle(.secondary) + Button("Pause for 1 Hour") { + appState.pause(minutes: 60) } + .disabled(!appState.autoSwitchEnabled) } - Section("Input Sources") { - Text("Current: \(appState.currentInputSourceName ?? "none")") - Text("ID: \(appState.currentInputSourceId ?? "none")") - -#if DEBUG - if appState.enabledInputSources.isEmpty { - Text("No enabled input sources found.") - .foregroundStyle(.secondary) + if appState.canUndoLastSwitch, let lastAction = appState.lastAction { + let target = appState.inputSourceName(for: lastAction.toInputSourceId) + let time = Self.timeFormatter.string(from: lastAction.timestamp) + Button("Undo Switch to \(target) (\(lastAction.deviceDisplayName), \(time))") { + appState.undoLastSwitch() } + } - ForEach(appState.enabledInputSources) { source in - Button { - appState.selectInputSource(source.id) - } label: { - if source.id == appState.currentInputSourceId { - Label(source.name, systemImage: "checkmark") - } else { - Text(source.name) - } - } - .disabled(!source.isSelectable) - } -#endif + // Monitor failures have their own line above; this is for switch errors. + if appState.isKeyboardMonitorRunning, let error = appState.lastAutoSwitchError { + Text("Error: \(error)") + } + } + + @ViewBuilder + private var diagnostics: some View { + Text(appState.permissionLine) + Text(appState.monitorLine) + Text(appState.activeInputSourceLine) + Text("Input source ID: \(appState.currentInputSourceId ?? "none")") + + if let keyboard = appState.activeKeyboardDevice { + Text("Keyboard: \(keyboard.displayName)") + Text("Vendor \(KeyboardFingerprint.hardwareIdLabel(keyboard.vendorId)) · Product \(KeyboardFingerprint.hardwareIdLabel(keyboard.productId))") + Text("Connection: \(keyboard.transport ?? "unknown") · Location \(keyboard.locationId.map(String.init) ?? "unknown")") } + Text("Last action: \(appState.lastAutoSwitchAction)") + Divider() - Button("Quit InputPilot") { - NSApp.terminate(nil) + Button("Open Debug Log…") { + open("debug-log") } - .keyboardShortcut("q") +#if DEBUG Divider() - Button { - openWindow(id: "settings") - } label: { - Label("Settings…", systemImage: "gearshape") + ForEach(appState.enabledInputSources) { source in + Button { + appState.selectInputSource(source.id) + } label: { + if source.id == appState.currentInputSourceId { + Label(source.name, systemImage: "checkmark") + } else { + Text(source.name) + } + } + .disabled(!source.isSelectable) } - .keyboardShortcut(",", modifiers: .command) +#endif + } - Button { - updaterService.checkForUpdates() - } label: { - Label("Check for Updates…", systemImage: "arrow.triangle.2.circlepath") + private var relaunchButton: some View { + Button("Relaunch InputPilot") { + appState.relaunch() } } + + private func open(_ windowId: String) { + openWindow(id: windowId) + // A menu bar app is never frontmost on its own. Without this the window + // can open behind the active app, with no Dock icon or ⌘-Tab to reach it. + NSApp.activate(ignoringOtherApps: true) + } } diff --git a/InputPilot/UI/SettingsView.swift b/InputPilot/UI/SettingsView.swift index ee609bb..d842984 100644 --- a/InputPilot/UI/SettingsView.swift +++ b/InputPilot/UI/SettingsView.swift @@ -1,136 +1,238 @@ import SwiftUI struct SettingsView: View { - private let noAutoSwitchTag = "__NO_AUTO_SWITCH__" + let updaterService: UpdaterService + + private let notMappedTag = "__NOT_MAPPED__" private let noFallbackTag = "__NO_FALLBACK__" @EnvironmentObject private var appState: AppState + @EnvironmentObject private var loginItem: LoginItemController + @State private var checksForUpdatesAutomatically = true var body: some View { Form { - Section { - Toggle( - "Pause Auto-Switch", - isOn: Binding( - get: { appState.isAutoSwitchPaused }, - set: { appState.setAutoSwitchPaused($0) } - ) - ) + if appState.hasMappingConflicts { + conflictsSection + } - Toggle( - "Switch on modifier-only key presses", - isOn: Binding( - get: { appState.switchOnModifierOnlyKeys }, - set: { appState.setSwitchOnModifierOnlyKeys($0) } - ) - ) + keyboardsSection + switchingSection + fallbackSection + updatesSection + statusSection + + AboutSection() + } + .formStyle(.grouped) + .frame(minWidth: 480, minHeight: 520) + .onAppear { + appState.refreshInputSourcesNow() + loginItem.refresh() + checksForUpdatesAutomatically = updaterService.automaticallyChecksForUpdates + } + .onChange(of: checksForUpdatesAutomatically) { newValue in + updaterService.automaticallyChecksForUpdates = newValue + } + } + + // MARK: - Sections + + private var conflictsSection: some View { + Section("Needs Attention") { + ForEach(appState.mappingConflicts) { conflict in + VStack(alignment: .leading, spacing: 6) { + Label { + Text("\(appState.deviceTitle(for: conflict.deviceKey)) → \(appState.mappingConflictSourceName(for: conflict))") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } - LabeledContent("Last action", value: appState.lastAutoSwitchAction) + Text(appState.mappingConflictReasonText(for: conflict)) + .font(.caption) + .foregroundStyle(.secondary) - if let error = appState.lastAutoSwitchError { - LabeledContent("Error") { - Text(error).foregroundStyle(.red) + Button("Choose Another Input Source") { + appState.openMappingFix(for: conflict.deviceKey) } } - } header: { - Text("Auto-Switch") - } footer: { - Text("When \"Switch on modifier-only key presses\" is off, a lone Shift, Control, Option, or Command press does not change the input source. Any other key, including the Tab in Cmd+Tab, counts as typing on that keyboard.") + .padding(.vertical, 2) } + } + } - Section("Input Monitoring") { - statusRow(appState.permissionLine) - statusRow(appState.activeKeyboardLine) - statusRow(appState.activeInputSourceLine) + private var keyboardsSection: some View { + Section { + if appState.knownDeviceKeys.isEmpty { + Text("Type a few keys on each keyboard you use, and it appears here.") + .foregroundStyle(.secondary) } - Section("Input Sources") { - LabeledContent("Current", value: appState.currentInputSourceName ?? "none") - LabeledContent("Identifier", value: appState.currentInputSourceId ?? "none") + ForEach(orderedDeviceKeys, id: \.self) { deviceKey in + deviceMappingRow(for: deviceKey) } + } header: { + Text("Keyboards") + } footer: { + if appState.knownDeviceKeys.count < 2 { + Text("InputPilot switches when you move between keyboards. Give this one an input source now; the next keyboard you type on appears here too.") + } + } + } - Section { - if appState.selectableInputSources.isEmpty { - Text("No selectable input sources available.") - .foregroundStyle(.secondary) - } else { - Picker("Global Fallback", selection: globalFallbackSelectionBinding) { - Text("No global fallback") - .tag(noFallbackTag) - - ForEach(appState.selectableInputSources) { inputSource in - Text(inputSource.name) - .tag(inputSource.id) - } - } + private var switchingSection: some View { + Section { + Toggle( + "Automatic switching", + isOn: Binding( + get: { appState.autoSwitchEnabled }, + set: { appState.setAutoSwitchEnabled($0) } + ) + ) - Button("Use current input source as global fallback") { - appState.useCurrentInputSourceAsGlobalFallback() + if let reason = appState.autoSwitchOffReason { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + + Toggle( + "Open InputPilot at login", + isOn: Binding( + get: { loginItem.isEnabled }, + set: { loginItem.setEnabled($0) } + ) + ) + + if loginItem.status == .requiresApproval { + LabeledContent("Waiting for your approval in System Settings") { + Button("Open Login Items…") { + loginItem.openSystemSettingsLoginItems() } - .disabled(appState.currentInputSourceId == nil) } - } header: { - Text("Fallbacks") - } footer: { - Text("Used when a device has no specific mapping.") } - Section("Conflicts") { - if appState.mappingConflicts.isEmpty { - Label("No mapping conflicts detected.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.secondary) - } else { - ForEach(appState.mappingConflicts) { conflict in - VStack(alignment: .leading, spacing: 6) { - Label { - Text("\(appState.deviceTitle(for: conflict.deviceKey)) → \(appState.mappingConflictSourceName(for: conflict))") - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - - Text(appState.mappingConflictReasonText(for: conflict)) - .font(.caption) - .foregroundStyle(.secondary) - - Button("Fix…") { - appState.openMappingFix(for: conflict.deviceKey) - } - } - .padding(.vertical, 2) - } + if let error = loginItem.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Picker( + "When you change the input source yourself", + selection: Binding( + get: { appState.manualChangePolicy }, + set: { appState.setManualChangePolicy($0) } + ) + ) { + ForEach(ManualChangePolicy.allCases) { policy in + Text(policy.title) + .tag(policy) } } - Section("Keyboard Device Mappings") { - if appState.knownDeviceKeys.isEmpty { - Text("No keyboard devices recognized yet. Press a key on a keyboard to detect it.") - .foregroundStyle(.secondary) + Toggle( + "Switch on modifier-only key presses", + isOn: Binding( + get: { appState.switchOnModifierOnlyKeys }, + set: { appState.setSwitchOnModifierOnlyKeys($0) } + ) + ) + } header: { + Text("Switching") + } footer: { + Text("A change counts as yours when anything but InputPilot makes it: ⌃Space, the input menu, Undo, or macOS's own \u{201C}Automatically switch to a document's input source\u{201D}. A lone Shift, Control, Option, or Command press only switches when modifier-only switching is on; any other key, including the Tab in Cmd+Tab, counts as typing on that keyboard.") + } + } + + private var fallbackSection: some View { + Section { + if appState.selectableInputSources.isEmpty { + Text("No selectable input sources available.") + .foregroundStyle(.secondary) + } else { + Picker("Every other keyboard", selection: globalFallbackSelectionBinding) { + Text("No fallback") + .tag(noFallbackTag) + + ForEach(appState.selectableInputSources) { inputSource in + Text(inputSource.name) + .tag(inputSource.id) + } } - ForEach(orderedDeviceKeys, id: \.self) { deviceKey in - deviceMappingRow(for: deviceKey) + Button("Use Current Input Source for Every Other Keyboard") { + appState.useCurrentInputSourceAsGlobalFallback() } + .disabled(appState.currentInputSourceId == nil) } + } header: { + Text("Fallbacks") + } footer: { + Text("A keyboard's own fallback is used when it has no input source or its input source was removed. This one covers every keyboard without either.") + } + } - AboutSection() + private var updatesSection: some View { + Section { + Toggle("Check for updates automatically", isOn: $checksForUpdatesAutomatically) + + Button("Check Now…") { + updaterService.checkForUpdates() + } + } header: { + Text("Updates") + } footer: { + Text("Asks GitHub about once a day whether a newer version exists. Nothing else is sent.") } - .formStyle(.grouped) - .frame(minWidth: 480, minHeight: 520) - .onAppear { - appState.refreshInputSourcesNow() + } + + private var statusSection: some View { + Section("Status") { + statusRow(appState.permissionLine) + statusRow(appState.monitorLine) + statusRow(appState.activeKeyboardLine) + statusRow(appState.activeInputSourceLine) + LabeledContent("Last action", value: appState.lastAutoSwitchAction) + + if let error = appState.lastAutoSwitchError { + LabeledContent("Error") { + Text(error).foregroundStyle(.red) + } + } + + if let hint = appState.keyboardMonitorHint { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + + if !appState.isInputMonitoringGranted { + Button("Open Input Monitoring Settings…") { + appState.openInputMonitoringSettings() + } + } else if !appState.isKeyboardMonitorRunning || appState.monitorHasPartialAccess { + Button("Relaunch InputPilot") { + appState.relaunch() + } + } } } + // MARK: - Rows + @ViewBuilder private func deviceMappingRow(for deviceKey: KeyboardDeviceKey) -> some View { + let title = appState.deviceTitle(for: deviceKey) + VStack(alignment: .leading, spacing: 8) { HStack { - Text(appState.deviceTitle(for: deviceKey)) + Text(title) .font(.headline) if appState.conflictFixTargetDeviceKey == deviceKey { - Text("Fix target") + Text("Needs a new input source") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) .padding(.horizontal, 6) @@ -147,18 +249,19 @@ struct SettingsView: View { Text("No selectable input sources available.") .foregroundStyle(.secondary) } else { - Picker("Input Source", selection: mappingSelectionBinding(for: deviceKey)) { - Text("No automatic switch") - .tag(noAutoSwitchTag) + Picker("Input source", selection: mappingSelectionBinding(for: deviceKey)) { + Text("Not mapped") + .tag(notMappedTag) ForEach(appState.selectableInputSources) { inputSource in Text(inputSource.name) .tag(inputSource.id) } } + .accessibilityLabel("Input source for \(title)") - Picker("Device Fallback (Advanced)", selection: perDeviceFallbackSelectionBinding(for: deviceKey)) { - Text("No device fallback") + Picker("Fallback", selection: perDeviceFallbackSelectionBinding(for: deviceKey)) { + Text("No fallback") .tag(noFallbackTag) ForEach(appState.selectableInputSources) { inputSource in @@ -166,11 +269,13 @@ struct SettingsView: View { .tag(inputSource.id) } } + .accessibilityLabel("Fallback input source for \(title)") } - Button("Forget device", role: .destructive) { + Button("Forget This Keyboard", role: .destructive) { appState.forgetDevice(deviceKey) } + .accessibilityLabel("Forget \(title)") } .padding(.vertical, 4) } @@ -182,6 +287,8 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } + // MARK: - Bindings + private var orderedDeviceKeys: [KeyboardDeviceKey] { guard let fixTarget = appState.conflictFixTargetDeviceKey else { return appState.knownDeviceKeys @@ -200,17 +307,13 @@ struct SettingsView: View { get: { guard let mappedInputSourceId = appState.mappedInputSourceId(for: deviceKey), isSelectableInputSourceId(mappedInputSourceId) else { - return noAutoSwitchTag + return notMappedTag } return mappedInputSourceId }, set: { selection in - if selection == noAutoSwitchTag { - appState.setMapping(for: deviceKey, inputSourceId: nil) - } else { - appState.setMapping(for: deviceKey, inputSourceId: selection) - } + appState.setMapping(for: deviceKey, inputSourceId: selection == notMappedTag ? nil : selection) } ) } @@ -226,11 +329,7 @@ struct SettingsView: View { return globalFallbackInputSourceId }, set: { selection in - if selection == noFallbackTag { - appState.setGlobalFallbackInputSourceId(nil) - } else { - appState.setGlobalFallbackInputSourceId(selection) - } + appState.setGlobalFallbackInputSourceId(selection == noFallbackTag ? nil : selection) } ) } @@ -246,11 +345,7 @@ struct SettingsView: View { return perDeviceFallbackInputSourceId }, set: { selection in - if selection == noFallbackTag { - appState.setPerDeviceFallback(for: deviceKey, inputSourceId: nil) - } else { - appState.setPerDeviceFallback(for: deviceKey, inputSourceId: selection) - } + appState.setPerDeviceFallback(for: deviceKey, inputSourceId: selection == noFallbackTag ? nil : selection) } ) } diff --git a/InputPilot/UI/WelcomeView.swift b/InputPilot/UI/WelcomeView.swift index 979b1e6..b2c0445 100644 --- a/InputPilot/UI/WelcomeView.swift +++ b/InputPilot/UI/WelcomeView.swift @@ -2,16 +2,23 @@ import SwiftUI /// First-run explainer. A menu bar app has no window of its own, so without /// this a new user sees nothing happen at all: macOS never prompts for Input -/// Monitoring on its own, and the app stays invisible and inert. +/// Monitoring on its own, and the app stays invisible and inert. It stays until +/// its last step is done, so the relaunch macOS needs does not end in silence. struct WelcomeView: View { @EnvironmentObject private var appState: AppState + @EnvironmentObject private var loginItem: LoginItemController @Environment(\.dismiss) private var dismiss + @Environment(\.openWindow) private var openWindow + /// Pre-checked: a menu bar utility that is not a login item silently stops + /// working after every restart. + @State private var openAtLogin = true var body: some View { VStack(spacing: 20) { Image(systemName: "keyboard") .font(.system(size: 48)) .foregroundStyle(.tint) + .accessibilityHidden(true) VStack(spacing: 8) { Text("Welcome to InputPilot") @@ -44,72 +51,132 @@ struct WelcomeView: View { .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) actions + // Without a width cap the stack takes its widest child's ideal + // single-line width, and the window frame clips captions instead of + // letting them wrap. + .frame(maxWidth: .infinity) } .padding(28) .frame(width: 420) + .onDisappear { + // Closing the window once everything works counts as done, so it does + // not come back at every launch. + if !appState.needsPermissionOnboarding && !appState.needsRelaunchAfterGrant { + appState.completeOnboarding() + } + } } @ViewBuilder private var actions: some View { - if appState.needsPermissionOnboarding { - VStack(spacing: 10) { - Button("Grant Access…") { - appState.requestInputMonitoringPermission() - } - .keyboardShortcut(.defaultAction) - .controlSize(.large) + if appState.status.permissionStatus == .denied { + deniedActions + } else if appState.needsPermissionOnboarding { + grantActions + } else if appState.needsRelaunchAfterGrant { + relaunchActions + } else { + finalActions + } + } - Button("Open System Settings") { - appState.openInputMonitoringSettings() - } - .buttonStyle(.link) + private var grantActions: some View { + VStack(spacing: 10) { + Button("Grant Access…") { + appState.requestInputMonitoringPermission() + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) - Text("If macOS does not show a prompt, add InputPilot manually under Privacy & Security → Input Monitoring.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + Button("Open System Settings") { + appState.openInputMonitoringSettings() } - // Without a width cap the stack takes its widest child's ideal - // single-line width, and the window frame clips the caption - // instead of letting it wrap. - .frame(maxWidth: .infinity) - } else if appState.needsRelaunchAfterGrant { - VStack(spacing: 10) { - Label("Permission granted.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .buttonStyle(.link) - Text("macOS applies Input Monitoring on the next launch. Quit and reopen InputPilot to finish setup.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + caption("If macOS does not show a prompt, add InputPilot under Privacy & Security → Input Monitoring with the + button.") + } + } - Button("Quit InputPilot") { - NSApp.terminate(nil) - } - .keyboardShortcut(.defaultAction) - .controlSize(.large) + /// macOS prompts only once. After a denial, "Grant Access" does nothing and + /// InputPilot is already listed, just switched off. + private var deniedActions: some View { + VStack(spacing: 10) { + Button("Open System Settings") { + appState.openInputMonitoringSettings() } - .frame(maxWidth: .infinity) - } else { - VStack(spacing: 10) { - Label("You're all set.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .keyboardShortcut(.defaultAction) + .controlSize(.large) - Text("Press a key on each keyboard you want to configure, then open Settings from the menu bar to assign an input source.") - .font(.callout) - .foregroundStyle(.secondary) + caption("Input Monitoring is switched off for InputPilot. Switch it on under Privacy & Security → Input Monitoring, then come back here.") + } + } + + private var relaunchActions: some View { + VStack(spacing: 10) { + Label("Permission granted.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + + caption("macOS applies Input Monitoring when InputPilot starts again.") + + Button("Relaunch InputPilot") { + appState.relaunch() + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + } + } + + private var finalActions: some View { + VStack(spacing: 12) { + Label("You're all set.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + + caption("Type a few keys on each keyboard you use, then give each one an input source in Settings.") + + Toggle("Open InputPilot at login", isOn: $openAtLogin) + + if let error = loginItem.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Open Settings…") { + finish(openingSettings: true) + } Button("Done") { - dismiss() + finish(openingSettings: false) } .keyboardShortcut(.defaultAction) - .controlSize(.large) } - .frame(maxWidth: .infinity) + .controlSize(.large) + } + } + + private func caption(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private func finish(openingSettings: Bool) { + loginItem.setEnabled(openAtLogin) + // Stay open and show what went wrong rather than drop the choice silently. + guard loginItem.lastError == nil else { + return + } + + appState.completeOnboarding() + if openingSettings { + openWindow(id: "settings") + NSApp.activate(ignoringOtherApps: true) } + dismiss() } } diff --git a/InputPilotUITests/InputPilotUITestsLaunchTests.swift b/InputPilotUITests/InputPilotUITestsLaunchTests.swift index 70ce3a6..44b12fb 100644 --- a/InputPilotUITests/InputPilotUITestsLaunchTests.swift +++ b/InputPilotUITests/InputPilotUITestsLaunchTests.swift @@ -1,32 +1,24 @@ -// -// InputPilotUITestsLaunchTests.swift -// InputPilotUITests -// -// Created by Luca on 09.02.26. -// - import XCTest final class InputPilotUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - override func setUpWithError() throws { continueAfterFailure = false } + /// Regression test for 1.0.0, where a new user got no window at all and so + /// no way to grant Input Monitoring. With onboarding not yet completed the + /// welcome window must open by itself - whether or not this build already + /// has the permission (CI's ad-hoc build never does). @MainActor - func testLaunch() throws { + func testWelcomeWindowOpensOnFirstLaunch() throws { let app = XCUIApplication() + app.launchArguments = ["-onboardingCompleted", "NO"] app.launch() - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app + XCTAssertTrue(app.windows["Welcome to InputPilot"].waitForExistence(timeout: 10)) let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" + attachment.name = "Welcome window" attachment.lifetime = .keepAlways add(attachment) } From a4545aa0ca5a9bcdaf0372728de5e6fdfdfc4e95 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 16:50:27 +0200 Subject: [PATCH 09/12] docs: describe 1.1.0 and bump the version to 1.1.0 (build 3) - README: the manual-change setting, Open at login, same-model keyboards, the new Settings layout, Relaunch, and Karabiner-Elements. - Site: three new FAQ entries (login, manual changes, Karabiner) in both the page and its structured data, the Relaunch answer, the version, and the re-measured size (about 3,900 lines of Swift). - SECURITY.md: the storage list includes the two new settings and says that Open at login lives with macOS, not InputPilot. - CHANGELOG: the 1.1.0 entry, folding in the unreleased fixes. --- CHANGELOG.md | 22 +++++++++++++-- InputPilot.xcodeproj/project.pbxproj | 24 ++++++++-------- README.md | 31 ++++++++++++-------- SECURITY.md | 4 +-- docs/index.html | 42 ++++++++++++++++++++++++---- 5 files changed, 90 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a111fdb..bbcea0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,30 @@ All notable changes to InputPilot are documented here. This project follows [Semantic Versioning](https://semver.org). -## [Unreleased] +## [1.1.0] — 2026-10-11 + +### Added + +- **Your own layout choices are respected.** A layout picked by hand — ⌃Space, the input menu, Undo, an input method's mode key — used to be reverted on the next key press. InputPilot now keeps it until you type on another keyboard. Settings → Switching offers three alternatives: keep it until you switch back yourself, switch back on the next key press (the old behaviour), or turn automatic switching off. +- **Open at login.** Offered, pre-checked, at the end of the first run, and as a switch in Settings. Without it, InputPilot stopped working after every restart. +- **A first run that ends somewhere.** The welcome window stays until its last step is done. After the grant macOS needs a fresh start, so the window offers **Relaunch InputPilot** instead of just quitting; after a denial its main button opens System Settings, since macOS only prompts once. +- **"Use … for …" in the menu** maps the keyboard you are typing on to the current input source in one click. +- **Check for updates automatically** can now be switched off in Settings. + +### Changed + +- The menu leads with what you are typing on and what it maps to. Hardware details and the debug log moved into a Diagnostics submenu, and Settings opens with your keyboards. +- Status and device details use plain words ("Input Monitoring: not granted yet", "USB · Vendor 1452 · Product 832") instead of jargon. +- The Settings "Pause" switch, which paused for a year, is now a plain "Automatic switching" switch. Pausing for 15 minutes or an hour stays in the menu. ### Fixed +- **Two keyboards of the same model** shared one mapping. Each now gets its own, and one of them can still change ports without becoming a third entry. +- **Karabiner-Elements** holding a keyboard stopped keyboard detection outright, with advice to relaunch that could never help. InputPilot now keeps running instead of giving up, and explains how to release that keyboard in Karabiner-Elements. +- Emoji & Symbols, Dictation and Press and Hold were offered as mapping targets; mapped, they opened a panel on every switch. Only keyboard input sources are offered now. +- Windows opened from the menu could appear behind the active app, unreachable without a Dock icon. They now come to the front. - A lone F6 press was treated as a modifier key and never triggered a switch. HID usage 0x3F is F6; it had been mistaken for Fn. -- While the keyboard monitor could not start, the same error was added to the debug log every second until it crowded out everything else. Each distinct failure is now logged once. +- While keyboard detection could not start, the same error was added to the debug log every second until it crowded out everything else. Each distinct failure is now logged once. - Public claims made precisely true: the app reads a key's usage code only to detect modifiers; update checks run automatically once a day; and the Tab in ⌘-Tab counts as typing. ## [1.0.1] — 2026-08-18 diff --git a/InputPilot.xcodeproj/project.pbxproj b/InputPilot.xcodeproj/project.pbxproj index 3d520bf..d99ad58 100644 --- a/InputPilot.xcodeproj/project.pbxproj +++ b/InputPilot.xcodeproj/project.pbxproj @@ -405,7 +405,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -420,7 +420,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilot; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -455,7 +455,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilot; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -473,11 +473,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilotTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -494,11 +494,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilotTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -514,10 +514,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilotUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -533,10 +533,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = T5M4XW2T24; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.lucagerlich.InputPilotUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/README.md b/README.md index 1a46e3b..73b3a68 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,14 @@ See [SECURITY.md](SECURITY.md) for the full privacy statement and how to report ## Features -- **Per-keyboard input sources** — map each keyboard to the layout you want. +- **Per-keyboard input sources** — map each keyboard to the layout you want, from Settings or with one click on "Use … for …" in the menu. +- **Respects your own choices** — pick a layout by hand (⌃Space, the input menu, Undo) and InputPilot keeps it until you switch keyboards. Settings offers three alternatives: keep it until you switch back yourself, switch back on the next key press, or turn automatic switching off. +- **Opens at login** — offered, pre-checked, at the end of the first run, and in Settings. - **Fallbacks** — a per-device fallback for when a mapping breaks, and a global fallback for unmapped keyboards. - **Undo** — revert the last automatic switch from the menu. -- **Pause** — silence auto-switching for 15 or 60 minutes when it would get in the way. +- **Pause** — silence auto-switching for 15 minutes or an hour when it would get in the way. - **Conflict detection** — spots mappings that point at an input source you have since removed or disabled, and offers to fix them. -- **Stable device identity** — keyboards are matched on vendor, product, transport and name, so unplugging and replugging (or moving to another USB port) keeps your mapping. +- **Stable device identity** — keyboards are matched on vendor, product, transport and name, so unplugging and replugging (or moving to another USB port) keeps your mapping — and two keyboards of the same model each get their own. - **No flapping** — a 400 ms debounce and a 1.5 s cooldown stop rapid switching, and a lone modifier press (⇧, ⌘, ⌥ or ⌃ on its own) is ignored by default. Any other key — including the Tab in ⌘-Tab — counts as typing on that keyboard. - **Debug log** — an in-app log window you can copy or export when reporting a bug. It contains device and status information only, never typed text. @@ -57,6 +59,7 @@ See [SECURITY.md](SECURITY.md) for the full privacy statement and how to report 1. A HID callback reports which keyboard produced the latest key press. 2. InputPilot resolves the target input source for that device, in order: **device mapping → per-device fallback → global fallback**. If none apply, it does nothing. 3. After the debounce settles, it switches the input source through the Text Input Sources (TIS) API. +4. It remembers the source it applied. If the current source differs at the next switch, someone else chose it, and your manual-change setting decides what happens. Auto-switching only runs when it is enabled and not paused. @@ -64,18 +67,22 @@ Auto-switching only runs when it is enabled and not paused. | Section | What it does | |---|---| -| Auto-Switch | Pause state, modifier-only opt-in, last action, last error | -| Input Monitoring | Permission status and the currently active keyboard | -| Input Sources | The current input source and its identifier | -| Fallbacks | Global fallback, plus a shortcut to adopt the current source | -| Conflicts | Mappings pointing at missing or disabled sources, with a fix action | -| Keyboard Device Mappings | Per-keyboard input source, per-device fallback, forget device | +| Needs Attention | Only shown when a mapping points at a removed or disabled source, with a fix action | +| Keyboards | Per-keyboard input source, per-keyboard fallback, forget a keyboard | +| Switching | Automatic switching on/off, Open at login, what happens when you change the source yourself, modifier-only switching | +| Fallbacks | The input source for every keyboard without one of its own | +| Updates | Sparkle's automatic daily check on/off, and Check Now | +| Status | Permission, keyboard detection, current keyboard and source, last action and error | ## Troubleshooting -**Nothing switches.** Check the menu bar icon: it shows whether the permission is granted and whether the monitor is running. Auto-switch must be enabled and not paused, and the target input source must still be enabled in System Settings → Keyboard → Input Sources. +**Nothing switches.** Open the menu bar menu: it shows which keyboard you are typing on, what it maps to, and whether InputPilot is keeping a layout you chose yourself. Diagnostics shows the permission and keyboard detection. Auto-switch must be enabled and not paused, and the target input source must still be enabled in System Settings → Keyboard → Input Sources. -**Permission granted but nothing happens.** macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again. +**Permission granted but nothing happens.** macOS applies Input Monitoring only to a fresh start. Click **Relaunch InputPilot** in the welcome window or in Settings → Status. + +**My layout changes back / does not change back.** That is the manual-change setting in Settings → Switching. By default a layout you pick yourself is kept until you type on another keyboard. macOS's own "Automatically switch to a document's input source" also counts as a change you made. + +**Karabiner-Elements.** For every keyboard whose events Karabiner-Elements modifies, it takes exclusive access and re-sends the keys from its own virtual keyboard, so InputPilot cannot see which physical keyboard you used. In Karabiner-Elements → Devices, turn off "Modify events" for each keyboard InputPilot should tell apart, then relaunch InputPilot. **A keyboard is not detected.** Press a key on it — mouse movement is not enough. InputPilot only learns about a keyboard once it produces a key event. @@ -85,7 +92,7 @@ Auto-switching only runs when it is enabled and not paused. ## What it stores -Settings live in `UserDefaults`: the auto-switch and modifier-only flags, the pause timestamp, the global fallback, your device mappings, and a schema migration flag. The last switch and the debug log are in memory only and disappear when you quit. If stored mappings ever fail to decode, the raw data is preserved under a `.corrupted` key rather than being overwritten. +Settings live in `UserDefaults`: the auto-switch and modifier-only flags, the manual-change setting, the pause timestamp, the global fallback, your device mappings, whether the first run is finished, and a schema migration flag. Open at login is registered with macOS itself (System Settings → General → Login Items). The last switch and the debug log are in memory only and disappear when you quit. If stored mappings ever fail to decode, the raw data is preserved under a `.corrupted` key rather than being overwritten. ## Requirements diff --git a/SECURITY.md b/SECURITY.md index 9223401..cd1b8e9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,11 +16,11 @@ Nothing else leaves that callback. The key's usage code is read inside it, solel Settings and keyboard mappings live in `UserDefaults` (`com.lucagerlich.InputPilot`): - for each keyboard you have mapped or given a fallback: its identifiers (vendor ID, product ID, transport, product name, location) and the input source mapped to it -- your fallbacks, the auto-switch and modifier-only settings, and the pause timestamp +- your fallbacks, the auto-switch, modifier-only and manual-change settings, the pause timestamp, and whether the first run is finished - a schema migration flag - only if stored mappings ever fail to decode: a `.corrupted` backup of that data, so it is not lost -Sparkle keeps its own bookkeeping in the same place: when it last checked for updates, whether the app has launched before, and a random number it uses for staged rollouts. No key or typing data is stored anywhere. +Sparkle keeps its own bookkeeping in the same place: when it last checked for updates, whether the app has launched before, and a random number it uses for staged rollouts. Open at login, if you turn it on, is registered with macOS itself, not stored by InputPilot. No key or typing data is stored anywhere. The debug log is an in-memory ring buffer of at most 200 entries, holding device identifiers, permission state, input source names, and errors. It is never written to disk unless you explicitly export it, and only reaches the clipboard if you copy it. As each entry is recorded, a filter replaces any occurrence of `keycode`, `typedtext`, `characters`, `appcontents` and `keystroke` with `[redacted]`. That filter is a tripwire, not the guarantee — the guarantee is that no code path sends key data to the log in the first place. diff --git a/docs/index.html b/docs/index.html index 258d88e..c5d6e3f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -185,7 +185,7 @@ "applicationSubCategory": "Keyboard layout switcher", "operatingSystem": "macOS", "softwareRequirements": "macOS 13.0 or later", - "softwareVersion": "1.0.1", + "softwareVersion": "1.1.0", "datePublished": "2026-08-18", "downloadUrl": "https://github.com/LucaGerlich/InputPilot/releases/latest", "releaseNotes": "https://github.com/LucaGerlich/InputPilot/blob/main/CHANGELOG.md", @@ -198,6 +198,8 @@ "Recognises keyboards again after unplugging, reconnecting or changing port", "Works with USB and Bluetooth keyboards", "Per-device and global fallback input sources", + "Keeps an input source you pick yourself until you switch keyboards, or as configured", + "Opens at login", "Debounce and cooldown prevent layout flapping", "Reads the source device and a modifier flag; the key's usage code is checked only to detect modifiers and is never stored, logged or transmitted", "No telemetry, no analytics, no crash reporting" @@ -272,7 +274,7 @@ "name": "I granted permission but nothing happens.", "acceptedAnswer": { "@type": "Answer", - "text": "macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again." + "text": "macOS applies Input Monitoring only to a fresh start. Click “Relaunch InputPilot” in the welcome window, or in Settings → Status." } }, { @@ -283,6 +285,30 @@ "text": "Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting." } }, + { + "@type": "Question", + "name": "Does it start at login?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Yes, if you let it. The last step of the first run offers “Open InputPilot at login”, already checked, and Settings has the same switch. It uses macOS’s own login items, so you can also manage it in System Settings → General → Login Items." + } + }, + { + "@type": "Question", + "name": "What happens when I change the layout myself?", + "acceptedAnswer": { + "@type": "Answer", + "text": "By default InputPilot keeps your choice until you type on another keyboard — handy for a word in another language. In Settings you can instead keep it until you switch back yourself, switch back on the next key press, or turn automatic switching off. A change made by macOS’s “Automatically switch to a document’s input source” counts as yours too." + } + }, + { + "@type": "Question", + "name": "Does it work with Karabiner-Elements?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Only for keyboards it leaves alone. For every keyboard whose events Karabiner-Elements modifies, it takes exclusive access and re-sends the keys from its own virtual keyboard, so InputPilot cannot see which physical keyboard you used. In Karabiner-Elements → Devices, turn off “Modify events” for each keyboard InputPilot should tell apart, then relaunch InputPilot." + } + }, { "@type": "Question", "name": "I use a German external keyboard with a US MacBook. Does this fix it?", @@ -423,7 +449,7 @@

The layout follows the keyboard. Not the app. Not the wind Download for macOS Documentation -

macOS 13+ · signed & notarized · Apache-2.0 · v1.0.1 · brew install --cask lucagerlich/tap/inputpilot

+

macOS 13+ · signed & notarized · Apache-2.0 · v1.1.0 · brew install --cask lucagerlich/tap/inputpilot

@@ -508,7 +534,7 @@

About that permission

  • The HID callback takes two things from each event: the device that sent it, and whether the key was a modifier. Nothing else.
  • The key's usage code is read only to decide whether it was a modifier, then discarded. It is never stored, logged or transmitted, and no character or text is ever derived from it.
  • The only network traffic is Sparkle's update check — automatically once a day, and when you ask — and the download of any update you accept. No telemetry, no analytics, no crash reporter.
  • -
  • The source is public: about 3,200 lines of Swift, plus Sparkle, the open-source updater it ships with. The keyboard handling is 15 lines and reads in a minute.
  • +
  • The source is public: about 3,900 lines of Swift, plus Sparkle, the open-source updater it ships with. The keyboard handling is 15 lines and reads in a minute.
  • Full privacy statement →

    @@ -522,9 +548,15 @@

    Questions

    InputPilot does not appear under Input Monitoring.

    Some Macs do not populate that list automatically. Add it by hand: System Settings → Privacy & Security → Input Monitoring → + → pick InputPilot in Applications, then switch it on.

    I granted permission but nothing happens. -

    macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again.

    +

    macOS applies Input Monitoring only to a fresh start. Click “Relaunch InputPilot” in the welcome window, or in Settings → Status.

    Does it work with Bluetooth keyboards?

    Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting.

    +
    Does it start at login? +

    Yes, if you let it. The last step of the first run offers “Open InputPilot at login”, already checked, and Settings has the same switch. It uses macOS’s own login items, so you can also manage it in System Settings → General → Login Items.

    +
    What happens when I change the layout myself? +

    By default InputPilot keeps your choice until you type on another keyboard — handy for a word in another language. In Settings you can instead keep it until you switch back yourself, switch back on the next key press, or turn automatic switching off. A change made by macOS’s “Automatically switch to a document’s input source” counts as yours too.

    +
    Does it work with Karabiner-Elements? +

    Only for keyboards it leaves alone. For every keyboard whose events Karabiner-Elements modifies, it takes exclusive access and re-sends the keys from its own virtual keyboard, so InputPilot cannot see which physical keyboard you used. In Karabiner-Elements → Devices, turn off “Modify events” for each keyboard InputPilot should tell apart, then relaunch InputPilot.

    I use a German external keyboard with a US MacBook. Does this fix it?

    That is the case it was written for. Map the external board to German and the built-in one to U.S. once. From then on the layout follows whichever one you type on. It switches about 0.4 seconds after you start typing on the other keyboard, so the first character or two can still land in the old layout — but there is no shortcut, no menu, nothing to remember.

    How is this different from Keychange, autokbisw, OctoKeys or Input Source Pro? From 5389ba1e35ddc7c5af5b67ddee7fdd61e1f85575 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:34:36 +0200 Subject: [PATCH 10/12] fix: tighten edge cases found in review - A failed login-item registration left a stale error that kept the welcome window's Done from closing it, even after unchecking the box. - Changing the manual-change policy re-evaluated and switched away from the layout the user was holding; it now applies from the next key. - The hold note is hidden while automatic switching is off, and cleared when the keyboard disconnects. - relaunch() waits at most 10 s for the old process, then lets Launch Services settle, instead of waiting without bound. --- InputPilot/App/AppState.swift | 15 ++++-- InputPilot/Services/LoginItemController.swift | 4 +- InputPilot/UI/MenuBarMenuView.swift | 2 +- .../AppStateManualChangeTests.swift | 50 +++++++++++++++++++ .../LoginItemControllerTests.swift | 14 ++++++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 2afb99b..f0036aa 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -236,7 +236,9 @@ final class AppState: ObservableObject { // Paths and PIDs are passed as arguments, never spliced into the script. process.arguments = [ "-c", - "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.1; done; /usr/bin/open \"$0\"", + // Waits at most 10 s for this process to exit, then lets Launch + // Services settle before opening the app again. + "i=0; while /bin/kill -0 \"$1\" 2>/dev/null && [ $i -lt 100 ]; do /bin/sleep 0.1; i=$((i+1)); done; /bin/sleep 0.3; /usr/bin/open \"$0\"", Bundle.main.bundlePath, String(ProcessInfo.processInfo.processIdentifier) ] @@ -375,7 +377,11 @@ final class AppState: ObservableObject { manualChangePolicy = policy appSettingsStore.set(policy.rawValue, forKey: Self.manualChangePolicyDefaultsKey) recordAction("When you change the input source yourself: \(policy.title.lowercased()).") - reevaluateActiveDevice() + // Applies from the next key press. Re-evaluating here would switch away + // from the layout the user is holding the moment they touch the picker. + if manualHoldDescription != nil { + manualHoldDescription = nil + } } func pause(minutes: Int) { @@ -736,6 +742,9 @@ final class AppState: ObservableObject { if activeKeyboardDevice != nil { activeKeyboardDevice = nil } + if manualHoldDescription != nil { + manualHoldDescription = nil + } if status.activeKeyboard != "none" { status.activeKeyboard = "none" @@ -946,11 +955,11 @@ final class AppState: ObservableObject { /// Settings changes are deliberate: they end any manual hold and apply to /// the keyboard in use straight away. private func reevaluateActiveDevice() { + endManualHold(appliedSourceId: nil) guard let activeDeviceKey else { return } - endManualHold(appliedSourceId: nil) evaluateSwitch(for: activeDeviceKey, eventKind: .deviceStabilized) } diff --git a/InputPilot/Services/LoginItemController.swift b/InputPilot/Services/LoginItemController.swift index 44e522c..517aee9 100644 --- a/InputPilot/Services/LoginItemController.swift +++ b/InputPilot/Services/LoginItemController.swift @@ -61,6 +61,9 @@ final class LoginItemController: ObservableObject { } func setEnabled(_ enabled: Bool) { + // A new choice supersedes any earlier failure, even when it needs no + // work - otherwise a stale error keeps blocking the welcome window. + lastError = nil refresh() guard enabled != isEnabled else { return @@ -72,7 +75,6 @@ final class LoginItemController: ObservableObject { } else { try service.unregister() } - lastError = nil } catch { lastError = enabled ? "Could not add InputPilot to your login items (\(error.localizedDescription)). You can add it in System Settings → General → Login Items." diff --git a/InputPilot/UI/MenuBarMenuView.swift b/InputPilot/UI/MenuBarMenuView.swift index 81c5103..d3b809e 100644 --- a/InputPilot/UI/MenuBarMenuView.swift +++ b/InputPilot/UI/MenuBarMenuView.swift @@ -91,7 +91,7 @@ struct MenuBarMenuView: View { if let keyboard = appState.activeKeyboardTitle { Text("Typing on \(keyboard) → \(appState.activeKeyboardTargetName ?? "not mapped")") - if let hold = appState.manualHoldDescription { + if appState.isAutoSwitchActive, let hold = appState.manualHoldDescription { Text(hold) } diff --git a/InputPilotTests/AppStateManualChangeTests.swift b/InputPilotTests/AppStateManualChangeTests.swift index 0a59cc5..73ca72a 100644 --- a/InputPilotTests/AppStateManualChangeTests.swift +++ b/InputPilotTests/AppStateManualChangeTests.swift @@ -169,6 +169,56 @@ struct AppStateManualChangeTests { #expect(harness.appState.manualHoldDescription == nil) } + @Test + func changingThePolicyDoesNotSwitchByItself() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + + // Touching the picker must not undo the layout the user chose. + harness.appState.setManualChangePolicy(.keepUntilSwitchBack) + await settle() + #expect(harness.sources.selectCalls == [us]) + + // The new policy applies from the next key press on. + harness.appState.setManualChangePolicy(.revertOnNextKeyPress) + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls.count == 2 } + #expect(harness.sources.selectCalls == [us, us]) + } + + @Test + func aLoneModifierOnTheOtherKeyboardDoesNotEndTheHold() async throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let clock = ImmediateClock() + let harness = makeHarness(defaults: defaults, clock: clock) + + harness.monitor.emit(macBook) + await waitFor { harness.sources.selectCalls == [us] } + clock.advance(by: .seconds(2)) + harness.sources.currentInputSourceIdValue = french + harness.monitor.emit(macBook) + await waitFor { harness.appState.manualHoldDescription != nil } + + // Reaching for ⌘ on the other keyboard is not typing on it. + harness.monitor.emit(keychron, kind: .keyDown(isModifier: true)) + await waitFor { harness.appState.activeKeyboardDevice == keychron } + harness.monitor.emit(macBook) + await settle() + + #expect(harness.sources.selectCalls == [us]) + #expect(harness.appState.manualHoldDescription != nil) + } + @Test func policyPersistsAcrossLaunches() throws { let (defaults, suiteName) = try makeDefaults() diff --git a/InputPilotTests/LoginItemControllerTests.swift b/InputPilotTests/LoginItemControllerTests.swift index 4ebd03f..4b56285 100644 --- a/InputPilotTests/LoginItemControllerTests.swift +++ b/InputPilotTests/LoginItemControllerTests.swift @@ -29,6 +29,20 @@ struct LoginItemControllerTests { #expect(controller.lastError != nil) } + @Test + func uncheckingAfterAFailedRegistrationClearsTheError() { + let service = MockLoginItemService() + service.registerError = LoginItemTestError.denied + let controller = LoginItemController(service: service) + controller.setEnabled(true) + #expect(controller.lastError != nil) + + // Otherwise the welcome window's Done stays blocked by a stale error. + controller.setEnabled(false) + + #expect(controller.lastError == nil) + } + @Test func pendingApprovalIsSurfaced() { let service = MockLoginItemService() From bd58881b6962550486916b6977820a18e9290e08 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:34:36 +0200 Subject: [PATCH 11/12] docs: narrow the same-model and network claims to what is verified - Same-model keyboards are told apart by port: say USB keyboards on different ports, and that swapping two identical keyboards swaps their mappings. - The updates footer no longer says "nothing else is sent"; GitHub does see an IP address and user agent. It says nothing about keyboards or typing is sent. - Note that existing users see the welcome window once after updating. - The input-source smoke test now fails on an empty list instead of passing vacuously, and a resolver comment states the real cost. --- CHANGELOG.md | 4 ++-- InputPilot/Services/DeviceKeyResolver.swift | 4 +++- InputPilot/UI/SettingsView.swift | 2 +- InputPilotTests/InputSourceServiceTests.swift | 4 ++++ README.md | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbcea0b..1916d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to InputPilot are documented here. This project follows [Sem - **Your own layout choices are respected.** A layout picked by hand — ⌃Space, the input menu, Undo, an input method's mode key — used to be reverted on the next key press. InputPilot now keeps it until you type on another keyboard. Settings → Switching offers three alternatives: keep it until you switch back yourself, switch back on the next key press (the old behaviour), or turn automatic switching off. - **Open at login.** Offered, pre-checked, at the end of the first run, and as a switch in Settings. Without it, InputPilot stopped working after every restart. -- **A first run that ends somewhere.** The welcome window stays until its last step is done. After the grant macOS needs a fresh start, so the window offers **Relaunch InputPilot** instead of just quitting; after a denial its main button opens System Settings, since macOS only prompts once. +- **A first run that ends somewhere.** The welcome window stays until its last step is done. After the grant macOS needs a fresh start, so the window offers **Relaunch InputPilot** instead of just quitting; after a denial its main button opens System Settings, since macOS only prompts once. Existing users see the welcome window once after updating, so they can turn on Open at login. - **"Use … for …" in the menu** maps the keyboard you are typing on to the current input source in one click. - **Check for updates automatically** can now be switched off in Settings. @@ -20,7 +20,7 @@ All notable changes to InputPilot are documented here. This project follows [Sem ### Fixed -- **Two keyboards of the same model** shared one mapping. Each now gets its own, and one of them can still change ports without becoming a third entry. +- **Two USB keyboards of the same model** on different ports shared one mapping. Each now gets its own, and moving one of them to another port at a time keeps its mapping. Keyboards are told apart by port, so swapping two identical keyboards between ports swaps their mappings. - **Karabiner-Elements** holding a keyboard stopped keyboard detection outright, with advice to relaunch that could never help. InputPilot now keeps running instead of giving up, and explains how to release that keyboard in Karabiner-Elements. - Emoji & Symbols, Dictation and Press and Hold were offered as mapping targets; mapped, they opened a panel on every switch. Only keyboard input sources are offered now. - Windows opened from the menu could appear behind the active app, unreachable without a Dock icon. They now come to the front. diff --git a/InputPilot/Services/DeviceKeyResolver.swift b/InputPilot/Services/DeviceKeyResolver.swift index f0c1031..82c6dd6 100644 --- a/InputPilot/Services/DeviceKeyResolver.swift +++ b/InputPilot/Services/DeviceKeyResolver.swift @@ -87,7 +87,9 @@ enum DeviceKeyResolver { // recorded location no longer holds a keyboard of that model, that is // the keyboard that moved. If every recorded location is still occupied, // this is another keyboard of the same model and gets its own key. - // Only evaluated here, off the hot path of an already-known keyboard. + // Never reached for a keyboard at its known location. A keyboard that + // moved and folded into its old key comes here on every key press; the + // cost is a few HID property reads per attached keyboard. let connected = connectedDevices() let vacatedKeys = primaryMatches.filter { key in !connected.contains { device in diff --git a/InputPilot/UI/SettingsView.swift b/InputPilot/UI/SettingsView.swift index d842984..7f13c18 100644 --- a/InputPilot/UI/SettingsView.swift +++ b/InputPilot/UI/SettingsView.swift @@ -184,7 +184,7 @@ struct SettingsView: View { } header: { Text("Updates") } footer: { - Text("Asks GitHub about once a day whether a newer version exists. Nothing else is sent.") + Text("Asks GitHub about once a day whether a newer version exists. Nothing about your keyboards or typing is sent.") } } diff --git a/InputPilotTests/InputSourceServiceTests.swift b/InputPilotTests/InputSourceServiceTests.swift index 8faa506..6437593 100644 --- a/InputPilotTests/InputSourceServiceTests.swift +++ b/InputPilotTests/InputSourceServiceTests.swift @@ -18,6 +18,10 @@ struct InputSourceServiceTests { let enabledIds = Set(service.listEnabledInputSources().map(\.id)) let allIds = Set(service.listAllInputSources().map(\.id)) + // Every Mac has at least one keyboard layout enabled; an empty list + // would make the checks below pass for the wrong reason. + #expect(!enabledIds.isEmpty) + #expect(enabledIds.isSubset(of: allIds)) #expect(enabledIds.isDisjoint(with: paletteSourceIds)) #expect(allIds.isDisjoint(with: paletteSourceIds)) #expect(paletteSourceIds.allSatisfy { !service.existsEnabledInputSource(id: $0) }) diff --git a/README.md b/README.md index 73b3a68..a166419 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ See [SECURITY.md](SECURITY.md) for the full privacy statement and how to report - **Undo** — revert the last automatic switch from the menu. - **Pause** — silence auto-switching for 15 minutes or an hour when it would get in the way. - **Conflict detection** — spots mappings that point at an input source you have since removed or disabled, and offers to fix them. -- **Stable device identity** — keyboards are matched on vendor, product, transport and name, so unplugging and replugging (or moving to another USB port) keeps your mapping — and two keyboards of the same model each get their own. +- **Stable device identity** — keyboards are matched on vendor, product, transport and name, so unplugging and replugging (or moving to another USB port) keeps your mapping — and two USB keyboards of the same model, on different ports, each get their own. - **No flapping** — a 400 ms debounce and a 1.5 s cooldown stop rapid switching, and a lone modifier press (⇧, ⌘, ⌥ or ⌃ on its own) is ignored by default. Any other key — including the Tab in ⌘-Tab — counts as typing on that keyboard. - **Debug log** — an in-app log window you can copy or export when reporting a bug. It contains device and status information only, never typed text. From 66dd2eccdeb7d5ee82b20e739806a702aa76548a Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Sun, 11 Oct 2026 18:41:37 +0200 Subject: [PATCH 12/12] fix: run the new test suites on the main actor The app target uses SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, and every existing suite is @MainActor. Three new suites were not, so Swift Testing ran them on background threads, where they used main-actor-isolated conformances (KeyboardDeviceKey's Equatable) and the Text Input Sources API. Swift's runtime isolation checks then terminated the test host on CI - locally they passed only by scheduling luck. The compiler's warnings came from macro-expanded files and slipped past a too-narrow filter. Also build the default login-item service inside the main-actor init rather than as a default argument, which is evaluated outside it. --- InputPilot/Services/LoginItemController.swift | 5 ++++- InputPilotTests/DeviceKeyResolverTests.swift | 1 + InputPilotTests/HIDKeyboardMonitorTests.swift | 1 + InputPilotTests/InputSourceServiceTests.swift | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/InputPilot/Services/LoginItemController.swift b/InputPilot/Services/LoginItemController.swift index 517aee9..d2efea1 100644 --- a/InputPilot/Services/LoginItemController.swift +++ b/InputPilot/Services/LoginItemController.swift @@ -51,7 +51,10 @@ final class LoginItemController: ObservableObject { @Published private(set) var lastError: String? private let service: LoginItemServicing - init(service: LoginItemServicing = MainAppLoginItemService()) { + /// Nil means the app itself; a default argument would be built outside + /// the main actor. + init(service: LoginItemServicing? = nil) { + let service = service ?? MainAppLoginItemService() self.service = service self.status = service.status } diff --git a/InputPilotTests/DeviceKeyResolverTests.swift b/InputPilotTests/DeviceKeyResolverTests.swift index 7902157..375f9af 100644 --- a/InputPilotTests/DeviceKeyResolverTests.swift +++ b/InputPilotTests/DeviceKeyResolverTests.swift @@ -1,6 +1,7 @@ import Testing @testable import InputPilot +@MainActor struct DeviceKeyResolverTests { @Test func secondKeyboardOfSameModelGetsItsOwnKey() { diff --git a/InputPilotTests/HIDKeyboardMonitorTests.swift b/InputPilotTests/HIDKeyboardMonitorTests.swift index 10d4b7f..745ea29 100644 --- a/InputPilotTests/HIDKeyboardMonitorTests.swift +++ b/InputPilotTests/HIDKeyboardMonitorTests.swift @@ -2,6 +2,7 @@ import IOKit.hid import Testing @testable import InputPilot +@MainActor struct HIDKeyboardMonitorTests { @Test(arguments: Array(UInt32(kHIDUsage_KeyboardLeftControl)...UInt32(kHIDUsage_KeyboardRightGUI))) func modifierUsagesAreModifiers(usage: UInt32) { diff --git a/InputPilotTests/InputSourceServiceTests.swift b/InputPilotTests/InputSourceServiceTests.swift index 6437593..80081c9 100644 --- a/InputPilotTests/InputSourceServiceTests.swift +++ b/InputPilotTests/InputSourceServiceTests.swift @@ -2,6 +2,7 @@ import Testing @testable import InputPilot /// Runs against the real Text Input Sources API of the machine under test. +@MainActor struct InputSourceServiceTests { // Selectable, enabled on most Macs, but not keyboard layouts: mapping a // keyboard to one opened the emoji viewer or dictation on every switch.