diff --git a/clients/apple/Sources/PunktfunkKit/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/InputCapture.swift index dec02f6..3379787 100644 --- a/clients/apple/Sources/PunktfunkKit/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/InputCapture.swift @@ -107,23 +107,6 @@ public final class InputCapture { /// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue. public var gcMouseForwarding = false - #if os(iOS) - /// Whether any device is attached as a `GCMouse` right now. The Magic Keyboard TRACKPAD does - /// not always register as a GCMouse on iPadOS (only a standalone mouse does) — when no GCMouse - /// is present the relative GCMouse path can't carry pointer motion. Main-queue. - public var hasGCMouse: Bool { !mice.isEmpty } - - /// Diagnostic: a one-line description of every attached GCMouse (count + GCDevice identity), so - /// PUNKTFUNK_INPUT_DEBUG can reveal whether the trackpad showed up as a mouse at all. - public var attachedMiceSummary: String { - guard !mice.isEmpty else { return "0 mice" } - let parts = mice.map { mouse -> String in - "\(mouse.productCategory)/\(mouse.vendorName ?? "?")" - } - return "\(mice.count) mice: \(parts.joined(separator: ", "))" - } - #endif - /// Fired on ⌘⎋ (the capture toggle — detected here so it works in both states; the /// event itself is swallowed). Main queue. public var onToggleCapture: (() -> Void)? @@ -177,7 +160,13 @@ public final class InputCapture { previous.onPreempted?() } Self.activeCapture = self - if let mouse = GCMouse.current { attach(mouse: mouse) } + // Attach EVERY connected mouse, not just GCMouse.current. With two pointing devices (e.g. + // the iPad's own Magic Keyboard trackpad AND a Universal Control "V-UC Automouse"), only one + // is `current` at a time; attaching just that one left the OTHER device's motion handler + // uninstalled, so moving it did nothing. Each GCMouse delivers its own deltas through its own + // handler, so handling all of them lets either device drive. New arrivals are caught by the + // GCMouseDidConnect observer below. + for mouse in GCMouse.mice() { attach(mouse: mouse) } if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) } observers.append(NotificationCenter.default.addObserver( forName: .GCMouseDidConnect, object: nil, queue: .main @@ -411,12 +400,6 @@ public final class InputCapture { !mice.contains(where: { $0 === mouse }) // re-delivered on wake — attach once else { return } mice.append(mouse) - #if os(iOS) - if inputDebug { - inputLog.debug( - "GCMouse attached: \(mouse.productCategory, privacy: .public)/\(mouse.vendorName ?? "?", privacy: .public) — now \(self.attachedMiceSummary, privacy: .public)") - } - #endif // macOS drives motion + buttons from NSEvent (StreamLayerView's local monitor → // sendMotion/sendMouseButton) because GCMouse's handlers proved unreliable there; // installing them too would double-send. iOS keeps GCMouse (raw deltas under diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift index 6d4af7c..1ceb8f0 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift @@ -11,13 +11,18 @@ // host mode, so the host's rescale is the identity). // // A hardware mouse/trackpad is a pointer, not a finger. When the scene is pointer-LOCKED -// (full-screen + frontmost iPad) GCMouse delivers raw relative deltas and the system hides -// the cursor — the gaming-grade path. When it CAN'T lock (Stage Manager, not frontmost, -// iPhone) the system shows its own cursor and routes the mouse through UIKit's pointer path: -// hover + indirect-pointer touches, which we forward as ABSOLUTE cursor position (+ buttons) -// so the host cursor tracks the visible local one. We never forward an indirect pointer as a -// touch — doing so hid the cursor and made the host see taps instead of a moving mouse. -// GCMouse is gated off whenever the lock isn't held so the two paths can't double-send. +// (full-screen + frontmost iPad, and the user hasn't disabled pointer capture in Settings — +// see PointerLockChain, which steers the lock request through SwiftUI's hosting controllers) +// GCMouse delivers raw relative deltas and the system hides the cursor — the gaming-grade path. +// InputCapture handles EVERY connected mouse (GCMouse.mice), not just the current one, so a +// trackpad + a second pointer (e.g. a Universal Control mouse) both drive. When the scene CAN'T +// lock (Stage Manager, not frontmost, iPhone, capture disabled) the system shows its own cursor +// and routes the mouse through UIKit's pointer path: hover + indirect-pointer touches, which we +// forward as ABSOLUTE cursor position (+ buttons) so the host cursor tracks the visible local one. +// We never forward an indirect pointer as a touch — doing so hid the cursor and made the host see +// taps instead of a moving mouse. The two paths are mutually exclusive on `gcMouseForwarding` +// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND +// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send. // Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming // starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here). // @@ -236,32 +241,24 @@ public final class StreamViewController: UIViewController { guard self?.captureEnabled == true else { return } connection?.send(event) } - // Indirect pointer (mouse/trackpad with no lock) → absolute cursor + buttons, routed - // through InputCapture so the forwarding gate and release-on-blur apply uniformly. + // Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll. + // While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so + // the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a + // mouse BOTH report through GCMouse under lock and ALSO emit UIKit indirect-pointer events + // (pinned at the locked position) — without this gate a click double-sends (GCMouse + UIKit) + // and a second pointer (e.g. a Universal Control mouse) competes with the trackpad. The gate + // is the exact mirror of the GCMouse handlers, which fire only while locked. streamView.onPointerMoveAbs = { [weak self] p in - guard let self else { return } - if iosInputDebug { - // Whether ANY UIKit pointer movement reaches us while the scene is LOCKED tells us - // if the trackpad (which may not be a GCMouse) can still be captured via UIKit. - iosInputLog.debug( - "UIKit pointer move x=\(p.x, privacy: .public) y=\(p.y, privacy: .public) locked=\(self.pointerLockEngaged() == true, privacy: .public) gcFwd=\(self.inputCapture?.gcMouseForwarding == true, privacy: .public)") - } + guard let self, self.inputCapture?.gcMouseForwarding == false else { return } self.inputCapture?.sendMouseAbs( x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h) } streamView.onPointerButton = { [weak self] button, down in - self?.inputCapture?.sendMouseButton(button, pressed: down) + guard let self, self.inputCapture?.gcMouseForwarding == false else { return } + self.inputCapture?.sendMouseButton(button, pressed: down) } - // Trackpad two-finger / wheel scroll → host scroll. The pan recognizer is the - // UNLOCKED regime; while locked, GCMouse's scroll handler owns it — mirror the - // sendMouseAbs !gcMouseForwarding gate so the two can't double-send. streamView.onScroll = { [weak self] dx, dy in - guard let self else { return } - if iosInputDebug { - iosInputLog.debug( - "UIKit scroll dx=\(dx, privacy: .public) dy=\(dy, privacy: .public) locked=\(self.pointerLockEngaged() == true, privacy: .public)") - } - guard self.inputCapture?.gcMouseForwarding == false else { return } + guard let self, self.inputCapture?.gcMouseForwarding == false else { return } self.inputCapture?.sendScroll(dx: dx, dy: dy) } @@ -472,7 +469,7 @@ public final class StreamViewController: UIViewController { pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state if iosInputDebug { iosInputLog.debug( - "pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public) useGCMouse=\(useGCMouse, privacy: .public) [\(self.inputCapture?.attachedMiceSummary ?? "n/a", privacy: .public)]") + "pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)") } } #endif