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
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to InputPilot are documented here. This project follows [Semantic Versioning](https://semver.org).

## [Unreleased]

### Fixed

- 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.
- 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

### Fixed
Expand All @@ -24,9 +32,9 @@ First public release.
- Undo for the last automatic switch, and pause for 15 or 60 minutes.
- Conflict detection for mappings pointing at removed or disabled input sources.
- Debounce (400 ms) and cooldown (1.5 s) to prevent flapping between keyboards.
- Optional switching on modifier-only key presses, off by default, so ⌘-Tab does not change the layout.
- Optional switching on modifier-only key presses, off by default, so a lone ⇧ or ⌘ press does not change the layout. Any other key — including the Tab in ⌘-Tab — counts as typing on that keyboard.
- In-app debug log with copy and export, containing device and status information only.
- Automatic updates via Sparkle, over a signed appcast.
- Automatic updates via Sparkle; every update archive is EdDSA-signed and verified before install.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion Config/AppInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>NSInputMonitoringUsageDescription</key>
<string>InputPilot detects which keyboard you are typing on to switch your input language automatically. It never reads or stores what you type.</string>
<string>InputPilot uses key presses only to tell which keyboard you are typing on and whether a modifier key was pressed, so it can switch your input language automatically. It never records, stores or sends what you type.</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/LucaGerlich/InputPilot/main/appcast.xml</string>
<!-- EdDSA public key; the matching private key lives in the release
Expand Down
8 changes: 5 additions & 3 deletions InputPilot/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -677,12 +677,14 @@ final class AppState: ObservableObject {

guard didStart else {
let failureMessage = hidKeyboardMonitor.lastStartErrorMessage ?? "HID monitor 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 {
lastMonitorStartFailure = failureMessage
recordFailure(failureMessage)
debugLog(failureMessage)
logError(category: "hid-monitor", message: failureMessage)
}
debugLog(failureMessage)
logError(category: "hid-monitor", message: failureMessage)
return
}

Expand Down Expand Up @@ -738,7 +740,7 @@ final class AppState: ObservableObject {
)
}

// A lone modifier press (Cmd+Tab, shortcut chords) still updates the
// A lone modifier press (e.g. the Cmd before a shortcut) still updates the
// active-device display above, but only triggers a switch when the user
// opted in. Settings-driven re-evaluations use .deviceStabilized and
// are never gated here.
Expand Down
13 changes: 5 additions & 8 deletions InputPilot/Services/HIDKeyboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring {
let usage = IOHIDElementGetUsage(element)

let device = IOHIDElementGetDevice(element)
onEvent?(makeActiveDevice(from: device), .keyDown(isModifier: isModifierUsage(usage)))
onEvent?(makeActiveDevice(from: device), .keyDown(isModifier: Self.isModifierUsage(usage)))
}

private func handleDeviceRemoval(_ device: IOHIDDevice) {
Expand Down Expand Up @@ -160,15 +160,12 @@ final class HIDKeyboardMonitor: HIDKeyboardMonitoring {
|| normalizedTransport == "ps2"
}

private func isModifierUsage(_ usage: UInt32) -> Bool {
static func isModifierUsage(_ usage: UInt32) -> Bool {
let leftControl = UInt32(kHIDUsage_KeyboardLeftControl)
let rightGUI = UInt32(kHIDUsage_KeyboardRightGUI)
if (leftControl...rightGUI).contains(usage) {
return true
}

// Fn is not consistently exposed; 0x3F is commonly used when present.
return usage == 0x3F
// 0x3F on this page is F6, not Fn: Fn/Globe arrives on Apple's vendor
// page and is already filtered out before this is called.
return (leftControl...rightGUI).contains(usage)
}

private static let inputValueCallback: IOHIDValueCallback = { context, _, _, value in
Expand Down
2 changes: 1 addition & 1 deletion InputPilot/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct SettingsView: View {
} header: {
Text("Auto-Switch")
} footer: {
Text("When \"Switch on modifier-only key presses\" is off, a lone Shift, Command, or Option press (for example during Cmd+Tab) does not change the input source — only regular typing does.")
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.")
}

Section("Input Monitoring") {
Expand Down
2 changes: 1 addition & 1 deletion InputPilot/UI/WelcomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct WelcomeView: View {
.fixedSize(horizontal: false, vertical: true)

Label {
Text("InputPilot only detects **which keyboard** sent a key press — never what you type. Nothing leaves your Mac.")
Text("InputPilot only uses key presses to tell **which keyboard** you are typing on — it never records what you type. Nothing you type leaves your Mac; the only network traffic is a daily update check and any update you choose to install.")
} icon: {
Image(systemName: "hand.raised")
}
Expand Down
50 changes: 49 additions & 1 deletion InputPilotTests/AppStateFailurePathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct AppStateFailurePathTests {

#expect(!keyboardMonitor.isRunning)
#expect(appState.status.permissionStatus == .denied)
#expect(appState.permissionWarningMessage != nil)
#expect(appState.permissionWarningMessage == "Input Monitoring permission is denied or was revoked.")
#expect(appState.activeKeyboardDevice == nil)
#expect(appState.status.activeKeyboard == "none")
}
Expand Down Expand Up @@ -113,6 +113,54 @@ struct AppStateFailurePathTests {
#expect(appState.keyboardMonitorHint != nil)
}

@Test
func repeatedMonitorStartFailureIsLoggedOnce() async throws {
let suiteName = "AppStateFailurePathTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
throw FailurePathTestError.failedToCreateUserDefaultsSuite
}

defer {
defaults.removePersistentDomain(forName: suiteName)
}

let keyboardMonitor = MockHIDKeyboardMonitor()
keyboardMonitor.startResult = false
keyboardMonitor.lastStartErrorMessage = "No matching keyboard HID device available."

let appState = AppState(
permissionService: MockPermissionService(accessType: kIOHIDAccessTypeGranted),
hidKeyboardMonitor: keyboardMonitor,
inputSourceService: MockInputSourceService(),
mappingStore: MockMappingStore(),
appSettingsStore: AppSettingsStore(defaults: defaults),
clock: ImmediateClock(),
permissionPollInterval: .milliseconds(10)
)

// The permission poll retries the start on every tick. The debug log
// that users attach to bug reports must not fill up with one line.
await waitFor {
keyboardMonitor.startCallCount >= 5
}

func monitorErrors() -> [LogEntry] {
appState.debugLogEntries.filter { $0.category == "hid-monitor" && $0.level == .error }
}
#expect(keyboardMonitor.startCallCount >= 5)
#expect(monitorErrors().count == 1)

// A different failure is new information and must still be logged.
keyboardMonitor.lastStartErrorMessage = "HID monitor start failed with IOReturn -536870203."
await waitFor {
monitorErrors().count == 2
}
#expect(monitorErrors().map(\.message) == [
"No matching keyboard HID device available.",
"HID monitor start failed with IOReturn -536870203."
])
}

@Test
func selectInputSourceFailureRecordsErrorWithoutArmingCooldown() async throws {
let suiteName = "AppStateFailurePathTests.\(UUID().uuidString)"
Expand Down
21 changes: 21 additions & 0 deletions InputPilotTests/HIDKeyboardMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import IOKit.hid
import Testing
@testable import InputPilot

struct HIDKeyboardMonitorTests {
@Test(arguments: Array(UInt32(kHIDUsage_KeyboardLeftControl)...UInt32(kHIDUsage_KeyboardRightGUI)))
func modifierUsagesAreModifiers(usage: UInt32) {
#expect(HIDKeyboardMonitor.isModifierUsage(usage))
}

// 0x3F is F6 on the keyboard usage page. Fn/Globe never arrives on that
// page (it uses Apple's vendor page), so it needs no entry here.
@Test(arguments: [
UInt32(kHIDUsage_KeyboardF6),
UInt32(kHIDUsage_KeyboardA),
UInt32(kHIDUsage_KeyboardTab)
])
func regularKeysAreNotModifiers(usage: UInt32) {
#expect(!HIDKeyboardMonitor.isModifierUsage(usage))
}
}
2 changes: 2 additions & 0 deletions InputPilotTests/ServiceMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ final class MockHIDKeyboardMonitor: HIDKeyboardMonitoring {
var startResult = true
var isRunning = false
var lastStartErrorMessage: String?
private(set) var startCallCount = 0

@discardableResult
func start(onEvent: @escaping (ActiveKeyboardDevice, KeyboardEventKind) -> Void) -> Bool {
self.onEvent = onEvent
startCallCount += 1

if startResult {
isRunning = true
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@ If you keep a German external keyboard next to a US MacBook keyboard, you alread

## Install

With [Homebrew](https://brew.sh):

```bash
brew tap LucaGerlich/tap
brew install --cask inputpilot
```

Or manually:

1. Download **`InputPilot-x.y.z.dmg`** from the [latest release](https://github.com/LucaGerlich/InputPilot/releases/latest).
2. Open the DMG and drag InputPilot into Applications.

Then, either way:

3. Launch it. A keyboard icon appears in the menu bar — there is no Dock icon and no main window.
4. A welcome window asks for **Input Monitoring**. Grant it, then quit and reopen InputPilot if macOS asks.
5. Press a key on each keyboard you want to configure, then open **Settings** from the menu bar icon and pick an input source per keyboard.
Expand All @@ -25,7 +37,7 @@ The app is signed with a Developer ID certificate and notarized by Apple, so it

To tell your keyboards apart, InputPilot has to see that *a* key was pressed and on *which* device. macOS puts that behind the Input Monitoring permission.

It never reads what you type. The HID callback keeps exactly two things — the device that sent the event, and whether the key was a modifier. The key's usage code is read only to compute that second value and is then discarded: it is never stored, logged or transmitted, and no character or text is derived from it. Nothing is sent off your Mac: the only network traffic is the update check, and the only third-party dependency is the Sparkle updater.
It never records what you type. The HID callback keeps exactly two things — the device that sent the event, and whether the key was a modifier. The key's usage code is read only to compute that second value and is then discarded: it is never stored, logged or transmitted, and no character or text is derived from it. Nothing you type is sent off your Mac: the only network traffic is Sparkle's update check (automatically once a day, or when you choose Check for Updates) and the download of any update you accept. Sparkle is the only third-party dependency.

See [SECURITY.md](SECURITY.md) for the full privacy statement and how to report a vulnerability.

Expand All @@ -37,7 +49,7 @@ See [SECURITY.md](SECURITY.md) for the full privacy statement and how to report
- **Pause** — silence auto-switching for 15 or 60 minutes 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.
- **No flapping** — a 400 ms debounce and a 1.5 s cooldown stop rapid switching, and modifier-only presses (a lone ⇧ or ⌘ during ⌘-Tab) are ignored by default.
- **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.

## How it works
Expand Down
17 changes: 12 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@ To decide which input source to activate, InputPilot needs to know **which keybo
- the device that sent it (vendor ID, product ID, transport, product name, location)
- whether the key was a modifier

Nothing else leaves that callback. The key's usage code is read inside it, solely to decide whether the key was a modifier, and is discarded when the callback returns — it is never assigned, stored, logged or transmitted, and no character or typed text is derived from it anywhere in the app.
Nothing else leaves that callback. The key's usage code is read inside it, solely to decide whether the key was a modifier. It exists only as a local constant within the callback and is gone when the callback returns — it is never kept in a property, stored, logged or transmitted, and no character or typed text is derived from it anywhere in the app.

## What InputPilot stores

Settings and keyboard mappings live in `UserDefaults`: which input source belongs to which keyboard, your fallbacks, the pause timestamp, and a schema migration flag. That is all.
Settings and keyboard mappings live in `UserDefaults` (`com.lucagerlich.InputPilot`):

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 export runs the text through a redaction pass that strips any field resembling key codes, typed text, characters, or app contents — defence in depth for a log that should never contain them in the first place.
- 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
- 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.

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.

## What leaves your Mac

Update checks against the appcast at `raw.githubusercontent.com`, and the download itself when you accept an update. Nothing else. There is no telemetry, no analytics, and no crash reporter.
Update checks against the appcast at `raw.githubusercontent.com` — automatically about once a day, and whenever you choose Check for Updates — and the download itself when you accept an update. Each check is an ordinary HTTPS request, so GitHub sees your IP address and a user agent naming the app and Sparkle versions. Nothing else is sent. There is no telemetry, no analytics, and no crash reporter.

Updates are verified twice: an EdDSA signature over the appcast enclosure, and Apple's notarization of the downloaded app.
Sparkle verifies every update before installing it: an EdDSA signature over the downloaded archive, checked against the public key built into the app, and a check that the new app carries a valid code signature. Each release is also notarized by Apple.

## Supply chain

Expand Down
Loading
Loading