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