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
@@ -98,6 +98,15 @@ public final class InputCapture {
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
public private(set) var forwarding = false
/// iPad pointer routing (the StreamViewController mirrors the scene's live pointer-lock
/// state into this). GCMouse only delivers relative deltas + buttons while the scene is
/// LOCKED, so this is true then and the GCMouse handlers forward. When the scene can't
/// lock (Stage Manager, not frontmost, iPhone) the iPad routes the mouse through UIKit's
/// pointer path as ABSOLUTE moves (`sendMouseAbs`) instead so this goes false, gating
/// GCMouse off and enabling the absolute path, the two never double-sending. Moot on
/// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue.
public var gcMouseForwarding = false
/// Fired on (the capture toggle detected here so it works in both states; the
/// event itself is swallowed). Main queue.
public var onToggleCapture: (() -> Void)?
@@ -257,6 +266,17 @@ public final class InputCapture {
#endif
}
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
/// itself and the button would otherwise stick until the next `releaseAll` (blur / stop).
public func releaseMouseButtons() {
for button in pressedButtons {
connection.send(.mouseButton(button, down: false))
}
pressedButtons.removeAll()
}
private func sendButton(_ button: UInt32, pressed: Bool) {
guard forwarding else { return }
if button == suppressedButton {
@@ -365,7 +385,7 @@ public final class InputCapture {
// pointer lock). See the file header.
#if !os(macOS)
input.mouseMovedHandler = { [weak self] _, dx, dy in
guard let self, self.forwarding else { return }
guard let self, self.forwarding, self.gcMouseForwarding else { return }
// GC gives +y up; the host expects screen-space (+y down).
let fx = dx + self.residualX
let fy = -dy + self.residualY
@@ -387,28 +407,40 @@ public final class InputCapture {
}
}
}
// Buttons take the GCMouse path only while the scene is pointer-locked; when it
// isn't, the UIKit indirect-pointer path carries them (gcMouseForwarding gates here
// so the two can't double-send).
input.leftButton.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(1, pressed: pressed)
guard let self, self.gcMouseForwarding else { return }
self.sendButton(1, pressed: pressed)
}
input.rightButton?.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(3, pressed: pressed)
guard let self, self.gcMouseForwarding else { return }
self.sendButton(3, pressed: pressed)
}
input.middleButton?.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(2, pressed: pressed)
guard let self, self.gcMouseForwarding else { return }
self.sendButton(2, pressed: pressed)
}
// First two side buttons GameStream X1/X2.
if let aux = input.auxiliaryButtons {
for (i, button) in aux.prefix(2).enumerated() {
button.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(UInt32(4 + i), pressed: pressed)
guard let self, self.gcMouseForwarding else { return }
self.sendButton(UInt32(4 + i), pressed: pressed)
}
}
}
// Scroll WHEEL (plain HID mice) while pointer-locked: GCMouse's scroll dpad reports
// wheel deltas here, +y up / +x right already the host's WHEEL convention, one unit
// per notch ×120 (WHEEL_DELTA), residual-accumulated by sendScroll. (Trackpad
// two-finger scrolling is gesture-based and does NOT reach GameController that
// arrives via the stream view's scroll pan recognizer; on macOS, via scrollWheel.)
input.scroll.valueChangedHandler = { [weak self] _, dx, dy in
guard let self, self.forwarding, self.gcMouseForwarding else { return }
self.sendScroll(dx: dx * 120, dy: dy * 120)
}
#endif
// NOTE: no scroll handler here. GCMouse's scroll dpad only fires for plain HID
// wheel deltas trackpad/Magic Mouse scrolling is gesture-based and never
// reaches GameController. Scroll arrives via the stream view's scrollWheel
// override (NSEvent covers wheels too) sendScroll().
}
/// Forward relative mouse motion (macOS). Fed by StreamLayerView's NSEvent monitor
@@ -440,6 +472,18 @@ public final class InputCapture {
}
}
/// Forward an ABSOLUTE cursor position (iPad pointer fallback). Fed by the iOS stream
/// view's hover / indirect-pointer path when the scene can't pointer-lock: the host
/// places its cursor at this client-surface pixel the same letterbox mapping the touch
/// path uses. Gated by `forwarding` AND `!gcMouseForwarding` (the relative GCMouse path
/// owns motion while locked), so absolute and relative motion never both fire. No residual
/// accumulation the value is absolute, not a delta.
public func sendMouseAbs(x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32) {
guard forwarding, !gcMouseForwarding else { return }
connection.send(.mouseMoveAbs(
x: x, y: y, surfaceWidth: surfaceWidth, surfaceHeight: surfaceHeight))
}
/// Forward a scroll gesture, WHEEL_DELTA(120)-scaled (positive = up / right,
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).