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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user