From 9aa2d71f49b2b0b28417439efd0f786dae3d435a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 15:38:03 +0200 Subject: [PATCH] fix: hide + freeze the local cursor while streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host renders its own cursor from our raw deltas, so the local macOS cursor both stays visible and drifts away from the remote one — and it can wander out of the window, where a click focuses another app. While the stream has focus, do what Moonlight does: warp the cursor mid-view, disconnect it from mouse movement (CGAssociateMouseAndMouseCursorPosition(false) — GCMouse still delivers raw HID deltas), and hide it. Released on app deactivation (Cmd+Tab is the escape hatch), view teardown, and disconnect; re-captured when the stream regains focus. The HUD's Disconnect gains ⌘D since a hidden, frozen cursor can't click it. Co-Authored-By: Claude Fable 5 --- clients/apple/README.md | 9 ++- .../Sources/PunktfunkClient/ContentView.swift | 5 +- .../Sources/PunktfunkKit/StreamView.swift | 61 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/clients/apple/README.md b/clients/apple/README.md index 09c8960..4687983 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -103,9 +103,12 @@ PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli doesn't persist fingerprints yet — add it alongside the "add host" UX. 8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus — on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so - nothing sticks down host-side. Local shortcuts (⌘-anything) still also reach the host; - a capture toggle is a small follow-up. One live capture per process (the GC mouse/ - keyboard singletons have a single handler slot — ownership is tracked so a stale + nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden + and frozen mid-view (`CursorCapture` in StreamView.swift — the host renders its own + cursor; the local one diverges from it and a stray click would focus another app); + Cmd+Tab frees it, ⌘D disconnects. Local shortcuts (⌘-anything) still also reach the + host; a capture toggle is a small follow-up. One live capture per process (the GC + mouse/keyboard singletons have a single handler slot — ownership is tracked so a stale capture's stop() can't clobber a newer one). 9. **iOS**: same package (`BUILD_IOS=1` for the xcframework slice); `StreamView` needs the `UIViewRepresentable` twin and touch→input mapping. diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index ae448b5..7e0b34d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -65,8 +65,11 @@ struct ContentView: View { VStack(alignment: .trailing, spacing: 4) { Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") .font(.system(.caption, design: .monospaced)) - Button("Disconnect") { model.disconnect() } + // ⌘D because the local cursor is hidden+frozen while streaming — the button + // can't be clicked. (Cmd+Tab away also frees the cursor.) + Button("Disconnect (⌘D)") { model.disconnect() } .font(.caption) + .keyboardShortcut("d", modifiers: .command) } .padding(8) .background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index c112e43..ece30aa 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -9,9 +9,39 @@ // UIViewRepresentable. #if os(macOS) +import AppKit import AVFoundation import SwiftUI +/// Hides the LOCAL cursor while streaming. The host renders its own cursor, and the local +/// one both diverges from it (the host applies acceleration/clamping to our raw deltas) +/// and can wander out of the window — a click there would focus another app. So while the +/// stream has focus we do what Moonlight does: warp the cursor into the view, freeze it +/// (`CGAssociateMouseAndMouseCursorPosition(false)` — GCMouse still delivers raw HID +/// deltas), and hide it. hide/unhide and associate are balanced via `captured`. +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 } + // 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) + NSCursor.hide() + captured = true + } + + func release() { + guard captured else { return } + CGAssociateMouseAndMouseCursorPosition(1) + NSCursor.unhide() + captured = false + } +} + public struct StreamView: NSViewRepresentable { private let connection: PunktfunkConnection private let onFrame: (@Sendable (AccessUnit) -> Void)? @@ -68,16 +98,44 @@ public final class StreamLayerView: NSView { private let displayLayer = AVSampleBufferDisplayLayer() private var token: PumpToken? public private(set) var connection: PunktfunkConnection? + private let cursorCapture = CursorCapture() + private var appObservers: [NSObjectProtocol] = [] public override init(frame: NSRect) { super.init(frame: frame) displayLayer.videoGravity = .resizeAspect layer = displayLayer // layer-hosting: assign before wantsLayer wantsLayer = true + // The cursor comes back whenever the app loses focus (Cmd+Tab is the escape + // hatch) and is re-captured when the stream regains it. + appObservers.append(NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.cursorCapture.release() + }) + appObservers.append(NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.captureCursorIfStreaming() + }) } public required init?(coder: NSCoder) { fatalError("not used") } + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + cursorCapture.release() + } else { + captureCursorIfStreaming() + } + } + + private func captureCursorIfStreaming() { + guard token != nil, NSApp.isActive else { return } + cursorCapture.capture(in: self) + } + /// Pump thread: pull AUs from the connection, wrap, enqueue. The first IDR yields the /// format description; non-IDR AUs before it are dropped (the host opens with an IDR). public func start( @@ -126,17 +184,20 @@ public final class StreamLayerView: NSView { thread.name = "punktfunk-pump" thread.qualityOfService = .userInteractive thread.start() + captureCursorIfStreaming() } /// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with /// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump). public func stop() { + cursorCapture.release() token?.cancel() token = nil connection = nil } deinit { + appObservers.forEach(NotificationCenter.default.removeObserver(_:)) token?.cancel() } }