feat(client): cross-target input handling + LAN mDNS discovery

Input handling, building on macOS/iOS/tvOS:
- macOS recapture after navigating out: engageCapture no longer latches
  captured=true when the cursor grab is refused mid app-activation (which left
  a free cursor that no later click could re-grab); cursorCapture.capture() now
  reports success. + canBecomeKeyView.
- iOS/iPadOS recapture: restore the prior capture on didBecomeActive (nothing
  re-grabbed mouse/keyboard on return before).
- iPad indirect pointer (no lock) is forwarded as an absolute MOUSE (move +
  buttons + scroll via hover / UITouch.indirectPointer), not as touch, with the
  local cursor visible; GCMouse owns the locked regime, gated so the two never
  double-send. Adds the MouseMoveAbs wire helper.
- Trackpad scroll on iOS (was entirely missing): GCMouse scroll dpad when
  locked + a scroll-only UIPanGestureRecognizer otherwise.
- tvOS: no focusable control during play (a focusable Disconnect button ate the
  controller's A in the focus engine); Siri Remote Menu disconnects.
- Don't leak touch to the host under the TOFU trust prompt (gate on
  captureEnabled).

LAN discovery: HostDiscovery (NWBrowser over _punktfunk._udp, the host's
crate::discovery advert) resolves each service to IP:port and parses the TXT
(fp advisory, pair, id); an "On this network" section in the grid (tap to save
+ connect, or pair if required). iOS/tvOS get NSBonjourServices via a merged
Config/Info.plist. Integration-tested end to end against a fake NWListener advert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:05:21 +02:00
parent 6b4de5d738
commit 6d3ff37d9e
9 changed files with 723 additions and 83 deletions
@@ -38,16 +38,21 @@ private let streamInputDebug =
private final class CursorCapture {
private var captured = false
func capture(in view: NSView) {
guard !captured, let window = view.window, view.bounds.width > 0 else { return }
/// Returns whether capture actually engaged. It can fail mid app-activation the click
/// that reactivates the app delivers `mouseDown` before the app is frontmost, and
/// `CGAssociateMouseAndMouseCursorPosition` is refused then so the caller must stay
/// released and let the NEXT click retry, never latching a half-captured state.
func capture(in view: NSView) -> Bool {
guard !captured, let window = view.window, view.bounds.width > 0 else { return false }
// Park the cursor mid-view so a click can't land in (and activate) another app.
let rectOnScreen = window.convertToScreen(view.convert(view.bounds, to: nil))
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
CGWarpMouseCursorPosition(
CGPoint(x: rectOnScreen.midX, y: primaryHeight - rectOnScreen.midY))
CGAssociateMouseAndMouseCursorPosition(0)
guard CGAssociateMouseAndMouseCursorPosition(0) == .success else { return false }
NSCursor.hide()
captured = true
return true
}
func release() {
@@ -194,6 +199,10 @@ public final class StreamLayerView: NSView {
/// InputCapture suppresses its press/release toward the host. Clicks while captured
/// are the host's (GC forwards them) nothing to do here.
public override func mouseDown(with event: NSEvent) {
if streamInputDebug {
streamInputLog.debug(
"mouseDown: captureEnabled=\(self.captureEnabled, privacy: .public) captured=\(self.captured, privacy: .public)")
}
if captureEnabled, !captured {
engageCapture(fromClick: true)
return
@@ -239,6 +248,11 @@ public final class StreamLayerView: NSView {
// here as a send; -combos still arrive via performKeyEquivalent and stay functional (D).
// Modifier keys never fire keyDown/keyUp they come through flagsChanged below.
public override var acceptsFirstResponder: Bool { true }
// A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the
// user can re-capture the deliberate design is that becoming active does NOT auto-grab;
// you click into the video. Default NSViews aren't key-view candidates, which can drop
// that first click; opting in keeps the view a valid click/responder target.
public override var canBecomeKeyView: Bool { true }
public override func keyDown(with event: NSEvent) {
if captured {
if let ic = inputCapture, let vk = InputCapture.keyCodeToVK[event.keyCode] {
@@ -285,7 +299,10 @@ public final class StreamLayerView: NSView {
guard captureEnabled, !captured, pump != nil, window != nil,
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
else { return }
cursorCapture.capture(in: self)
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
// frontmost), stay released so the NEXT click retries never latch captured=true over
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
guard cursorCapture.capture(in: self) else { return }
inputCapture?.setForwarding(true, suppressClick: fromClick)
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
// delta (the monitor isn't up yet), and the engage click's suppression latch is