Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions InputPilot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
101 changes: 101 additions & 0 deletions InputPilot/App/AppState+Display.swift
Original file line number Diff line number Diff line change
@@ -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))"
}
}
Loading
Loading