Skip to content

feat: 1.1.0 — respect manual layout changes, open at login, a first run that ends - #14

Merged
LucaGerlich merged 12 commits into
mainfrom
feat/first-hour-fixes
Oct 11, 2026
Merged

LucaGerlich merged 12 commits into
mainfrom
feat/first-hour-fixes

Conversation

@LucaGerlich

Copy link
Copy Markdown
Owner

The 1.1.0 product work from the post-launch audit: the gaps a new user hits in the first hour.

Added

  • Manual layout changes are respected (Settings → Switching). A layout picked by hand — ⌃Space, the input menu, Undo, an IME mode key — used to be reverted on the next key press. Now configurable, Keychange-style:
    • keep it until I switch keyboards (default) · keep it until I switch back myself · switch back on the next key press (old behaviour) · turn off automatic switching (with the reason shown).
    • Detection: InputPilot remembers the source it last applied; a different current source at switch time means someone else chose it. A switch that did not take effect is retried, never mistaken for a manual choice; moving keyboards re-arms from the current source so a ⌃Space inside the 400 ms debounce is kept; a lone ⌘ on the other keyboard does not count as switching keyboards.
  • Open at login via SMAppService.mainApp, pre-checked at the end of the first run and as a Settings switch.
  • Onboarding that ends: the welcome window stays until its last step; Relaunch InputPilot replaces bare Quit after the grant; a denied permission gets System Settings as the main button.
  • "Use … for …" in the menu maps the keyboard in use to the current source in one click.
  • Automatic update checks can be switched off.

Fixed

  • Two USB keyboards of the same model shared one mapping. A keyboard now folds into a known key only if that key's port no longer holds the same model.
  • Karabiner-Elements holding a keyboard stopped detection outright. On kIOReturnExclusiveAccess the monitor keeps running — only when permission was granted at launch, so a fresh grant still asks for a relaunch.
  • Emoji & Symbols, Dictation and Press and Hold are no longer offered as mapping targets.
  • Windows opened from the menu are brought to the front.

Changed

  • Task-first menu (Diagnostics submenu, Quit last), keyboards-first Settings, plain words instead of VID/PID/IOReturn jargon, accessibility labels.
  • AppState shrinks to ~1,150 lines: device-key rules move to DeviceKeyResolver, view strings to AppState+Display.
  • Version 1.1.0 (build 3); README, site FAQ (+3 entries, JSON-LD in sync), SECURITY.md and CHANGELOG updated.

Test plan

  • TDD throughout: every behaviour test was seen failing first (RED), including mutation checks that the same-model, debounce-race and does-not-take tests catch the bugs they guard.
  • 69 unit tests pass (was 38).
  • Independent review of the branch: no blockers; its medium findings are fixed in the last two commits.
  • JSON-LD parses; 10 FAQ entries identical between page and structured data.
  • CI green, including the new UI regression test for the 1.0.0 missing first-run window (could not run locally: the screen is locked, so macOS refuses UI automation).
  • Manual on real hardware (needs a person at the keyboard): each policy with two keyboards, Undo, Open at login across a reboot, Welcome from defaults delete com.lucagerlich.InputPilot onboardingCompleted, pickers without Emoji/Dictation, and Karabiner-Elements with "Modify events" on for one keyboard only.

Move canonical-key matching, configured-source resolution and the
active-device check into DeviceKeyResolver as pure functions that take
every input as a parameter. No behaviour change; AppState drops from
1,115 to 1,037 lines before the 1.1.0 features land in it.
A keyboard seen at a new location was folded into the one known key of
the same model, so two identical keyboards (say a US and a German Magic
Keyboard) shared one Settings row and one mapping.

Fold a keyboard into a known key only if that key's recorded location no
longer holds a keyboard of the same model - that is the keyboard that
moved. If every recorded location is still occupied, it is another
keyboard and gets its own key. This also lets one of two same-model
keyboards change ports without becoming a third entry. The attached
devices are only queried on this slow path, never for a known keyboard.
Every key press re-applied the keyboard's mapping, so a layout picked by
hand (Ctrl-Space, the menu bar, Undo, an input method's mode toggle) was
reverted on the next key - a daily fight for multilingual users.

InputPilot now remembers the source it last applied. If the current one
differs when it is about to switch, someone else chose it, and a new
setting decides what happens:

- keep it until I switch keyboards (default)
- keep it until I switch back myself
- switch back on the next key press (the old behaviour)
- turn off automatic switching, with the reason shown

Details that make the detection reliable:
- a switch that did not take effect (some CJK input methods) is retried,
  never mistaken for a manual choice;
- moving to another keyboard re-arms from the current source, so a
  Ctrl-Space pressed during the 400 ms debounce is still respected;
- only keys that may switch count as changing keyboards, so a lone Cmd on
  the other keyboard does not end a hold;
- settings changes end any hold and apply at once, through one helper
  that uses the canonical device key instead of five raw-key copies.
A menu bar utility that is not a login item silently stops working after
every restart. LoginItemController wraps SMAppService.mainApp (macOS 13+)
behind a small protocol, reports enabled / disabled / requires-approval,
and surfaces registration errors instead of swallowing them. The UI uses
it in the welcome window and in Settings.
…ents

Onboarding:
- The welcome window only appeared while permission was missing. After
  the grant-and-relaunch macOS asks for, a new user got no window, no
  mappings and no hint. Onboarding now lasts until its last step is done
  (stored as onboardingCompleted) and returns whenever permission is
  missing or a relaunch is still needed.
- relaunch() starts a fresh copy once this process has exited, for a
  Relaunch button instead of a bare Quit. The bundle path and PID are
  passed as arguments, never spliced into the shell script.
- Settings booleans also read "YES"/"NO" strings, so UI tests can start
  onboarding with -onboardingCompleted NO.

Karabiner-Elements:
- Any keyboard held exclusively by another app made IOHIDManagerOpen fail
  and discarded the whole monitor, with advice to relaunch that could
  never help. On kIOReturnExclusiveAccess the monitor now keeps running
  for the other keyboards and explains how to release the held one.
- Only when permission was granted at launch: the open error comes from
  an unordered set of devices, so right after a fresh grant it could hide
  the permission failure that a relaunch fixes.
Emoji & Symbols, Dictation, Press and Hold and the emoji Touch Bar item
are enabled, selectable input sources of the palette category. They
appeared in every picker; mapped to a keyboard, selecting one opened a
panel and never became the current keyboard source, so it was selected
again after every cooldown. Lists and the enabled check now keep only
the keyboard category.
- Status reads "Input Monitoring: not granted yet" and "Keyboard
  detection: running" instead of "Permission: unknown" / "Monitor".
- Device details read "USB · Vendor 1452 · Product 832"; the location
  only appears when it tells two keyboards of the same model apart.
- Conflicts explain themselves instead of "missing/disabled".
- useCurrentInputSourceForActiveDevice() maps the keyboard in use to the
  source selected right now, for a one-click action in the menu.
- The view-facing strings move into AppState+Display.swift, so AppState
  shrinks to 1,150 lines instead of growing with the new UI.
Menu: what you are typing on and what it maps to comes first, with a
one-click "Use <source> for <keyboard>"; then switching, pause and undo.
Hardware IDs and the debug log move into a Diagnostics submenu, Quit
goes last, and every window it opens is brought to the front - a menu
bar app is never frontmost, so Settings could open unreachable behind
the active app.

Settings: keyboards first (with an empty state that explains the second
keyboard), then switching with Open at login and the new manual-change
policy, fallbacks, updates with a toggle for Sparkle's daily check, and
status. The 365-day "Pause" toggle becomes a plain on/off switch.

Welcome: a denied permission gets System Settings as the main button,
the post-grant state gets a Relaunch button instead of Quit, and the
last step offers Open at login, pre-checked, before Done.

Accessibility: the menu bar icon is labelled, per-keyboard controls name
their keyboard, decorative images are hidden from VoiceOver.

The empty launch UI test is replaced by a regression test for 1.0.0's
missing first-run window.
- README: the manual-change setting, Open at login, same-model
  keyboards, the new Settings layout, Relaunch, and Karabiner-Elements.
- Site: three new FAQ entries (login, manual changes, Karabiner) in both
  the page and its structured data, the Relaunch answer, the version,
  and the re-measured size (about 3,900 lines of Swift).
- SECURITY.md: the storage list includes the two new settings and says
  that Open at login lives with macOS, not InputPilot.
- CHANGELOG: the 1.1.0 entry, folding in the unreleased fixes.
- A failed login-item registration left a stale error that kept the
  welcome window's Done from closing it, even after unchecking the box.
- Changing the manual-change policy re-evaluated and switched away from
  the layout the user was holding; it now applies from the next key.
- The hold note is hidden while automatic switching is off, and cleared
  when the keyboard disconnects.
- relaunch() waits at most 10 s for the old process, then lets Launch
  Services settle, instead of waiting without bound.
- Same-model keyboards are told apart by port: say USB keyboards on
  different ports, and that swapping two identical keyboards swaps their
  mappings.
- The updates footer no longer says "nothing else is sent"; GitHub does
  see an IP address and user agent. It says nothing about keyboards or
  typing is sent.
- Note that existing users see the welcome window once after updating.
- The input-source smoke test now fails on an empty list instead of
  passing vacuously, and a resolver comment states the real cost.
The app target uses SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, and every
existing suite is @mainactor. Three new suites were not, so Swift Testing
ran them on background threads, where they used main-actor-isolated
conformances (KeyboardDeviceKey's Equatable) and the Text Input Sources
API. Swift's runtime isolation checks then terminated the test host on CI
- locally they passed only by scheduling luck. The compiler's warnings
came from macro-expanded files and slipped past a too-narrow filter.

Also build the default login-item service inside the main-actor init
rather than as a default argument, which is evaluated outside it.
@LucaGerlich
LucaGerlich merged commit 5e6a2d6 into main Oct 11, 2026
1 check passed
@LucaGerlich
LucaGerlich deleted the feat/first-hour-fixes branch October 11, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant