A small macOS menu bar utility. By default, tap the left ⌘ to select one keyboard layout and the right ⌘ to select another. Pick either layout directly in the menu. Users who need only these two keys do not need additional setup.
Configure Keys… lets you replace the default keys and create up to five bindings. A solo modifier tap means pressing and releasing it without other keyboard or mouse activity. Shortcuts such as ⌘C, ⌘Tab, and ⌘-click keep their normal behavior. An assigned ordinary key switches on key down and suppresses its normal action.
- macOS 15 or newer, Apple silicon or Intel
- The input sources you want to use must already be enabled in System Settings → Keyboard → Input Sources
- Building from source needs the Swift toolchain from the Command Line Tools; full Xcode is not required
Install with Homebrew:
brew trust --cask tonatoz/tap/2cmd # Homebrew 6 ignores untrusted third-party taps
brew install --cask tonatoz/tap/2cmdHomebrew installs the app into /Applications and follows new GitHub releases.
Alternatively, download 2cmd.dmg or 2cmd.zip from the
latest release and move the app
into /Applications.
The app is signed with this project's own certificate and is not notarized, and
Homebrew quarantines every download — unconditionally, since --no-quarantine and
its HOMEBREW_CASK_OPTS equivalent were removed from Homebrew in July 2026. A
quarantined build of this kind is spawned by launchd and then held by Gatekeeper
before its main() runs, so the failure does not look like a security prompt at all:
the process is listed in Activity Monitor, no menu bar icon ever appears, and the
only hint is a "2cmd was not opened" alert that is easy to miss. The cask therefore
drops the quarantine flag in a postflight_steps block; nothing else is needed.
For a .dmg or .zip download the flag has to be cleared by hand:
xattr -dr com.apple.quarantine /Applications/2cmd.app
open /Applications/2cmd.appApproving the app once in System Settings → Privacy & Security → Open Anyway works too. Then grant Accessibility, as described below.
Releases are signed with a dedicated certificate that stays the same across versions, so the Accessibility permission survives updates. You can check that a download really came from this project's release pipeline:
codesign -d -r- /Applications/2cmd.app | grep designated
# designated => identifier "dev.anton.2cmd" and certificate leaf = H"33dbe410cf5122fca09f92e2bb49b888e797c508"make signing-cert # once, see "Making the grant survive rebuilds"
make install # build, sign, copy to /Applications
open /Applications/2cmd.appOther targets:
make app # build dist/2cmd.app only
make run # build and launch from dist/
make test # run the state machine checks
make icon # regenerate dist/AppIcon.icns
make release # universal bundle + dist/2cmd.zip for another Mac
make uninstall # remove /Applications/2cmd.app
make cleanInstalling into /Applications is recommended: both the Accessibility permission and
launch-at-login are tied to the app's location and signature.
make install builds for the current architecture only. For another machine build a
universal bundle and archive it — ditto is used rather than zip because it keeps
the signature and bundle structure intact:
make release # -> dist/2cmd.zip, x86_64 + arm64, ~500 KBOn the other Mac:
ditto -x -k 2cmd.zip /Applications
xattr -dr com.apple.quarantine /Applications/2cmd.app # only if it was downloaded
open /Applications/2cmd.appThen grant Accessibility there as well — TCC decisions are per machine.
Two things to expect:
- Gatekeeper rejects the app (
spctl --assess→rejected), because it is signed with a local certificate and not notarized. That only matters if the copy carries the quarantine flag, which is attached by browsers, AirDrop and messengers — not byscp,rsyncor a USB stick. Either strip it with thexattrline above, or open the app once via System Settings → Privacy & Security → Open Anyway. - macOS 15 or newer is required (
LSMinimumSystemVersion), and the signing certificate's private key stays on this machine — the other Mac does not need it, the certificate itself travels inside the signature.
If the other machine is also a development machine, the cleaner route is to copy the
repository and run make signing-cert && make install there: it gets its own local
identity and no quarantine is ever involved.
For handing the app to someone else properly, the only real fix for the Gatekeeper
warning is a paid Developer ID certificate plus notarization (xcrun notarytool).
An event tap requires the Accessibility permission. On first launch macOS shows a prompt; open System Settings → Privacy & Security → Accessibility and enable 2cmd. No restart needed — the app polls for the grant and starts working as soon as it appears. While the permission is missing (or interception is off) the menu bar icon stays dimmed.
TCC stores the grant together with a code signing requirement. With an ad-hoc
signature there is no certificate, so that requirement collapses to the exact
cdhash of one build. Rebuild the app and the identity changes, so:
- the checkbox in Accessibility stays switched on while access is actually denied;
AXIsProcessTrustedWithOptions(prompt:)shows no prompt, because macOS already has a decision on file for that identifier;- switching the checkbox off and on does not help — that rewrites only the allow bit, not the stored requirement.
What actually works, either one:
make reset-permission # tccutil reset Accessibility dev.anton.2cmd, then relaunchor remove 2cmd from the Accessibility list with the − button and add it again.
Sign every build with a stable certificate instead. A self-signed one is enough — no paid Developer ID required — because TCC only needs the identity to stay constant:
make signing-cert # once
make installThat creates a local 2cmd Local Signing code-signing certificate, imports it into
the login keychain (pre-authorised for codesign, so no password prompts) and from
then on make install prints Signing with stable identity. security find-identity
lists it as CSSMERR_TP_NOT_TRUSTED — expected and harmless: the certificate has no
trusted anchor, and TCC only needs a stable identity, not a trusted one. The
certificate cannot be used to distribute the app.
The effect is visible in the requirement stored with the grant:
# ad-hoc — changes on every rebuild
designated => cdhash H"0fa1c9…"
# certificate — stays put
designated => identifier "dev.anton.2cmd" and certificate leaf = H"e79f95…"
Override the name with make install SIGN_IDENTITY="My Cert". Without a certificate
the build still works, but prints a warning and the permission dies on every rebuild.
Since macOS 10.15 a listen-only keyboard tap is gated by the separate Input
Monitoring service, while .defaultTap is covered by Accessibility. The app uses
.defaultTap, which needs Accessibility. It suppresses only assigned ordinary presses
and presses captured by key recording. Other events pass through unchanged.
Gatekeeper is holding the app before it starts. A quarantined, non-notarized build is
spawned by launchd and then stopped inside dyld, so ps and Activity Monitor list a
live process that never created its status item. Confirm it:
xattr -p com.apple.quarantine /Applications/2cmd.app # flag still there?
sample $(pgrep -x 2cmd) 1 | grep -c TwoCmd_main # 0 = never reached main()
log show --last 5m --predicate 'process == "amfid"' | grep 2cmd
# ... not valid: Error Code=-423 "The file is adhoc signed or signed by an unknown
# certificate chain"Fix: clear the quarantine flag (see Install) and reopen the app. A healthy
process shows TwoCmd_main → -[NSApplication run] in sample.
That is macOS, not 2cmd: System Settings → Keyboard → "Automatically switch to a document's input source" remembers an input source per app and restores it on activation. Check and disable:
defaults read com.apple.HIToolbox AppleGlobalTextInputProperties
# { TextInputGlobalPropertyPerContextInput = 1; } ← 1 means the feature is on2cmd cannot fire on ⌘⇥: the ⇥ keyDown reaches the tap and voids the pending gesture
before ⌘ is released. To see what the app itself does, watch its log:
log stream --level info --predicate 'subsystem == "dev.anton.2cmd"'A completed binding logs select <input source id> -> true when selection succeeds.
- Enabled — turn interception on or off
- Configured keys — pick the input source for each key; initially Left ⌘ and Right ⌘
- Configure Keys… — edit physical keys and add optional bindings
- Launch at Login — register as a login item (
SMAppService) - Check for Updates… — compares the running version against the latest release
- Quit
Defaults on first launch: left ⌘ → your English layout, right ⌘ → your Russian layout, chosen from the input sources already enabled in the system.
The configuration window starts with two required rows. Change their keys or input sources, or add up to three optional rows. Only optional rows can be deleted.
- Choose Configure Keys… from the menu.
- Choose an input source for each row.
- Choose Record Key, then press the physical key you want to assign.
- Choose Apply to activate and save the complete configuration.
Cancel or closing the window discards unapplied edits. Existing bindings remain active while you edit. Recording temporarily suspends switching and consumes the captured press. Use Cancel Recording to cancel without reserving Escape.
Supported assignments include:
- Left and right Command, Option, and Control
- Character keys
- Ordinary function keys
- Navigation keys
- Editing keys
Fn/Globe, Caps Lock, Shift, and multimedia keys cannot be assigned. Standard function-key events must reach macOS as function keys, rather than their multimedia actions.
A physical key code identifies each binding, independently of the active input source. Letter labels describe their US keyboard positions. A key cannot appear in two rows, but different keys can select the same input source.
An assigned ordinary key loses its normal action while its input source is available. The window shows this warning before you apply. Holding the key does not switch again. Shortcuts pass through when the modifier precedes the assigned key. Pressing an assigned letter before Command does not reconstruct an already-suppressed shortcut.
Restore Standard Keys restores left and right Command in the draft, retains the first two rows' input sources, and removes optional rows. Choose Apply to confirm that draft, or Cancel to retain the previous configuration.
If an input source becomes unavailable, the app retains its binding and marks it. The key temporarily performs its normal action. The binding resumes when that input source becomes available again.
Settings survive app restarts and updates. Existing left/right selections migrate automatically. Menu source selection saves immediately; it is unavailable while the configuration window is open, so it cannot overwrite a draft.
The app needs the Accessibility permission to install a CGEventTap, which is the
same mechanism a keylogger would use. What it actually does with it:
-
Typed text is never stored, logged, or transmitted. The event path reads physical key codes, modifier flags, and repeat status. It retains only transient press state to recognize gestures and pair intercepted releases.
-
Only configured presses switch input sources. Ordinary assigned keys are suppressed when used without a held modifier. Unassigned keys pass through.
-
No network access at all, except when you explicitly choose Check for Updates…, which requests one public GitHub API URL and sends no data about you.
-
No analytics, telemetry, or crash reporting. Settings live in
UserDefaultsand contain up to five key-code/source-ID pairs plus the Enabled preference. -
Diagnostics go to the unified log and contain no keystrokes:
log show --last 5m --predicate 'subsystem == "dev.anton.2cmd"' --info
The event path is Sources/TwoCmd/KeyTapMonitor.swift plus
Sources/TwoCmdCore/KeyBindingDetector.swift. Read it before granting Accessibility
to a binary you do not trust.
| File | Role |
|---|---|
Sources/TwoCmdCore/KeyBinding.swift |
Physical keys, binding model, and configuration validation |
Sources/TwoCmdCore/KeyBindingDetector.swift |
Gesture recognition and ordinary-key event decisions, no AppKit |
Sources/TwoCmdCore/ActivationCoordinator.swift |
Permission/tap startup, retries until the tap is up |
Sources/TwoCmdCore/Version.swift |
Numeric version comparison for the update check |
Sources/TwoCmd/KeyTapMonitor.swift |
Keyboard event tap, press suppression, and mouse monitors |
Sources/TwoCmd/InputSourceManager.swift |
Text Input Source Services wrapper |
Sources/TwoCmd/StatusItemController.swift |
Menu bar item and menu |
Sources/TwoCmd/BindingConfigurationController.swift |
Native configuration window and draft editing |
Sources/TwoCmd/UpdateChecker.swift |
Latest release lookup via the GitHub API |
Sources/TwoCmd/Settings.swift |
UserDefaults persistence |
Sources/TwoCmd/AppDelegate.swift |
Permission flow and wiring |
Tools/MakeIcon.swift |
Draws the app icon (see below) |
Tools/make-signing-cert.sh |
Creates the local signing identity |
The app icon is generated from source — AppKit draws it, iconutil packs the
.icns, so no binary artwork is committed:
make icon # dist/AppIcon.icns, rebuilt when Tools/MakeIcon.swift changesA white ⌘ on a canvas split left/right — blue for the left ⌘, red for the right ⌘. Nothing else, so it stays readable down to 16 px.
The menu bar icon is deliberately different: an SF Symbol drawn as a template image, so it stays monochrome and follows the system appearance, per the macOS HIG.
The detector keys off device-dependent modifier bits (NX_DEVICELCMDKEYMASK and
friends) rather than maskCommand, so the left and right ⌘ can be told apart, and a
release only counts when no other modifier is still held. Caps lock is ignored for
that check since it is a latched state rather than a held key.
Mouse and scroll events are observed through NSEvent monitors instead of the event
tap, because including mouse events in a CGEventTap is known to interfere with
dragging.
