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