Skip to content

Add software-KVM: drive the receiver's native desktop from the sender's keyboard/mouse#85

Open
DrDavidL wants to merge 7 commits into
swellweb:mainfrom
DrDavidL:codex/kvm-control
Open

Add software-KVM: drive the receiver's native desktop from the sender's keyboard/mouse#85
DrDavidL wants to merge 7 commits into
swellweb:mainfrom
DrDavidL:codex/kvm-control

Conversation

@DrDavidL
Copy link
Copy Markdown
Contributor

@DrDavidL DrDavidL commented Jun 1, 2026

Summary

Adds a software KVM to TargetBridge: a sender-side toggle, "Control receiver Mac (keyboard & mouse)", that re-routes the sender's physical keyboard/mouse to the receiver Mac's native macOS desktop over the existing session — then back to the streamed display. Synergy / Universal-Control-style, on the link-local connection you already have.

Verified working end-to-end on real hardware (MacBook Pro sender → OCLP iMac receiver): toggle on → the receiver window minimizes to expose its native desktop → the MacBook keyboard/mouse drive the iMac (move / click / type / ⌘-shortcuts) → escape chord returns control and streaming resumes.

MVP scope (agreed): relative mouse (receiver owns/clamps the cursor), mouse + keyboard only, the toggle also hides the receiver window to reveal its desktop, and a keyboard escape (Control-Option-Command-K) that always returns control.

How to run it

Receiver (the Mac being controlled):

  1. Build it as an app bundle: TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh → produces build/TargetBridge Receiver.app.
  2. Drag it into /Applications and launch it from Finder. ⚠️ Do not run the bare ./tbreceiver from a terminal/VSCode — a bare-binary launch inherits the terminal's TCC/launch context and silently breaks the link-local data path (stream connects, sends the display profile, then dies with ENOTCONN / NWError 54 reset by peer). This is unrelated to the KVM code — pristine main fails identically from a terminal.
  3. Add TargetBridge Receiver.app to System Settings → Privacy & Security → Accessibility (it injects events into the native session). Injection silently no-ops until granted.

Sender (the Mac you drive from):

  1. Run the app; grant Screen Recording (for streaming) and, when you flip the KVM toggle, Accessibility (for the input tap). Add Input Monitoring too if keys don't come through.
  2. Stream as usual, then flip "Control receiver Mac (keyboard & mouse)." The receiver window minimizes, and you're driving its native desktop. The on-screen indicator shows Control-Option-Command-K to return.

How it works

Protocol (TBMonitorProtocol.swift + receiver proto.h): five new JSON packet types inputControl / inputMouseMove / inputMouseButton / inputScroll / inputKey (0x33–0x37), reusing the existing length-prefixed framing.

Sender (TBKVMController.swift): an active kCGSessionEventTap on a dedicated thread + CFRunLoop (never the main runloop — a slow callback on an active tap would freeze system-wide input). It consumes keyboard/mouse, coalesces mouse-move deltas at ~120 Hz, parks the local cursor, and forwards over the session's NWConnection. Escape chord recognized inside the tap. Un-park failsafes cover tap-disabled re-enable, resign-active, terminate, connection drop, and an atexit cursor re-associate for the crash case. While active it declares local user activity every 30 s so the controlling Mac doesn't idle into the screensaver/lock mid-session (even while passively watching remote video).

Receiver (input.c / main.c): re-synthesizes events via CGEventCreate* + CGEventPost(kCGHIDEventTap, …), owning a virtual cursor clamped to CGMainDisplayID bounds (drag-vs-move by button state). inputControl minimizes/restores the SDL window to reveal/hide the native desktop; modifiers are reset on each KVM toggle so the escape chord can't leave keys stuck. Disconnect auto-exits KVM. Input init is lazy (first KVM engage) so the normal streaming path is identical to a build without KVM.

Commits

  1. 223d000 — the feature (protocol + sender tap/UI + receiver injection/window-hide).
  2. 5beadf6 — lazy receiver input init (keep the non-KVM path identical to a no-KVM build).
  3. 54b859e — drop the ApplicationServices link / AXIsProcessTrusted probe (use CoreGraphics only; receiver Accessibility is granted manually).
  4. 75e747f — spell out the escape chord as Control-Option-Command-K in the indicator.
  5. 20d54b5 — keep the controlling Mac awake while KVM is active (screensaver/lock fix).
  6. 9a512ed — fix a CI type-check timeout (Xcode 16.4) by building the CGEventMask with a loop instead of a 14-term OR-chain.
  7. 39e19b1receiver CPU fix: the main loop busy-spun a core during streaming (constant fan); now poll()s the socket with a short timeout — same latency, idle CPU drops to ~nothing. Pre-existing behavior, not KVM-specific, but rides along here.

Known limitations / caveats

  • Run the receiver as the .app, not a bare binary (see above) — the one thing most likely to bite.
  • Ad-hoc signing keys TCC grants to a signature that changes every rebuild, so Screen Recording / Accessibility grants must be re-added after each rebuild. A stable self-signed identity would fix it (follow-up).
  • Receiver Accessibility is added manually (no auto-prompt).

Test plan

  • Both apps build clean; sender + receiver.
  • End-to-end on hardware: control the iMac's native desktop (move/click/type/shortcuts); escape returns control.
  • Screensaver: controlling Mac stays awake during KVM (no mid-session lockout) — fixed in 20d54b5.
  • CI green on macOS-15 (sender Apple Silicon + receiver arm64/x86_64).
  • Receiver CPU (39e19b1): confirm the iMac no longer pegs a core / runs the fan during streaming (rebuild the receiver .app).
  • Failsafe drills: quit sender mid-KVM → local cursor returns (atexit); pull the link → KVM auto-exits; no stuck modifiers after escaping via the chord.
  • Load: SD video + fast mouse during KVM → no input flood, no added stutter.

Out of scope (v2)

Clipboard sharing, drag-and-drop fidelity, double-click timing, multi-display + edge-flip, absolute-coordinate mode, binary input packets, configurable hotkey, stable code-signing for persistent grants.

🤖 Generated with Claude Code

New toggle "Control receiver Mac (keyboard & mouse)" lets the sender's
physical keyboard/mouse drive the receiver iMac's *native* macOS desktop
over the existing session, then toggle back to the streamed display —
Synergy-style, on the link-local connection.

MVP scope (per design): relative mouse (receiver owns/clamps the cursor),
keyboard + mouse only, and the toggle also hides the receiver window to
expose its native desktop. Escape is the ⌃⌥⌘K hotkey (keyboard-only).

Shared protocol (TBMonitorProtocol.swift / proto.h): five new JSON packet
types — inputControl/inputMouseMove/inputMouseButton/inputScroll/inputKey
(0x33–0x37), mirroring the existing length-prefixed framing.

Sender (Swift):
- TBKVMController: an active kCGSessionEventTap on a DEDICATED thread +
  CFRunLoop (never the main runloop — a slow callback there would freeze
  system-wide input). It consumes keyboard/mouse, coalesces mouse-move
  deltas at ~120 Hz, parks the local cursor
  (CGAssociateMouseAndMouseCursorPosition(false) + NSCursor.hide), and
  forwards events over the session's NWConnection (thread-safe send).
- Escape hotkey ⌃⌥⌘K recognized inside the tap (swallowed, not forwarded).
- Un-park failsafes: tap-disabled re-enable, app resign-active, terminate,
  connection drop (session.stop hook), and an atexit cursor re-associate
  for the crash case.
- Service toggle `controlIMacKVM` (transient; targets the connected
  session) + a "Controlling receiver Mac — ⌃⌥⌘K to return" indicator.
- Accessibility prompted via AXIsProcessTrustedWithOptions.

Receiver (C):
- input.c: re-synthesizes events via CGEventCreate*/CGEventPost to
  kCGHIDEventTap; owns a virtual cursor clamped to CGMainDisplayID bounds,
  drag-vs-move by button state, and resets modifiers on each KVM toggle so
  the escape chord can't leave keys stuck. AXIsProcessTrusted check logged.
- main.c: handle the input packets; inputControl hides/restores the SDL
  window (minimize → resign active → reveal native desktop) and resets KVM
  on disconnect.
- Makefile: build input.c, link ApplicationServices (AXIsProcessTrusted).

Both apps build clean. Runtime verification (Accessibility/Input
Monitoring grants on both machines + physical end-to-end) is pending —
see the PR test plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DrDavidL
Copy link
Copy Markdown
Contributor Author

DrDavidL commented Jun 1, 2026

Still working on this...

@DrDavidL DrDavidL closed this Jun 1, 2026
DrDavidL and others added 4 commits May 31, 2026 21:27
The KVM build broke normal streaming on the receiver: the connection
dropped right after the display profile (sender saw NWError 54 reset by
peer; receiver read returned ENOTCONN), while the pre-KVM build connected
fine. Isolation showed the only thing the KVM changes added to the
non-KVM path was a startup tb_input_create() call — which touches the
CoreGraphics HID/Accessibility machinery (CGEventSourceCreate +
AXIsProcessTrusted) before the socket even listens.

Create tb_input lazily, the first time an inputControl{enabled:true}
packet arrives, so the normal streaming path never initializes any input
machinery and is identical to a build without KVM. tb_input_destroy stays
NULL-safe at teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The KVM receiver build breaks normal streaming on the OCLP iMac (connect
drops right after the display profile) while the pre-KVM build streams
fine with the same sender — confirmed deterministic. With input init now
lazy, no KVM code runs on the connect path, so the only remaining
build-level difference is linking the ApplicationServices umbrella for the
single AXIsProcessTrusted() diagnostic call.

Drop ApplicationServices: use CoreGraphics (already linked) for all the
CGEvent*/CGDisplay* injection APIs, and stop probing the Accessibility
grant (injection simply no-ops until the user grants it). This makes the
receiver link the exact same frameworks as a build without KVM, isolating
whether the umbrella link was the cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"⌃⌥⌘K" was misread (the ⌃ Control glyph looked like a caret to type).
Show "Control-Option-Command-K" instead, in all three locales, so the
return-from-KVM shortcut is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
While controlling the receiver, the sender's keyboard/mouse events are
consumed by the tap and forwarded, so the local idle timer sees no
activity and fires the screensaver/lock mid-session — which then trips the
resign-active failsafe, drops KVM, and reverts the receiver to showing the
stream. Worst while passively watching remote video (no local input for
long stretches).

Declare local user activity (IOPMAssertionDeclareUserActivity,
kIOPMUserActiveLocal) on KVM activate and every 30s thereafter, cancelled
on deactivate. The Mac stays awake for the duration of KVM control so the
screensaver/display-sleep never interrupt it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DrDavidL DrDavidL reopened this Jun 1, 2026
CI (Xcode 16.4) failed with "the compiler is unable to type-check this
expression in reasonable time" on TBKVMController's 14-term CGEventMask
OR-chain. Build the mask with a loop over an explicit [CGEventType] array
instead of one giant `|` expression — trivially type-checkable on every
toolchain. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@swellweb
Copy link
Copy Markdown
Owner

swellweb commented Jun 1, 2026

Thanks a lot for working on this — the software-KVM direction is definitely valuable.

I do not want to merge this directly onto main as-is, mainly because the current release work is already happening on feature/input-dockstation, and that branch now carries a lot of input-control / permissions / clipboard / brightness / receiver-master logic on top of newer protocol changes.

So the right path here is to rebase / rework the useful parts against feature/input-dockstation, rather than trying to land this patchset directly on top of main.

The main reason is to avoid regressions and protocol collisions:

  • the current release branch already uses the input-related packet range
  • there is already an Input Dockstation control flow in place
  • permissions / receiver status / clipboard / brightness handling have all moved forward there

So: thank you — this is important work, but the fixes need to be adapted on feature/input-dockstation before they can be considered for the release branch.

@swellweb
Copy link
Copy Markdown
Owner

swellweb commented Jun 1, 2026

Small follow-up: I am actively working through this myself on feature/input-dockstation so we can adapt the KVM pieces against the current release branch carefully and avoid regressions. That is why I would prefer keeping the integration work there instead of landing a parallel direct-to-main variant.

@swellweb
Copy link
Copy Markdown
Owner

swellweb commented Jun 1, 2026

Quick note to keep the integration path clear: any follow-up changes for this work should be opened against feature/input-dockstation, not directly against main or a parallel branch.

That branch already contains the current input-dockstation / receiver-master flow, protocol updates, permission handling, cursor/clipboard behavior, and the ongoing fixes needed to avoid regressions. Keeping the work there is the safest way to converge everything before merge.

The main loop deliberately skipped its idle SDL_Delay while video was
active ("no extra millisecond of latency"), so during any stream it
spun a full CPU core at 100% — the iMac's fan ran constantly. Pre-existing
behavior, not KVM-related, but very visible during long KVM sessions.

Replace the busy-spin with poll() on the client socket (10ms timeout):
it returns the instant a packet is ready (same latency as the busy loop)
and otherwise sleeps, so idle CPU between frames drops to ~nothing while
the per-second housekeeping and quit handling still run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants