Keep the keyboard layout of a remote Mac in step with the Mac in front of you, across a Jump Desktop session — and give Caps Lock and Globe one clear job each.
A remote desktop client forwards your keystrokes. It does not forward your input source. So the two machines keep separate ideas of what language you are typing in, and those ideas drift apart within minutes:
- You switch to Russian locally. The remote is still on English. You type a
sentence, look up, and it is
ghbdtn. - You switch on the remote instead. Now the local machine is the one that is wrong, and every local shortcut misfires.
- You give up and switch on both sides every time, which is two gestures for one intention, forever.
The usual workaround — a "switch to the next input source" hotkey on each side — does not survive contact with reality. "Next" is relative to where each machine currently is, so a single dropped or duplicated event leaves the two lists permanently out of phase, and you are back to guessing.
The second problem is smaller but constant. With three layouts (English, Russian, Ukrainian) on one cycling key, every switch is a gamble on how many presses you need, and you overshoot.
Caps Lock answers one question: Latin or Cyrillic. Globe answers a different one: which Cyrillic. They never compete, and neither of them cycles through a list — each press names its destination.
| key | question it answers | behavior |
|---|---|---|
| Caps Lock | Latin or Cyrillic? | ABC ↔ your last Cyrillic |
| Globe (fn), tapped alone | which Cyrillic? | RU ↔ UA; from ABC, goes to the other one |
| Shift + Caps Lock | — | a real Caps Lock |
| fn + any other key | — | unchanged: F-keys, emoji, dictation |
Every change is mirrored to the remote machine, whichever way you made it —
Caps Lock, Globe, the menu bar, Cmd+Space, or another app's shortcut.
local Mac remote Mac
┌────────────────────────────┐ ┌──────────────────────┐
│ Karabiner-Elements │ │ Hammerspoon │
│ caps → set layout │ │ hotkeys │
│ globe → set layout │ │ Ctrl+Opt+Cmd+1/2/3 │
│ │ │ │ │ │
│ ▼ │ Jump window │ ▼ │
│ Hammerspoon │ │ set layout │
│ inputSourceChanged ─────┼──────────────┼──► │
│ Jump activated ─────┼──────────────┼──► │
└────────────────────────────┘ └──────────────────────┘
The protocol is three chords: Ctrl+Opt+Cmd+1 = English, 2 = Russian,
3 = Ukrainian. Absolute, never relative — that is what keeps the two machines
from drifting apart.
The transport is the focused Jump window itself. Chords ride through its focus; there is no separate connection. Focus is therefore not a UI detail here, it is literally the state of the link: no focus, no delivery.
Which is why the local side has two triggers:
hs.keycodes.inputSourceChanged— the layout changed and Jump is in front: send now.- an
hs.application.watcheron Jump activation — you came back to the window, so send the current layout, because anything you changed while elsewhere was never delivered.
These are the places where the obvious answer is the wrong one. If you fork this and something breaks, start here.
The receiver listens with an event tap, not hs.hotkey.bind. hs.hotkey
registers through Carbon's RegisterEventHotKey, which never sees the synthetic
key events a remote desktop client injects: the chord arrives, and the hotkey
stays silent. An hs.eventtap on the session event stream sees it. This one
costs an afternoon, because nothing fails loudly — the sender's log shows the
chord going out, and the receiver simply says nothing.
Two things follow from that change of layer. The tap is handed a key code,
not a character — at that point the character does not exist yet, it appears
once the layout is applied — so the table is keyed by 18/19/20 rather than
"1"/"2"/"3". And a tap is an observer, not a consumer: it has to
return true to swallow the chord, or the digit is typed into whatever window
is focused.
The "only when Jump is focused" gate lives in Hammerspoon, not in Karabiner.
A frontmost_application_if condition gates the whole manipulator, not one
action inside its to array. Put it on the Caps Lock rules and Caps Lock stops
switching layouts outside Jump entirely — it falls through to a real Caps Lock.
Doing it in Karabiner means duplicating every manipulator, gated and ungated.
And Karabiner only ever sees the keys it intercepted itself, so it cannot know
about a layout change made from the menu bar or by another app.
inputSourceChanged sees all of them. One gate in one place instead of sixteen
conditions.
Karabiner sends no chords. It changes the local layout and nothing else. All mirroring is Hammerspoon's job. Chords emitted from Karabiner would land in every application, not just Jump.
"to": [{ "key_code": "fn", "lazy": true }] on the Globe rules. Without
lazy, a lone tap still hands fn to macOS, whose Globe key defaults to
"Change Input Source" — so the system advances the layout on its own and then
to_if_alone fires, giving you a double hop. lazy means "emit fn only if
another key follows": a lone tap is entirely ours, while fn+F1 still works.
No need to touch the system setting.
KEY_DELAY = 0. That is how long the modifiers are held, in microseconds.
While ctrl+alt+cmd are down, any character you manage to type arrives as
Ctrl+Opt+Cmd+<char>. Hammerspoon's default is 200000 (0.2 s) — exactly the
window a fast typist lands inside.
A 0.4 s debounce. inputSourceChanged can fire twice for one switch.
A 0.3 s delay after Jump activates. The window needs to actually take focus, or the chord goes to the application you just left.
Requires Homebrew. Run it on each machine:
./install.sh client # the Mac you type on
./install.sh receiver # the Mac you reach through JumpIt installs Hammerspoon (and Karabiner-Elements on the client), clears the
quarantine flag on the bundle, drops the config in place and restarts the app.
An existing init.lua is backed up next to itself, never silently replaced.
Then, by hand — no script can do these:
- System Settings → Privacy & Security → Accessibility → enable Hammerspoon. If it is not in the list, use the Hammerspoon menu bar icon → Preferences → Enable Accessibility, which raises the request properly.
- Keyboard → Input Sources: add ABC, Russian — PC, Ukrainian — PC. The
identifiers everything is keyed on are
com.apple.keylayout.ABC,com.apple.keylayout.RussianWinandcom.apple.keylayout.Ukrainian-PC. On the receiver also add plain Russian (com.apple.keylayout.Russian) — that is what it selects for RU, see "Cmd shortcuts dead on a Cyrillic layout" below. - On the client: Karabiner-Elements → Complex Modifications → Add rule → enable both rules from "Layout switching", and the rule from "Jump Desktop: release a captured pointer".
hs -c 'hs.accessibilityState()' # true — permission granted
hs -c 'hs.keycodes.currentSourceID()' # current layout
hs -c 'jumpWatcher ~= nil' # client: the watcher is alive
hs -c 'jumpCaptureTap:isEnabled()' # client: the pointer-capture detector is live
hs -c 'jumpStateTimer:running()' # client: Jump's input toggles are watched
hs -c 'layoutTap:isEnabled()' # receiver: the tap is live
hs -c 'shortcutTap:isEnabled()' # receiver: the shortcut fix is live
tail -f ~/.hammerspoon/diag.log # both sides log hereClient log: sent 3 (layout changed) is a send triggered by a switch,
sent 3 (jump activated) a resend on returning to the window, skip 3 (... debounced) a suppressed duplicate. Receiver log: switched to UA.
End-to-end test: leave Jump, change the layout in another application — try Caps Lock, Globe, and the menu bar, which are three different code paths — then come back to Jump. The remote layout should follow on its own.
Hammerspoon asks for Accessibility but never appears in the list. The
process was launched through a path TCC cannot resolve back to the bundle:
either a path containing ../.., or a bundle still carrying
com.apple.quarantine. Fix:
osascript -e 'tell application "Hammerspoon" to quit'; sleep 2; pkill -x Hammerspoon
xattr -dr com.apple.quarantine /Applications/Hammerspoon.app
tccutil reset Accessibility org.hammerspoon.Hammerspoon
open -a Hammerspoon # through LaunchServices, the way Finder does itCheck how the process was started with ps -Ao pid,comm | grep -i hammerspoon.
The right answer is exactly
/Applications/Hammerspoon.app/Contents/MacOS/Hammerspoon.
hs -c hangs. The first call after the app restarts reliably does not
answer. Kill it and repeat; it responds from then on. Cap it so it cannot hang a
terminal:
pkill -x hs
perl -e 'alarm 8; exec "hs", "-c", $ARGV[0]' 'hs.accessibilityState()'Permission granted but not in effect. Restart Hammerspoon.
Cmd shortcuts dead on a Cyrillic layout (Jump Desktop 10). Letters type
fine, but Cmd+C / Cmd+V do nothing, and in Chrome Cmd+C opens DevTools. Jump 10
sends a shortcut as a character (c) and looks for the key that types it in
the receiver's current layout:
- Ukrainian — PC has no such key, so Jump sends key code 108 (no such key)
carrying
c. Nothing matches it. - Russian — PC has Latin letters on Option, so Jump finds Option+C and sends Cmd+Option+C — the DevTools inspector in Chrome.
Two parts of the receiver config handle this. RU selects plain Russian, which
has no Latin on Option, so it behaves like UA. A second event tap,
shortcutTap, swallows every Cmd/Ctrl + key code 108 and posts a fresh event
for the ANSI key instead. It has to be a fresh event: the c Jump attaches is
enough for AppKit to miss Cmd+C under a Cyrillic layout, even with key code 8.
Typing is unaffected — Jump picks the key for each character, so the client's
layout still decides what comes out.
Jump takes the pointer: no cursor, swipes go to the remote. That is
Remote → Lock Mouse Cursor, bound to Ctrl+Cmd+Esc, easy to hit by accident.
The cursor is confined to the session window and hidden, and four-finger swipes
switch Spaces on the remote. Holding Esc is supposed to leave it; it does not
always. Jump logs nothing about the mode, on either side.
The client config watches for it. jumpCaptureTap notices the pointer getting
movement while its position stays put, shows an alert, and writes
capture: pointer frozen ... to diag.log — with the state of Jump's Lock
Mouse Cursor, Block Local Mouse and macOS Shortcuts items, and what happened
just before. Ctrl+Opt+Cmd+Esc is the way out: Karabiner catches it below
Jump and opens hammerspoon://jump-release, which turns off whichever of Lock
Mouse Cursor / Block Local Mouse is on. If neither is, a second press within
5 s disconnects the session; reconnecting has released it so far.
Block Local Mouse (Ctrl+Opt+Cmd+M) and macOS Shortcuts (Ctrl+Cmd+Left) can
flip by accident too, without freezing anything. So while Jump is in front,
jumpStateTimer polls the three items every 2 s, logs each change as
state: ... -> ... with the Cmd/Ctrl chords that arrived just before it
(jumpChordTap, with the pid that posted each — 0 is a keyboard), and alerts
when a capture item turns on. That log is what tells a stray keypress from a
chord this config sent itself.
- The sync is one-way. The transport is the focused Jump window, and keys
only travel outward. A layout change made on the remote machine does not come
back. Two-way would need a channel of its own — an
hs.httpserveron the client and a request from the far side, say — which does not bolt onto this design. - Hammerspoon has to be running. If it is not, nothing is mirrored.
hs.autoLaunch(true)in the config registers it as a login item. - Microsoft Remote Desktop. If your Karabiner config maps Caps Lock to Right Option inside RDP, Caps Lock never reaches the layout rules there and the chain does not apply. Jump is unaffected.
Three places:
client/karabiner-rules.json— Globe is currently a hard RU↔UA toggle across four manipulators. For three or more Cyrillic layouts, rewrite it to walk all installed non-Latin sources instead.client/init.lua,keyForCurrentLayout— knows three identifiers.receiver/init.lua, theLAYOUTStable — knows three chords.
client/init.lua → ~/.hammerspoon/init.lua on the client
client/karabiner-rules.json → ~/.config/karabiner/assets/complex_modifications/
client/karabiner-jump-rules.json → the same folder, as jump-release.json
receiver/init.lua → ~/.hammerspoon/init.lua on the receiver
install.sh puts all of the above in place
Jump Desktop's bundle id is com.p5sys.jump.mac.viewer.web; the code matches on
the com.p5sys.jump prefix so it survives a change of edition.
Read the layout the machine actually has (defaults read returns a stale cache):
cat > /tmp/getlayout.swift <<'EOF'
import Carbon
if let cur = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let p = TISGetInputSourceProperty(cur, kTISPropertyInputSourceID) {
print(Unmanaged<CFString>.fromOpaque(p).takeUnretainedValue() as String)
}
EOF
swift /tmp/getlayout.swiftCheck whether the combos arrive at the receiver at all. Logs key codes and
modifiers only, never characters. Expect keycode=18|19|20 flags=...:
cat > /tmp/modprobe.swift <<'EOF'
import Cocoa
let cb: CGEventTapCallBack = { _, type, event, _ in
guard type == .keyDown else { return Unmanaged.passUnretained(event) }
let f = event.flags
if !f.intersection([.maskControl, .maskAlternate, .maskCommand]).isEmpty {
print("keycode=\(event.getIntegerValueField(.keyboardEventKeycode)) flags=0x\(String(f.rawValue, radix: 16))")
}
return Unmanaged.passUnretained(event)
}
guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
options: .listenOnly, eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue),
callback: cb, userInfo: nil) else { print("TAP_FAILED"); exit(2) }
CFRunLoopAddSource(CFRunLoopGetCurrent(), CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0), .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { exit(0) }
CFRunLoopRun()
EOF
swift /tmp/modprobe.swiftExercise the receiver without involving the client. Post at session level —
.cghidEventTap does not reproduce how a remote desktop injects events, and a
receiver can appear to work there while failing in practice:
cat > /tmp/sendsession.swift <<'EOF'
import Cocoa
let flags: CGEventFlags = [.maskControl, .maskAlternate, .maskCommand]
let src = CGEventSource(stateID: .combinedSessionState)
if let d = CGEvent(keyboardEventSource: src, virtualKey: 20, keyDown: true),
let u = CGEvent(keyboardEventSource: src, virtualKey: 20, keyDown: false) {
d.flags = flags; u.flags = flags
d.post(tap: .cgSessionEventTap); usleep(60_000); u.post(tap: .cgSessionEventTap)
}
EOF
swift /tmp/sendsession.swiftKey codes: 1 = 18, 2 = 19, 3 = 20, Caps Lock = 57.
If the combos arrive but nothing switches, the receiver is almost certainly
using hs.hotkey instead of an event tap. See the note at the top of
receiver/init.lua.
MIT.