feat(apple): client-side cursor for gamescope sessions (toggle + shortcut)

gamescope's PipeWire capture carries no cursor (verified upstream — it never
composites the cursor or adds SPA_META_Cursor), so the cursor must be drawn on the
client. New macOS "cursor-visible" capture mode: instead of disassociating+hiding
the system cursor and sending relative deltas (the game path, unchanged), it keeps
the system cursor visible over the stream and sends ABSOLUTE positions
(MouseMoveAbs), mapped through the video's aspect-fit (AVMakeRect) to host pixels
with the letterbox bars dropped. The visible system cursor IS the client cursor —
zero added latency, no double cursor (gamescope draws none), accurate (the client
drives the host's absolute mouse).

- Default: on iff the session's resolved compositor is gamescope (via the new
  punktfunk_connection_compositor getter, 1db9445).
- Settings: "Cursor in stream" → Auto (gamescope) / Always / Never.
- Shortcut: ⌘⇧C toggles it live mid-session (re-engages capture so disassociation
  + abs/rel forwarding swap atomically); shown in the HUD.

macOS-only (the visible-cursor mode lives in the macOS StreamView). Verified to
compile + link via xcodebuild Release on the Mac; runtime behavior (cursor landing,
hover forwarding) to be confirmed live. Rust ABI side committed separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:07:15 +00:00
parent 1db9445c01
commit c47462e7ca
6 changed files with 175 additions and 13 deletions
@@ -18,6 +18,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
@AppStorage(DefaultsKey.cursorMode) private var cursorMode = "auto"
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@ObservedObject private var gamepads = GamepadManager.shared
#if os(macOS)
@@ -371,6 +372,24 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
#if os(macOS)
Section {
Picker("Cursor in stream", selection: $cursorMode) {
Text("Auto (gamescope)").tag("auto")
Text("Always").tag("always")
Text("Never").tag("never")
}
} header: {
Text("Cursor")
} footer: {
Text("Show the local system cursor over the stream instead of capturing it. "
+ "gamescope's capture carries no cursor, so the client draws its own — "
+ "Auto turns this on only for gamescope sessions. ⌘⇧C toggles it live "
+ "during a session.")
.font(.caption)
.foregroundStyle(.secondary)
}
#endif
Section {
Picker("Presenter", selection: $presenter) {
Text("Stage 1 (default)").tag("stage1")
@@ -39,6 +39,11 @@ struct StreamHUDView: View {
: "Click the stream to capture input")
.font(.caption2)
.foregroundStyle(.secondary)
// The client-side cursor (C) draws the local cursor over the stream instead of
// capturing it the only accurate cursor for gamescope, whose capture has none.
Text("⌘⇧C toggles the on-screen cursor")
.font(.caption2)
.foregroundStyle(.secondary)
#elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse.
Text(model.mouseCaptured