Skip to content

feat(mac): add keyman command-line interface (stage 1)#15946

Closed
boazy wants to merge 2 commits into
keymanapp:masterfrom
boazy:feat/mac/keyman-cli
Closed

feat(mac): add keyman command-line interface (stage 1)#15946
boazy wants to merge 2 commits into
keymanapp:masterfrom
boazy:feat/mac/keyman-cli

Conversation

@boazy
Copy link
Copy Markdown

@boazy boazy commented May 12, 2026

Summary

Adds mac/keyman-cli/, a standalone Rust CLI (keyman list | status | activate | select) for controlling Keyman from the shell on macOS, and the minimal Keyman input-method change required to make select switch keyboards live without restarting the IM. Two commits, both scoped mac:

  • feat(mac): handle keyman:select?path=… URL for live keyboard switching — 30 lines added to KMInputMethodAppDelegate.processURL: extending the existing keyman:// URL scheme with a select action that dispatches selectKeyboardFromMenu: (the exact same code path the in-app menu uses).
  • feat(mac): add keyman command-line interface (stage 1) — new Rust crate.

Stage 1 supports macOS only; the crate is shaped behind a KeymanClient trait so Windows / Linux backends can land later without an API break. The Rust crate is not embedded in Keyman.app for this PR — it's intentionally a cargo build --release artefact — and no existing build-pipeline files (mac/build.sh, Xcode targets, the .pkg installer, codesign steps) are touched. The macOS linker already emits an ad-hoc signature sufficient for local execution.

IPC for select

The CLI's activate runs entirely in-process via the Carbon TIS API. select is harder: the running IMK process keeps KMSelectedKeyboardKey cached in memory and does not observe NSUserDefaults changes (verified — rg 'NSUserDefaultsDidChangeNotification|addObserver.*NSUserDefaults' mac/Keyman4MacIM/ returns zero hits). Some form of IPC is therefore required for a live switch without a Keyman restart.

Option Verdict Reason
Extend keyman:// URL scheme ✅ chosen Keyman already registers keyman: (Info.plist CFBundleURLSchemes) and routes Apple Events via kInternetEventClass:kAEGetURL in KMInputMethodAppDelegate -initCompletion. One new else if in processURL: reuses the existing surface; LaunchServices handles the app-not-running case for free. Smallest combined diff.
Distributed notifications Rejected Requires a new observer in the AppDelegate plus a new notification name, and doesn't compose with LaunchServices auto-launch when Keyman isn't running.
XPC service Rejected Robust but expensive: new bundle target, entitlements, signing changes — overkill for v1. Worth revisiting if a richer remote-control surface emerges.
Mach port + bootstrap server Rejected Same overkill concern, lower-level than XPC.
Apple Events / AppleScript Rejected No .sdef currently shipped; would need a scripting dictionary.
Write the pref + signal the IMK Rejected Fragile; entangles two mechanisms.
Accessibility-API menu click Rejected Would require the user to grant the CLI Accessibility permission — terrible UX.

The CLI dispatches keyman:select?path=<percent-encoded id> via /usr/bin/open -g, then polls KMSelectedKeyboardKey via CFPreferencesAppSynchronize for up to 1.5 s to confirm the round-trip. On timeout it exits 9 with a diagnostic explaining that the running Keyman.app lacks the matching IMK patch — observed directly in the smoke transcript below, where the unpatched installed Keyman 18.0.248 ignores the URL.

Cross-platform readiness

KeymanClient (mac/keyman-cli/src/client.rs) is the seam between the CLI dispatcher and per-OS backends. Value types (Keyboard, KeyboardId, Status, ImState, ActivateOutcome, SelectOutcome) use OS-neutral names (e.g. im_registered rather than "in AppleEnabledInputSources"). The dispatcher in src/main.rs only deals in already-resolved KeyboardId values, with the free-form string → canonical id resolver living in src/resolver.rs as a CLI-level concern.

Non-macOS targets compile against src/backend/unsupported.rs, which returns std::io::ErrorKind::Unsupported with a clear "stage 1 is macOS only" message. The crate's README documents the integration points each future backend will hook:

  • Windows. Selection state lives in TSF profiles; activate maps to ITfInputProcessorProfileMgr::ActivateProfile; select likely to a profile exchange with the running Keyman Engine.
  • Linux. ibus / fcitx D-Bus interfaces map cleanly onto KeymanClient. The current URL approach has no direct analogue, but a small named socket or D-Bus signal inside the engine process fits.

Both deferred to follow-up PRs.

Manual smoke-test transcript

Captured against the installed Keyman 18.0.248 on the dev machine. All 7 expected keyboards (KMActiveKeyboardsKey) are present.

==> $ keyman --version
keyman 0.1.0

==> $ keyman list
  /cgreek/cgreek.kmx  Classical Greek
  /hebrew_phonetic_arabic/hebrew_phonetic_arabic.kmx  Phonetic Arabic
  /oldenglish/oldenglish.kmx  Old English
  /qpolish/qpolish.kmx  Quick Polish
  /qrussian/qrussian.kmx  Quick Russian
* /sil_euro_latin/sil_euro_latin.kmx  EuroLatin (SIL)
  /sil_ipa/sil_ipa.kmx  IPA (SIL)
exit=0

==> $ keyman list --json | jq '.keyboards | length'
7

==> $ keyman status
keyman: registered as input source: yes
keyman: currently selected input source: no
keyman: input-method process running: yes
keyman: selected keyboard: EuroLatin (SIL) (/sil_euro_latin/sil_euro_latin.kmx)
exit=0

==> $ keyman activate
keyman: registered=yes selected=yes (was registered=yes selected=no)
exit=0

==> $ keyman activate --json    # idempotent re-run
{
  "im_registered_before": true,
  "im_selected_before": true,
  "im_registered_after": true,
  "im_selected_after": true,
  "changed": false
}
exit=0

==> $ keyman select does-not-exist
keyman: Unknown keyboard: 'does-not-exist'. Run `keyman list` to see available keyboards.
exit=3

==> $ keyman select sil_euro_latin    # currently selected; no IPC dispatched
keyman: selected 'EuroLatin (SIL)' (/sil_euro_latin/sil_euro_latin.kmx)
exit=0

==> $ keyman select qpolish    # against UNPATCHED Keyman 18.0.248
keyman: Selected '/qpolish/qpolish.kmx' but Keyman did not switch keyboards within 1500ms.
        The currently-selected keyboard is /sil_euro_latin/sil_euro_latin.kmx.
        This usually means the running Keyman.app does not yet support the
        `keyman:select` URL action; rebuild the IM and reinstall.
exit=9

The last line is the expected result against an unpatched Keyman: the URL is dispatched (LaunchServices open exits 0), but the IMK has no handler for select, so the prefs round-trip times out and the diagnostic guides the user to rebuild. Building and installing the IMK from this branch promotes that exit-9 case to a successful live switch.

Live-switch verification (post-IMK-rebuild) is a visual check — see mac/keyman-cli/scripts/smoke.sh for the guided manual procedure covering steps that need a human (menu-bar IM picker state; TextEdit typing on the very next keystroke; menu checkmark movement).

Deviations from the handoff

  • The handoff describes the bundle id as keyman.inputmethod.Keyman. The Xcode project currently sets PRODUCT_BUNDLE_IDENTIFIER = "keyman.inputmethod.$(PRODUCT_NAME:rfc1034identifier)" which would resolve to keyman.inputmethod.Keyman4MacIM, but the installed runtime artefact still reports keyman.inputmethod.Keyman (confirmed via defaults read "~/Library/Input Methods/Keyman.app/Contents/Info.plist" CFBundleIdentifier). The CLI hard-codes keyman.inputmethod.Keyman, matching the runtime constant in KMInputMethodLifecycle.m:44. No action needed; flagging because it surprised me.
  • Project layout: handoff suggested tools/keyman-cli/, core/keyman-cli/, or new top-level cli/. I chose mac/keyman-cli/ to mirror the existing precedent of mac/setup/textinputsource/ (a similar small single-purpose helper inside mac/). When Windows / Linux backends arrive the path may want to migrate to tools/keyman-cli/; doing it now would touch the build pipeline (labeler.yml etc.) without payoff.
  • Stage 1 status field naming: I used im_registered / im_selected / im_process_running instead of the handoff's suggested phrasing. The handoff explicitly invited OS-neutral names; these read cleanly for the Windows TSF and Linux ibus/fcitx maps.

Verification

  • cargo fmt -- --check clean.
  • cargo clippy --all-targets -- -D warnings clean (with #[allow(clippy::struct_excessive_bools)] on the two wire-schema structs — bools are the schema there).
  • cargo test — 14 / 14 unit tests pass (keyboard-id resolver covers each accepted input form, ambiguity, unknown, case-insensitivity, leading-slash handling, package disambiguation; URL percent-encoding round-trip).
  • Release binary: ~907 KB arm64 Mach-O.

Out of scope (explicit)

  • enable / disable subcommands; any mutation of KMActiveKeyboardsKey.
  • Embedding the CLI in Keyman.app/Contents/MacOS/.
  • Changes to mac/build.sh, Xcode targets, the .pkg installer, or code-signing pipelines.
  • Windows / Linux backends. The trait is shaped for them; the implementations are deferred.
  • iOS / Android / web clients.
  • AppleScript scripting dictionary.
  • Symlinking the binary into /usr/local/bin from any installer.

boazy added 2 commits May 12, 2026 14:17
Extend processURL: in KMInputMethodAppDelegate to recognise a new
'select' action that carries a percent-encoded canonical keyboard id
in a 'path' query parameter. The URL is parsed, the matching index in
self.activeKeyboards is looked up, and selectKeyboardFromMenu: is
dispatched on the main queue so the same code path that runs when a
user picks a keyboard from Keyman's menu fires — loading the kmx into
Core, updating the OSK, persisting KMSelectedKeyboardKey, and applying
persisted options.

Required by the new keyman command-line tool in mac/keyman-cli/, which
dispatches keyman:select?path=… via LaunchServices (open(1)). The
existing keyman:download URL handling is untouched.

No changes to entitlements, Info.plist, or the build pipeline; the
existing kAEGetURL registration in initCompletion already routes these
URLs to processURL:.
Introduces mac/keyman-cli/, a standalone Rust crate providing a
'keyman' binary that controls Keyman from the shell.

Subcommands (all support --json for machine-readable output):

  keyman list              every keyboard in KMActiveKeyboardsKey,
                           with the currently-selected one starred
                           and display names resolved from kmp.json
  keyman status            OS-level (im_registered / im_selected /
                           im_process_running) + Keyman-level state
  keyman activate          enable + select Keyman via Carbon TIS
                           (idempotent)
  keyman select <id>       activate if needed, then dispatch a
                           keyman:select?path=… URL via LaunchServices
                           and verify the round-trip via prefs

Resolution rules for <id> (case-insensitive): exact canonical id,
package/keyboard, filename[.kmx], stem. Ambiguity is reported with the
list of candidates; the resolver lives in src/resolver.rs and has 14
unit tests covering each input form.

Architecture is shaped for future Windows / Linux backends: every
operation goes through the KeymanClient trait in src/client.rs, the
value types in src/keyboard.rs use OS-neutral names (im_registered
rather than 'enabled in AppleEnabledInputSources'), and non-macOS
targets compile against src/backend/unsupported.rs returning a clear
'not yet implemented' error.

macOS internals:

  src/backend/macos/prefs.rs     CFPreferencesCopyAppValue against
                                 the keyman.inputmethod.Keyman domain
  src/backend/macos/tis.rs       Carbon TIS bindings (filters input
                                 sources by kTISPropertyBundleID)
  src/backend/macos/packages.rs  serde-driven kmp.json reader for
                                 keyboards[].name display names
  src/backend/macos/ipc.rs       'open keyman:select?path=…' dispatch
                                 with percent-encoding

IPC for select reuses the existing keyman:// URL surface (see the
matching feat(mac): handle keyman:select URL commit). Alternative
options considered (distributed notifications, XPC, Mach ports,
AppleScript) are documented in the crate README with rejection
rationale. Smallest combined diff of the candidates.

Build: cargo build --release (in mac/keyman-cli/). MSRV 1.75; the
binary is ~900KB arm64 Mach-O; no codesign step required (the macOS
linker emits the ad-hoc signature local execution needs).

Out of scope for this PR (called out in the README):
  - enable/disable/install (no mutation of KMActiveKeyboardsKey)
  - embedding the binary in Keyman.app
  - changes to mac/build.sh, Xcode targets, or the .pkg installer
  - Windows / Linux backends

cargo fmt clean. cargo clippy --all-targets -- -D warnings clean.
14/14 unit tests pass.

scripts/smoke.sh provides a guided manual smoke test covering both
shell-verifiable and visual checks (menu-bar IM picker, live TextEdit
typing) once the matching IMK patch is built into Keyman.app.
@github-project-automation github-project-automation Bot moved this to Todo in Keyman May 12, 2026
@keymanapp-test-bot keymanapp-test-bot Bot added the user-test-missing User tests have not yet been defined for the PR label May 12, 2026
@keymanapp-test-bot
Copy link
Copy Markdown

User Test Results

Test specification and instructions

ERROR: user tests have not yet been defined

@keymanapp-test-bot keymanapp-test-bot Bot added this to the A19S29 milestone May 12, 2026
@keyman-server
Copy link
Copy Markdown
Collaborator

This pull request is from an external repo and will not automatically be built. The build must still be passed before it can be merged. Ask one of the team members to make a manual build of this PR.

@mcdurdin
Copy link
Copy Markdown
Member

Thank you for your contribution. Unfortunately we cannot accept contributions in Rust at this time. It would be wise to engage with the team before investing in making a contribution of an entirely new component, to ensure that your work aligns with the project roadmap.

@mcdurdin mcdurdin closed this May 12, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in Keyman May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

user-test-missing User tests have not yet been defined for the PR

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants