Keyboard remapper for macOS, configured with a JSON file. It seizes the keyboards via IOKit and posts the remapped events, so no driver is needed.
Requires macOS 26 or later. Download KeyRemapper-<version>.dmg from the latest release, open it, and drag KeyRemapper.app to /Applications.
Releases are signed with an Apple Development certificate but are not notarized. If macOS blocks the first launch, choose Open Anyway in System Settings > Privacy & Security. Use Check for Updates… in the menu to install new releases.
On first launch, allow KeyRemapper in System Settings > General > Login Items & Extensions, then enable Accessibility and Input Monitoring under System Settings > Privacy & Security. These permissions let KeyRemapper remap keys system-wide.
Choose Open Config folder from the menu to edit ~/KeyRemapperMac/config.json. Saving any JSON file there reloads the active profile.
Key names come from symbols.json. The app's Logger shows the current app and keyboard names. Mistakes (unknown fields or keys) are reported when the profile loads.
One to one, before the rules. The first one that matches wins.
[
{ "from": "CmdL", "to": "AltL", "keyboard": "50475:1133" },
{ "from": "Caps", "to": "F18" }
]The first rule that matches the pressed key replaces it, until it's released. Keys without a rule pass through.
[
{
"from": ["H"], // any of these keys
"modifiers": ["CmdL"], // held, the others can't be (optional)
"optional": ["ShiftL"], // can also be held, ["any"] for all (optional)
"app": "com.google.Chrome", // or ["com.google.Chrome", "com.apple.finder"] (optional)
"keyboard": "50475:1133", // productId:vendorId, a string or array too (optional)
"if": "myVar", // these variables are on, a string or array (optional)
"unless": "myVar", // these variables are off, a string or array (optional)
"to": "LeftArrow", // on key down, without it the key is silenced
"tap": "Esc", // released within tapDelay, without pressing other keys
"doubleTap": "CmdL+F", // tapped twice
"hold": "CmdL+W", // held for holdDelay, without other key events
"afterKeyUp": "Esc" // on release, after the tap
}
]Only from is required. Any key can be a modifier (eg. ["F18", "F"]), but only the real modifiers (Cmd, Alt, Ctrl, Shift and Fn) have to be listed in modifiers or optional to be held. The keys sent by the modifiers are released while the rule's key is pressed, so Cmd + H sending LeftArrow doesn't send Cmd + LeftArrow. After it's released, only the real modifiers are pressed again (any other key would be typed again).
Each action is space separated. Like Karabiner, the last key of to and hold is held until the key is released, the others are tapped. tap, doubleTap and afterKeyUp come on release, so all their keys are tapped. The keys of hold don't repeat.
"to": "C" // C, held
"to": "CmdL+ShiftL+C" // with modifiers
"to": "Tab delay:250 Tab" // waits 250ms
"to": "currentKey" // the pressed key
"to": "AltL:up LeftClick" // releases Alt, then holds the click
"to": "set:myVar" // turns on a variable, unset:myVar turns it offWith several keys, an action can also have one item per key:
{
"from": ["H", "J", "K", "L"],
"modifiers": ["F18"],
"optional": ["any"],
"to": ["LeftArrow", "DownArrow", "UpArrow", "RightArrow"]
}Like Karabiner's set_variable, a variable stays on or off until an action changes it, and if and unless check it on key down. Reloading the profile turns them all off. Here F18 + F + H selects only if F is pressed after F18. If F is pressed first, it's typed and H only moves:
[
{
"from": ["F"],
"modifiers": ["F18"],
"optional": ["any"],
"to": "set:vimShift",
"afterKeyUp": "unset:vimShift"
},
{ "from": ["H"], "modifiers": ["F18"], "if": "vimShift", "to": "ShiftL+LeftArrow" },
{ "from": ["H"], "modifiers": ["F18"], "to": "LeftArrow" }
]A mode that a tap toggles:
[
{ "from": ["F19"], "unless": "myMode", "tap": "set:myMode" },
{ "from": ["F19"], "if": "myMode", "tap": "unset:myMode" }
]For a click mode, a variable can track the physical modifier while :up releases its output. This avoids pressing the modifier again when the drag ends. Normal modifier shortcuts work before entering click mode; afterward, release and press the modifier again to use it normally. The same pattern works with Cmd, Ctrl, or any other key:
[
{
"from": ["AltL"],
"optional": ["any"],
"to": "AltL set:clickMode",
"afterKeyUp": "unset:clickMode"
},
{
"from": ["C"],
"optional": ["any"],
"if": "clickMode",
"to": "AltL:up LeftClick"
}
]An explicit :up works in any action and releases the named key even when another rule pressed it. It doesn't release the physical key or turn off variables, so repeated C presses still click until Alt is physically released.
"%import(file.json)" is replaced by that file, relative to the one importing it. In an array, an imported array is spread into it (see Tests/imports.json or my config).
{
"remaps": "%import(_remaps.json)",
"rules": ["%import(_vimMode.json)", { "from": ["A"], "to": "B" }]
}Each profile can have tests: pairs of input key events and the expected events sent to the OS. Inputs can also have delay:{ms}, app:{name} and keyboard:{id}.
"tests": [
["Caps", "Esc"],
["Caps:down delay:250 Caps:up", ""],
["app:com.google.Chrome CmdL:down H CmdL:up", "CmdL:down CmdL:up LeftArrow CmdL:down CmdL:up"]
]make test # engine tests (Tests/)
make test-runtime # Mac runtime tests: key events in, posted events out (Tests/runtime.mm)
make test-app # builds, installs and drives the signed app's menu (Tests/app.sh)
make test-config [CONFIG=...] # tests of each profile (default ~/KeyRemapperMac/config.json)
make build # unsigned Debug build of the Mac app
make install # builds, installs in /Applications, and launches the signed Release app
make dev-app # quits the app, builds and runs a local signed Debug app
make dev [CONFIG=... PROFILE=1 LOG=1] # run the remapper from the terminal
make icon # regenerates the app and menu bar icons from images/*.svg- Use
$HOMEinstead of~inCONFIG, zsh doesn't expand it there make install(orScripts/install.sh) uses your Xcode signing identity, waits for the app and daemon to quit, and safely replaces/Applications/KeyRemapper.app. It asks for admin access only if needed.Scripts/install.sh --no-buildreuses the last install build.- To select a specific certificate, run
make install CODE_SIGN_IDENTITY="<certificate name or SHA-1>". This uses manual signing for both the app and daemon. - Sign in to your Apple account in Xcode > Settings > Accounts before installing. The installer lets Xcode refresh signing certificates and provisioning profiles when needed.
make test-appreplaces the installed app and needs Accessibility for the terminal app. It briefly saves an invalid config to check reloading, and restores yours after. Physical keystrokes can't be automated (it would need a virtual HID driver), so try a few keys after itmake dev-applaunchesbuild/dev-app/Build/Products/Debug/KeyRemapper.appwithout replacing the installed app or running tests. It uses your existing config and signing identity.make devneeds no app, helper or signing. Quit KeyRemapper first and grant Input Monitoring and Accessibility to the terminal app. Stop it with Ctrl+C- Xcode: open
KeyRemapper.xcodeprojand sign both targets (KeyRemapper, Daemon) with your Apple Development certificate. The daemon only accepts apps signed by the same team - The app only restarts the daemon when its version changes, so after changing the daemon bump the version
- Icons:
images/icon.svgandimages/menubar-icon.svgare the sources, edit them and runmake icon(needsbrew install librsvg). Don't edit the files inAssets.xcassetsdirectly
More dev notes in NOTES.md.
Push to main, then run make release-patch, make release-minor or make release-major. It previews the new version and its commits and, after confirmation, pushes an annotated tag. The release workflow builds the app and publishes its DMG, using the commits as release notes, and the appcast.xml that the installed apps check for updates (Sparkle).
The workflow can also be run manually (Actions > Run workflow) to build without releasing.
The Mac build is signed with these repository secrets:
MAC_BUILD_CERTIFICATE_BASE64: the Apple Development certificate (.p12) in base64 (base64 -i certificate.p12)MAC_BUILD_CERTIFICATE_BASE64_PASSWORD: its passwordMAC_APP_CERTIFICATE: its name, eg.Apple Development: me@email.com (XXXXXXXXXX)
And the updates with:
SPARKLE_PRIVATE_KEY: the private EdDSA key whose public key isSUPublicEDKeyinKeyRemapper/Info.plist. Sparkle'sgenerate_keyskeeps it in the login keychain (accountkeyremapper),generate_keys --account keyremapper -x key.txtexports it
If you see "Couldn't register the Daemon: The operation couldn't be completed. Operation not permitted" on a Mac that had an older KeyRemapper version installed, a leftover legacy daemon may be blocking registration. Quit KeyRemapper, open Terminal, and run:
sudo launchctl remove co.goerwin.KeyRemapperDaemon
sudo rm -f /Library/LaunchDaemons/co.goerwin.KeyRemapperDaemon.plist
sudo rm -f /Library/PrivilegedHelperTools/co.goerwin.KeyRemapperDaemonThen reopen KeyRemapper and allow it under System Settings > General > Login Items & Extensions if prompted.
KeyRemapper's source code is licensed under the MIT License. The KeyRemapper name, logo, app icon, menu bar icon, and artwork in images/ and KeyRemapper/Assets.xcassets/ are not covered by that license and are all rights reserved. Unofficial forks and redistributions must use their own branding and must not imply endorsement or affiliation.
{ "profiles": [ { "name": "My profile", // optionals, in ms "tapDelay": 200, "holdDelay": 400, "delayUntilRepeat": 300, "keyRepeatInterval": 25, "doubleClickSpeed": 500, "remaps": [], "rules": [], "tests": [] } ] }