diff --git a/CHANGELOG.md b/CHANGELOG.md index a111fdb..1916d10 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. 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. + +### 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 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. - 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/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 5e2ec7b..f0036aa 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -13,6 +13,8 @@ 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 onboardingCompletedDefaultsKey = "onboardingCompleted" private static let inputSourcePollIntervalSeconds: TimeInterval = 6 @Published private(set) var status = InputStatusSnapshot.placeholder @@ -28,6 +30,12 @@ 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 hasCompletedOnboarding: Bool @Published private(set) var lastAction: SwitchAction? @Published private(set) var previousInputSourceIdBeforeLastSwitch: String? @Published private(set) var debugLogEntries: [LogEntry] = [] @@ -43,12 +51,18 @@ 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 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(set) var activeDeviceKey: KeyboardDeviceKey? + private var lastSwitchingDeviceKey: KeyboardDeviceKey? #if DEBUG private let logger = Logger(subsystem: "com.lucagerlich.InputPilot", category: "AppState") @@ -82,11 +96,18 @@ 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.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() @@ -143,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 { @@ -199,6 +208,51 @@ 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", + // 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) + ] + + 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: @@ -218,10 +272,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." } @@ -290,10 +345,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,8 +364,23 @@ 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()).") + // 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 } } @@ -334,8 +405,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() } } @@ -378,6 +449,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) } @@ -401,9 +483,7 @@ final class AppState: ObservableObject { recordAction("Global fallback cleared.") } - if let activeKeyboardDevice { - evaluateSwitch(for: KeyboardDeviceKey(device: activeKeyboardDevice), eventKind: .deviceStabilized) - } + reevaluateActiveDevice() } func useCurrentInputSourceAsGlobalFallback() { @@ -436,7 +516,7 @@ final class AppState: ObservableObject { if let activeKeyboardDevice, isCurrentActiveDeviceRepresented(by: deviceKey, activeKeyboardDevice: activeKeyboardDevice) { - evaluateSwitch(for: deviceKey, eventKind: .deviceStabilized) + reevaluateActiveDevice() } } @@ -458,7 +538,7 @@ final class AppState: ObservableObject { if let activeKeyboardDevice, isCurrentActiveDeviceRepresented(by: deviceKey, activeKeyboardDevice: activeKeyboardDevice) { - evaluateSwitch(for: deviceKey, eventKind: .deviceStabilized) + reevaluateActiveDevice() } } @@ -475,56 +555,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) @@ -676,7 +710,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 { @@ -690,6 +724,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 { @@ -701,9 +738,13 @@ final class AppState: ObservableObject { private func clearActiveKeyboardDevice() { switchController.reset() + activeDeviceKey = nil if activeKeyboardDevice != nil { activeKeyboardDevice = nil } + if manualHoldDescription != nil { + manualHoldDescription = nil + } if status.activeKeyboard != "none" { status.activeKeyboard = "none" @@ -724,6 +765,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,14 +790,27 @@ 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) } private func registerDetectedDevice(_ device: ActiveKeyboardDevice) -> KeyboardDeviceKey { let observedDeviceKey = KeyboardDeviceKey(device: device) - let canonicalDeviceKey = canonicalDeviceKey( + let canonicalDeviceKey = DeviceKeyResolver.canonicalKey( for: observedDeviceKey, - among: knownDeviceKeys + among: knownDeviceKeys, + connectedDevices: { [hidKeyboardMonitor] in hidKeyboardMonitor.connectedDevices() } ) detectedDevicesByKey[canonicalDeviceKey] = device @@ -769,12 +824,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 @@ -817,6 +879,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) @@ -828,6 +895,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, @@ -841,6 +912,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() { + endManualHold(appliedSourceId: nil) + guard let activeDeviceKey else { + return + } + + evaluateSwitch(for: activeDeviceKey, eventKind: .deviceStabilized) + } + private func sortKnownDevices() { knownDeviceKeys.sort { lhs, rhs in let leftName = deviceTitle(for: lhs) @@ -858,7 +980,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 { @@ -867,21 +989,17 @@ final class AppState: ObservableObject { ?? id } - private func resolvedTargetInputSourceId(for deviceKey: KeyboardDeviceKey) -> String? { + 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 +1013,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]) { @@ -1085,8 +1127,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/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/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/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/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/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/DeviceKeyResolver.swift b/InputPilot/Services/DeviceKeyResolver.swift new file mode 100644 index 0000000..82c6dd6 --- /dev/null +++ b/InputPilot/Services/DeviceKeyResolver.swift @@ -0,0 +1,124 @@ +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], + connectedDevices: () -> [ActiveKeyboardDevice] + ) -> KeyboardDeviceKey { + let primaryMatches = existingKeys.filter { $0.matchesPrimary(of: observedDeviceKey.fingerprint) } + + if let exactMatch = primaryMatches.first(where: { $0 == observedDeviceKey }) { + return exactMatch + } + + 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 + } + + 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. + // 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 + 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. + 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 + } +} diff --git a/InputPilot/Services/HIDKeyboardMonitor.swift b/InputPilot/Services/HIDKeyboardMonitor.swift index e7a6b74..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 } @@ -95,6 +102,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 @@ -204,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/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/InputPilot/Services/LoginItemController.swift b/InputPilot/Services/LoginItemController.swift new file mode 100644 index 0000000..d2efea1 --- /dev/null +++ b/InputPilot/Services/LoginItemController.swift @@ -0,0 +1,100 @@ +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 + + /// 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 + } + + var isEnabled: Bool { + status != .disabled + } + + 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 + } + + do { + if enabled { + try service.register() + } else { + try service.unregister() + } + } 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/InputPilot/Services/ServiceProtocols.swift b/InputPilot/Services/ServiceProtocols.swift index b1c874f..668c9bb 100644 --- a/InputPilot/Services/ServiceProtocols.swift +++ b/InputPilot/Services/ServiceProtocols.swift @@ -12,6 +12,9 @@ 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 allowsPartialOpen: Bool { get set } var isRunning: Bool { get } var lastStartErrorMessage: String? { get } } 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..d3b809e 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 appState.isAutoSwitchActive, 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..7f13c18 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 about your keyboards or typing 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/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/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.. 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.. 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/DeviceKeyResolverTests.swift b/InputPilotTests/DeviceKeyResolverTests.swift new file mode 100644 index 0000000..375f9af --- /dev/null +++ b/InputPilotTests/DeviceKeyResolverTests.swift @@ -0,0 +1,93 @@ +import Testing +@testable import InputPilot + +@MainActor +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/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 new file mode 100644 index 0000000..80081c9 --- /dev/null +++ b/InputPilotTests/InputSourceServiceTests.swift @@ -0,0 +1,30 @@ +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. + 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)) + + // 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/InputPilotTests/LoginItemControllerTests.swift b/InputPilotTests/LoginItemControllerTests.swift new file mode 100644 index 0000000..4b56285 --- /dev/null +++ b/InputPilotTests/LoginItemControllerTests.swift @@ -0,0 +1,97 @@ +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 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() + 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 +} diff --git a/InputPilotTests/ServiceMocks.swift b/InputPilotTests/ServiceMocks.swift index 297ff01..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 } @@ -29,6 +31,11 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { private var onEvent: ((ActiveKeyboardDevice, KeyboardEventKind) -> Void)? 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? @@ -39,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 @@ -51,6 +64,10 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring { isRunning = false } + func connectedDevices() -> [ActiveKeyboardDevice] { + connected + } + func emit(_ device: ActiveKeyboardDevice, kind: KeyboardEventKind = .keyDown(isModifier: false)) { onEvent?(device, kind) } @@ -66,6 +83,9 @@ final class MockInputSourceService: InputSourceServicing { var allSources: [InputSourceInfo] = [] var currentInputSourceIdValue: String? var selectInputSourceResult = true + /// False models TIS reporting success without the source actually + /// changing, a known macOS behaviour with some CJK input methods. + var selectTakesEffect = true private(set) var selectCalls: [String] = [] func listEnabledInputSources() -> [InputSourceInfo] { @@ -91,7 +111,7 @@ final class MockInputSourceService: InputSourceServicing { func selectInputSource(id: String) -> Bool { selectCalls.append(id) - if selectInputSourceResult { + if selectInputSourceResult, selectTakesEffect { currentInputSourceIdValue = id } @@ -181,3 +201,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) + } +} 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) } diff --git a/README.md b/README.md index 1a46e3b..a166419 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 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. @@ -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?