feat(apple): explicit input-capture state machine — no more cursor grabs on window chrome
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Capture used to engage whenever the app became active, so the click that activates the window — on the title bar (a drag) or a resize edge — got the cursor warped away mid-gesture, and raw deltas kept streaming to the host while the user fought the window. Reworked Moonlight-style, with capture as a deliberate, reversible state owned by StreamLayerView: - Engage: automatically once when the stream starts / trust is confirmed (one-shot, can never fire surprisingly later), or by clicking into the video (that click's press/release are suppressed toward the host; acceptsFirstMouse makes it one click from another app). NEVER on app re-activation. - Release: ⌘⎋ (toggles, key-window-scoped), focus loss — now including same-app window switches (⌘, / ⌘N / ⌘M resign key without resigning the app; previously the new window inherited a hidden frozen cursor and its typing was double-delivered to the host) — and disconnect. - While released: nothing is forwarded (InputCapture.forwarding gates the GC handlers; held keys/buttons are flushed host-side so nothing sticks), the cursor is free, and the HUD (now showing the capture state) is clickable. - The no-beep behavior moved from the NSEvent monitor to first-responder key consumption — swallowing at the monitor risked starving GC's own delivery (the "input broken altogether" report). The monitor now only intercepts ⌘⎋. - Adversarial-review fixes: a second session preempts the previous one cleanly instead of leaving it captured with dead GC handlers (onPreempted); the engage click's suppression latch can't outlive the click (mouseUp backstop); ⌘⎋'s physical Esc can't type into the host in either toggle direction (suppressedVK latch + Esc-while-⌘ guard); capture callbacks defer out of the SwiftUI update pass. Validated live against the box: 16185 input datagrams injected during a captured session (gamescope EIS), title-bar drag/resize free while released, and visible cursor + typing on a streamed KWin desktop, all user-confirmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ struct ContentView: View {
|
||||
return nil
|
||||
}()
|
||||
return ZStack {
|
||||
stream(capturesCursor: pendingFingerprint == nil)
|
||||
stream(captureEnabled: pendingFingerprint == nil)
|
||||
.blur(radius: pendingFingerprint != nil ? 32 : 0)
|
||||
.overlay {
|
||||
if pendingFingerprint != nil {
|
||||
@@ -257,19 +257,22 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Stream
|
||||
|
||||
private func stream(capturesCursor: Bool) -> some View {
|
||||
private func stream(captureEnabled: Bool) -> some View {
|
||||
Group {
|
||||
if let conn = model.connection {
|
||||
StreamView(
|
||||
connection: conn,
|
||||
capturesCursor: capturesCursor,
|
||||
captureEnabled: captureEnabled,
|
||||
onCaptureChange: { [weak model] captured in
|
||||
model?.mouseCaptured = captured
|
||||
},
|
||||
onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) },
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
}
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if capturesCursor { hud(conn) }
|
||||
if captureEnabled { hud(conn) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,8 +282,13 @@ 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))
|
||||
// ⌘D because the local cursor is hidden+frozen while streaming — the button
|
||||
// can't be clicked. (Cmd+Tab away also frees the cursor.)
|
||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.opacity(0.8)
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
|
||||
@@ -53,9 +53,11 @@ final class SessionModel: ObservableObject {
|
||||
@Published var fps = 0
|
||||
@Published var mbps = 0.0
|
||||
@Published var totalFrames = 0
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
|
||||
let meter = FrameMeter()
|
||||
private var inputCapture: InputCapture?
|
||||
private var statsTimer: Timer?
|
||||
|
||||
var isBusy: Bool { phase != .idle }
|
||||
@@ -118,8 +120,6 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
if let conn = connection {
|
||||
@@ -132,6 +132,7 @@ final class SessionModel: ObservableObject {
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
mouseCaptured = false
|
||||
}
|
||||
|
||||
/// Called (via the main actor) when the pump hits end-of-session.
|
||||
@@ -143,11 +144,10 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func beginStreaming() {
|
||||
guard let conn = connection else { return }
|
||||
guard connection != nil else { return }
|
||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||
// flip this phase change causes, released/re-engaged by the user from there).
|
||||
phase = .streaming
|
||||
let capture = InputCapture(connection: conn)
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
|
||||
Reference in New Issue
Block a user