When you drive one Mac from another with Jump Desktop, Korean input can break like this:
expected: 잘되나 이렇게 뜨네 actual: ㅈㅏㄹㄷㅗㅣㄴㅏ ㅇㅣㄹㅓㅎㄱㅔㄸㅡㄴㅔ
Every syllable falls apart into its component letters. And there’s a second, more constant annoyance: Caps Lock never reaches the remote machine from inside the Jump window, so switching languages means clicking the remote menu bar with your mouse — once outside, once inside, every single time.
This post collapses those two actions into one Caps Lock press. The technique applies to any IME-based language — Japanese, Chinese — not just Korean.
First — why this happens at all
Typing Korean happens in two stages. ① Your keystrokes accumulate as jamo (ㄱ, ㅏ…), then ② those jamo combine into a finished syllable. That in-between state — letters gathered but not yet merged — is called pre-edit.
The catch is that Jump’s wire protocol has nowhere to put a pre-edit state. So when your local Mac is in Korean, Jump has no choice but to ship each jamo separately, as if each one were already a finished character. The receiving side never gets a chance to merge them.
local in Korean → sends "ㄷ" "ㅗ" "ㅣ" as separate characters → ㄷㅗㅣ ❌
local in English → sends the raw keys d k s u → 안녕 ✅
(the remote Mac's IME does the merging)
Inspect the actual key events and it shows plainly: in Korean mode every keycode arrives as a fake 49, because these aren’t real keypresses but synthesized “insert this character” instructions. In English mode the genuine keycodes (d, k…) pass straight through.
Which leaves exactly one rule to satisfy.
While typing into Jump, the local Mac must be in English and the remote Mac in Korean.
Let the remote do the composing.
Everything below is just how to make one Caps Lock press set both of those values. If a step looks intimidating, remember that this single sentence is the whole objective.
The answer: stop sending keys to the remote
The intuitive fix is to forward Caps Lock (or F13, or whatever) to the remote so its IME toggles. I built exactly that. It fails. Key delivery turns out to be intermittent — you press, and sometimes nothing happens on the other side.
What actually works is sending the remote a command instead of a keystroke:
Caps Lock inside Jump → script flips the local input source
→ SSH tells the remote to match
Caps Lock outside Jump → Ctrl+Space (normal local switch)
Jump’s keyboard path is out of the picture entirely, so its reliability stops mattering.
Stuck? Just hand this to your AI agent
Swift compilation, SSH ControlMaster, Karabiner JSON — that’s a lot of unfamiliar surface. You don’t need to understand any of it.
Copy this post’s URL, hand it to your coding agent (Claude Code, Codex, Cursor), and say:
Set this up on my Mac following this article. I’m on the controlling (viewer) Mac that drives another Mac through Jump Desktop. First check Karabiner’s driver approval with
systemextensionsctl list | grep pqrs, and back upkarabiner.jsonbefore changing anything. Ask me for the remote address and account.
This article is written to work as a human walkthrough and as a spec an agent can execute. Paths, commands, verification steps, and rollback are all here.
Two things are worth stating explicitly to the agent. Which machine it’s working on — the viewer, not the remote; confusing them wastes hours fixing the wrong computer. And verify with bytes that arrive, not with what the screen says. On this problem the menu bar indicator lies.
What you need
- Tailscale on both Macs, same account. No port forwarding, no public IP
- Remote Login (SSH) enabled on the remote — System Settings → General → Sharing
- Karabiner-Elements on the viewer only. It’s the only way to intercept Caps Lock before the local IME sees it
Karabiner needs its DriverKit extension approved, or your rules silently do nothing:
systemextensionsctl list | grep pqrs → activated enabled good → activated waiting for user approve in System Settings → Privacy & Security
Step 1: a tiny input-source tool
macOS ships no CLI for switching input sources. Thirty lines of Swift covers it. Build it on both machines.
import Carbon
import Foundation
func currentID() -> String {
guard let s = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceID) else { return "?" }
return Unmanaged<CFString>.fromOpaque(p).takeUnretainedValue() as String
}
func select(_ target: String) -> Bool {
guard let list = TISCreateInputSourceList(nil, false)?.takeRetainedValue()
as? [TISInputSource] else { return false }
for s in list {
guard let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceID) else { continue }
if (Unmanaged<CFString>.fromOpaque(p).takeUnretainedValue() as String) == target {
return TISSelectInputSource(s) == noErr
}
}
return false
}
let args = CommandLine.arguments
if args.count > 1 { _ = select(args[1]) }
print(currentID())
swiftc -O tis.swift -o ~/bin/tis
Step 2: keep the SSH connection warm
A fresh SSH handshake per keypress costs over a second. ControlMaster reuses the connection and brings it down to ~270ms round trip, which is the difference between usable and infuriating. In the viewer’s ~/.ssh/config:
Host jumpremote
HostName 100.x.x.x # Tailscale address
User <remote account>
IdentityFile ~/.ssh/id_ed25519
ControlMaster auto
ControlPath ~/.ssh/cm/%r@%h:%p
ControlPersist 10m
BatchMode yes
ServerAliveInterval 30
Install key auth too — a password prompt would hang the script:
mkdir -p ~/.ssh/cm && chmod 700 ~/.ssh/cm ssh-copy-id -i ~/.ssh/id_ed25519.pub <remote account>@100.x.x.x
Step 3: the sync script
The important detail is that this sets rather than toggles. If you toggle the remote, any drift between the two machines persists forever. Reading the local value and forcing the remote to match means a single press realigns them.
#!/bin/bash
# ~/bin/hanyeong-sync.sh
TIS=$HOME/bin/tis
ABC="com.apple.keylayout.ABC"
KOR="com.apple.inputmethod.Korean.2SetKorean"
cur=$("$TIS" | /usr/bin/sed -n 's/^current: //p')
case "$cur" in
*Korean*|*Hangul*) target="$ABC" ;;
*) target="$KOR" ;;
esac
"$TIS" "$target" >/dev/null # local
/usr/bin/ssh jumpremote "~/bin/tis $target" # remote, same value
Step 4: the Karabiner rule
Add this to complex_modifications.rules in ~/.config/karabiner/karabiner.json. All it does is branch on whether Jump is frontmost.
{
"description": "Caps Lock = language (in Jump: local+remote, outside: local only)",
"manipulators": [
{
"type": "basic",
"from": { "key_code": "caps_lock" },
"to": [{ "shell_command": "/Users/<you>/bin/hanyeong-sync.sh" }],
"conditions": [{
"type": "frontmost_application_if",
"bundle_identifiers": ["^com\\.p5sys\\.jump\\.mac\\.viewer\\.web$"]
}]
},
{
"type": "basic",
"from": { "key_code": "caps_lock" },
"to": [{ "key_code": "spacebar", "modifiers": ["left_control"] }]
}
]
}
Confirm it parsed. core_configuration is updated. means Karabiner accepted the file:
tail -1 /var/log/karabiner/core_service.log
Four traps — I fell into all of them
1. Ctrl+Space never reaches the local machine inside Jump
This one cost me the most time. When Jump is frontmost it captures Ctrl+Space and forwards it to the remote, so the local input source never changes. The indicator can still appear to move, which makes it worse.
22:57:32 local=EN ← pressed Caps Lock 22:57:38 local=EN ← pressed again, still unchanged
Hence the TIS API for the local half inside Jump. Outside Jump, Ctrl+Space is fine.
2. Pinning the viewer to English kills Korean entirely
“Keep the local IME out of the way by forcing English, and let the remote IME compose” sounds right. It removes the only path to Korean. Add a watcher that re-forces English whenever Jump gains focus, and you get flawless English and permanently broken Korean.
I built that state, then concluded Korean input through Jump was structurally impossible. I had broken a working feature myself and then declared it unfixable. When something that used to work stops working, suspect your own changes first.
3. Per-app input source memory doesn’t apply to Jump
macOS can remember an input source per application via TextInputGlobalPropertyPerContextInput. It does not attach to Jump Desktop. Restart the app, enable the setting, test the round trip — no restore.
The reason is that Jump captures the keyboard wholesale and never creates a standard text input context. There’s nothing for the setting to bind to. Note also that this value lives in two domains — -g and com.apple.HIToolbox — so checking only one misleads you.
4. Hammerspoon can’t press the key for you
Synthetic events from hs.eventtap.keyStroke do not trigger macOS system hotkeys. Send a synthetic Ctrl+Space in Finder and the input source won’t budge. Karabiner injects through a virtual HID device, so its output is indistinguishable from a physical press — that’s the only path you can trust.
Because of this, I once tried to test “does Jump capture Ctrl+Space?” with synthetic events and reached the opposite conclusion. This class of question can only be settled with real keystroke records.
Verify with bytes, not with the screen
This is the biggest lesson from the whole exercise. Both the menu bar indicator and defaults read reported “Korean” while English was actually being transmitted — repeatedly. Only the bytes that arrive on the remote are trustworthy.
On the remote, run cat > /tmp/typed.txt, type rk, then read it back over SSH:
hexdump -C /tmp/typed.txt ea b0 80 0a → 가 U+AC00 ✅ working 72 6b 0a → rk ❌ remote is in English ㄱㅏ → ❌ syllables decomposed
A terminal is the cleanest place to measure this — it receives IME composition with the least interpretation. Confirm in your real app afterwards.
Instrument it — one line pays for itself
Append one line to the script and you can see, with timestamps, whether the rule fired, whether the local side changed, and what the remote returned:
echo "$(date '+%H:%M:%S') local=$after remote=$out" >> ~/bin/hanyeong.log
The local=EN pair above came from exactly this. Without it, the Ctrl+Space capture would have stayed hidden much longer.
Things to know
- If Tailscale drops, remote switching stops. Fall back to clicking the remote menu bar; it recovers automatically when the link returns
- The standalone Tailscale build is the connection. There’s no separate daemon — quitting the GUI kills your SSH
- Karabiner’s
shell_commandruns with almost no environment. Use absolute paths, always - Don’t use
optional: ["any"]infrom. It carries modifiers along and causes select-all and cursor jumps on the remote - Don’t diagnose with Karabiner-EventViewer running. It temporarily disables Karabiner’s modifications, so a healthy setup looks broken
- If the remote Mac sleeps, everything stops. macOS defaults to sleeping after a minute even on AC power, so it drops off as soon as whatever was keeping it busy finishes. Run
sudo pmset -c sleep 0 disksleep 0on the remote (AC only — battery behaviour stays untouched) - There’s no server software to install on the remote. SSH (22) and Screen Sharing (5900) are both built into macOS — enable them under System Settings → General → Sharing
- Tailscale means never touching your router. Port forwarding plus DDNS also works, but your public IP drifts and port 22 ends up exposed to the internet. Tailscale gives you a stable address and a direct link between just the two machines (~16 ms round trip in practice)
- Don’t wrap an SSH tunnel around Tailscale. The whole path is already encrypted, so it’s redundant — point Screen Sharing straight at
100.x.x.x:5900. The exception: ports bound only to127.0.0.1on the remote (dev servers and the like) stay unreachable over Tailscale and still needssh -L
Bonus: if the app behaves strangely
If you launch Jump Desktop straight out of ~/Downloads, macOS runs it under app translocation — a random read-only path that changes every launch. Input Monitoring and Accessibility grants can never stick.
ps -Ao pid,comm | grep "Jump Desktop.app/Contents/MacOS" → if the path contains AppTranslocation, that's your problem
Move it to /Applications and clear the quarantine flag. On recent macOS xattr -dr sometimes doesn’t take, so making an attribute-free copy is more reliable:
ditto --noextattr --norsrc /Applications/"Jump Desktop.app" /Applications/"Jump Desktop-clean.app" codesign --verify --deep --strict /Applications/"Jump Desktop-clean.app" # verify before swapping
When it breaks again — work down this list
Sooner or later a setup that worked for weeks stops working. It’s rarely the configuration — it’s almost always a link that dropped. Check in this order; the top entries are the ones that actually happen.
- Did the remote Mac fall asleep? — the overwhelming favourite. Wake it, and if you haven’t run
sudo pmset -c sleep 0 disksleep 0yet, do it now - Is Tailscale actually running? — on the standalone build, quitting the app kills the link. Check both machines
- Does SSH connect? —
ssh jumpremote 'echo ok'returningokmeans the channel is fine - Is Karabiner still approved? —
systemextensionsctl list | grep pqrsmust readactivated enabled. macOS updates sometimes revoke it - Is the rule even firing? —
tail ~/bin/hanyeong.log. No new lines means Karabiner; new lines that all saylocal=ENmeans the input-source switch - Finish with bytes — use the
hexdumpcheck from the Verify section above
Don’t reach for lsof when checking whether a port is open — without sudo it misses root-owned sockets and reports open ports as closed. Use nc -z 100.x.x.x 22 or netstat -an -p tcp | grep LISTEN.
Or just hand it to your AI agent
Give it this article’s URL along with the text below — it’s written to walk the six steps in order.
Typing Korean into my remote Mac through Jump Desktop suddenly stopped working. This article has both the setup and a recurrence checklist. I’m on the viewer (controlling) Mac and the setup is already in place.
Walk the “When it breaks again” list from step 1: remote sleep → Tailscale → SSH → Karabiner approval →~/bin/hanyeong.log→ arriving bytes.
Don’t trust the menu bar indicator ordefaults read— judge by the bytes that actually reach the remote. Usencrather thanlsoffor port checks. Back up any config file before editing it, and tell me which step the chain broke at.
Once it finds the culprit, hand it the original setup prompt near the top of this article. Both prompts pin down which machine you’re on and verify by bytes, because skipping either one leads to fixing the wrong machine — or declaring victory over something that was never fixed.
Closing
IME problems in remote desktop tools invite you to ask “how do I deliver this keystroke?” But if the delivery path is unreliable, everything built on top of it fails intermittently. When the two machines already share another channel, sending a command beats sending a key.
And when you debug, don’t trust the status indicator. On this problem it disagreed with reality more than once, and each time I fixed the wrong thing.
댓글 남기기