fix: hide + freeze the local cursor while streaming
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:
@@ -103,9 +103,12 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> 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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user