fix: hide + freeze the local cursor while streaming
ci / rust (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:38:03 +02:00
parent bfd64ce871
commit 9aa2d71f49
3 changed files with 71 additions and 4 deletions
@@ -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()
}
}