Skip to content

About

Keep the keyboard layout of a remote Mac in step with the local one over Jump Desktop. Caps Lock picks the script, Globe picks which Cyrillic.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

keyboard-layout-sync

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.

Русская версия

The problem

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.

What this does

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.

How it works

             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.watcher on Jump activation — you came back to the window, so send the current layout, because anything you changed while elsewhere was never delivered.

Design decisions worth keeping

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.

Install

Requires Homebrew. Run it on each machine:

./install.sh client     # the Mac you type on
./install.sh receiver   # the Mac you reach through Jump

It 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:

  1. 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.
  2. Keyboard → Input Sources: add ABC, Russian — PC, Ukrainian — PC. The identifiers everything is keyed on are com.apple.keylayout.ABC, com.apple.keylayout.RussianWin and com.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.
  3. 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".

Verify

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 here

Client 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.

Known traps

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 it

Check 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.

Limitations

  • 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.httpserver on 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.

Adding a fourth layout

Three places:

  1. 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.
  2. client/init.lua, keyForCurrentLayout — knows three identifiers.
  3. receiver/init.lua, the LAYOUTS table — knows three chords.

Layout of this repo

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.

Troubleshooting

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.swift

Check 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.swift

Exercise 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.swift

Key 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.

License

MIT.

About

Keep the keyboard layout of a remote Mac in step with the local one over Jump Desktop. Caps Lock picks the script, Globe picks which Cyrillic.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages