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.
|
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 —
|
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
|
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;
|
nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden
|
||||||
a capture toggle is a small follow-up. One live capture per process (the GC mouse/
|
and frozen mid-view (`CursorCapture` in StreamView.swift — the host renders its own
|
||||||
keyboard singletons have a single handler slot — ownership is tracked so a stale
|
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).
|
capture's stop() can't clobber a newer one).
|
||||||
9. **iOS**: same package (`BUILD_IOS=1` for the xcframework slice); `StreamView` needs the
|
9. **iOS**: same package (`BUILD_IOS=1` for the xcframework slice); `StreamView` needs the
|
||||||
`UIViewRepresentable` twin and touch→input mapping.
|
`UIViewRepresentable` twin and touch→input mapping.
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||||
.font(.system(.caption, design: .monospaced))
|
.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)
|
.font(.caption)
|
||||||
|
.keyboardShortcut("d", modifiers: .command)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
|
.background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
|||||||
@@ -9,9 +9,39 @@
|
|||||||
// UIViewRepresentable.
|
// UIViewRepresentable.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftUI
|
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 {
|
public struct StreamView: NSViewRepresentable {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
@@ -68,16 +98,44 @@ public final class StreamLayerView: NSView {
|
|||||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
private var token: PumpToken?
|
private var token: PumpToken?
|
||||||
public private(set) var connection: PunktfunkConnection?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
|
private let cursorCapture = CursorCapture()
|
||||||
|
private var appObservers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
public override init(frame: NSRect) {
|
public override init(frame: NSRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
displayLayer.videoGravity = .resizeAspect
|
displayLayer.videoGravity = .resizeAspect
|
||||||
layer = displayLayer // layer-hosting: assign before wantsLayer
|
layer = displayLayer // layer-hosting: assign before wantsLayer
|
||||||
wantsLayer = true
|
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 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
|
/// 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).
|
/// format description; non-IDR AUs before it are dropped (the host opens with an IDR).
|
||||||
public func start(
|
public func start(
|
||||||
@@ -126,17 +184,20 @@ public final class StreamLayerView: NSView {
|
|||||||
thread.name = "punktfunk-pump"
|
thread.name = "punktfunk-pump"
|
||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
thread.start()
|
thread.start()
|
||||||
|
captureCursorIfStreaming()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
|
cursorCapture.release()
|
||||||
token?.cancel()
|
token?.cancel()
|
||||||
token = nil
|
token = nil
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
appObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
token?.cancel()
|
token?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user