Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e088af7ed | |||
| b7a00137eb | |||
| acce43ebbf | |||
| 35923080fb | |||
| 0c9461242c | |||
| cc6b37fef0 | |||
| 1927077cbe | |||
| 068da456f5 | |||
| 256aa5f7f2 | |||
| 2562663fc6 |
@@ -447,14 +447,6 @@ extension SettingsView {
|
|||||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||||
@ViewBuilder var inputSection: some View {
|
@ViewBuilder var inputSection: some View {
|
||||||
Section("Keyboard & mouse") {
|
Section("Keyboard & mouse") {
|
||||||
#if os(macOS)
|
|
||||||
described(mouseModeDescription) {
|
|
||||||
Picker("Mouse input", selection: $mouseMode) {
|
|
||||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
|
||||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
||||||
Picker("Modifier keys", selection: $modifierLayout) {
|
Picker("Modifier keys", selection: $modifierLayout) {
|
||||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||||
@@ -467,20 +459,6 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
|
||||||
private var mouseModeDescription: String {
|
|
||||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
|
||||||
case .capture:
|
|
||||||
return "The pointer locks to the stream and sends relative motion — best for "
|
|
||||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
|
||||||
case .desktop:
|
|
||||||
return "The pointer moves freely in and out of the stream and sends absolute "
|
|
||||||
+ "positions — best for remote desktop work. Unavailable on gamescope hosts."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Audio
|
// MARK: - Audio
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ struct SettingsView: View {
|
|||||||
@State var customMode = false
|
@State var customMode = false
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
|
||||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ public final class InputCapture {
|
|||||||
/// event itself is swallowed). Main queue.
|
/// event itself is swallowed). Main queue.
|
||||||
public var onToggleCapture: (() -> Void)?
|
public var onToggleCapture: (() -> Void)?
|
||||||
|
|
||||||
/// Fired on ⌃⌥⇧M (the mouse-model flip, capture ⇄ desktop — cross-client parity with the
|
/// Fired on ⌘⇧C (the client-side-cursor toggle — flips between the captured/disassociated
|
||||||
/// SDL clients' Ctrl+Alt+Shift+M; detected here, like ⌘⎋, so it works regardless of the
|
/// relative path and the visible-cursor absolute path; detected here, like ⌘⎋, so it works
|
||||||
/// current capture state and the event itself is swallowed). macOS only; the
|
/// regardless of the current capture state and the event itself is swallowed). macOS only;
|
||||||
/// absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||||
public var onToggleMouseMode: (() -> Void)?
|
public var onToggleCursor: (() -> Void)?
|
||||||
|
|
||||||
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
||||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||||
@@ -245,14 +245,13 @@ public final class InputCapture {
|
|||||||
self.onToggleCapture?()
|
self.onToggleCapture?()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop — the SDL clients' identical
|
// ⌘⇧C toggles the client-side cursor (visible-cursor absolute path vs the
|
||||||
// chord). Detected in both capture states, like ⌘⎋, so the model can be set
|
// captured relative path). keyCode 8 = kVK_ANSI_C; layout-independent so it
|
||||||
// before engaging. keyCode 46 = kVK_ANSI_M; layout-independent. Suppress the M
|
// fires the same on any keyboard. Suppress the C (latched like ⌘⎋'s Esc) so it
|
||||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
// doesn't type into the host, and swallow the event so it doesn't beep.
|
||||||
// event so it doesn't beep.
|
if event.keyCode == 8 /* C */, flags == [.command, .shift] {
|
||||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
self.suppressedVK = 0x43 // VK_C — the same physical C is en route via GC
|
||||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
self.onToggleCursor?()
|
||||||
self.onToggleMouseMode?()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/// How a physical mouse drives the host — the cross-client mouse model (the SDL clients'
|
|
||||||
/// `MouseMode` / `Settings::mouse_mode`, design/remote-desktop-sweep.md M1). Stored stringly
|
|
||||||
/// under `DefaultsKey.mouseMode`.
|
|
||||||
public enum MouseInputMode: String, CaseIterable, Sendable {
|
|
||||||
/// Pointer capture (disassociated, hidden cursor, relative deltas) — the game model,
|
|
||||||
/// and the default: the only cursor you see is the host's.
|
|
||||||
case capture
|
|
||||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
|
||||||
/// motion is forwarded as absolute positions through the letterbox. The remote desktop
|
|
||||||
/// model. Requires a host injector with absolute support (not gamescope).
|
|
||||||
case desktop
|
|
||||||
}
|
|
||||||
@@ -38,11 +38,10 @@ private let streamInputDebug =
|
|||||||
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
||||||
/// hide/unhide and associate are balanced via `captured`.
|
/// hide/unhide and associate are balanced via `captured`.
|
||||||
///
|
///
|
||||||
/// In the DESKTOP mouse model (absolute pointer, remote-desktop-sweep M1) this is a no-op:
|
/// In CLIENT-SIDE-CURSOR mode (gamescope, whose capture carries no host cursor) this is a
|
||||||
/// the pointer stays free (entering and leaving the stream at will) and StreamLayerView
|
/// no-op: the local cursor stays visible and free, and StreamLayerView forwards ABSOLUTE
|
||||||
/// forwards ABSOLUTE positions instead; the local cursor is hidden only while over the view
|
/// positions instead — the visible system cursor IS the on-screen cursor. `disassociate`
|
||||||
/// (cursor rects). `disassociate` selects between the two; `release()` only undoes what
|
/// selects between the two; `release()` only undoes what `capture` actually did.
|
||||||
/// `capture` actually did.
|
|
||||||
private final class CursorCapture {
|
private final class CursorCapture {
|
||||||
private var captured = false
|
private var captured = false
|
||||||
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
||||||
@@ -208,17 +207,14 @@ public final class StreamLayerView: NSView {
|
|||||||
/// forwarded). Main-thread only.
|
/// forwarded). Main-thread only.
|
||||||
public private(set) var captured = false
|
public private(set) var captured = false
|
||||||
|
|
||||||
/// Desktop (absolute) mouse model — remote-desktop-sweep M1: when true the pointer is
|
/// Client-side-cursor mode: when true the local system cursor stays VISIBLE over the
|
||||||
/// never disassociated (it enters and leaves the stream freely) and the mouse monitor
|
/// stream and the mouse monitor forwards ABSOLUTE positions (the visible cursor is the
|
||||||
/// forwards ABSOLUTE positions through the letterbox; the local cursor is hidden only
|
/// on-screen cursor — gamescope draws none, so no double cursor); when false the existing
|
||||||
/// while over this view (cursor rects — the host's composited cursor, tracking our
|
/// captured/disassociated relative path runs unchanged. Initialized at session start from
|
||||||
/// sends, is the one you see) and reappears the moment it leaves. When false the
|
/// the `cursorMode` setting + the host's resolved compositor, toggled live by ⌘⇧C. A live
|
||||||
/// captured/disassociated relative path runs unchanged. Initialized at session start
|
/// flip re-engages capture in the new mode so disassociation + the abs/rel choice swap
|
||||||
/// from the `mouseMode` setting gated by the host's resolved compositor (gamescope's
|
/// atomically. Main-thread only.
|
||||||
/// EIS is relative-only — absolute sends would be dropped, so it pins to capture);
|
private var cursorVisible = false
|
||||||
/// flipped live by ⌃⌥⇧M. A live flip re-engages capture in the new model so
|
|
||||||
/// disassociation + the abs/rel choice swap atomically. Main-thread only.
|
|
||||||
private var desktopMouse = false
|
|
||||||
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
||||||
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
||||||
/// surprisingly later (e.g. on a resize).
|
/// surprisingly later (e.g. on a resize).
|
||||||
@@ -444,9 +440,9 @@ public final class StreamLayerView: NSView {
|
|||||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
// In client-side-cursor mode there is no grab (the cursor stays visible) — capture
|
||||||
// always engages and the monitor forwards absolute positions instead.
|
// always engages and the monitor forwards absolute positions instead.
|
||||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
guard cursorCapture.capture(in: self, disassociate: !cursorVisible) else { return }
|
||||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||||
@@ -454,7 +450,6 @@ public final class StreamLayerView: NSView {
|
|||||||
installMouseMonitor()
|
installMouseMonitor()
|
||||||
captured = true
|
captured = true
|
||||||
window?.makeFirstResponder(self)
|
window?.makeFirstResponder(self)
|
||||||
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
|
|
||||||
notifyCaptureChange(true)
|
notifyCaptureChange(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,28 +459,9 @@ public final class StreamLayerView: NSView {
|
|||||||
cursorCapture.release()
|
cursorCapture.release()
|
||||||
inputCapture?.setForwarding(false)
|
inputCapture?.setForwarding(false)
|
||||||
captured = false
|
captured = false
|
||||||
window?.invalidateCursorRects(for: self)
|
|
||||||
notifyCaptureChange(false)
|
notifyCaptureChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully transparent cursor for the desktop mouse model's hide-over-view rect —
|
|
||||||
/// an empty 1×1 image draws nothing.
|
|
||||||
private static let invisibleCursor = NSCursor(
|
|
||||||
image: NSImage(size: NSSize(width: 1, height: 1)), hotSpot: .zero)
|
|
||||||
|
|
||||||
/// Desktop mouse model: the local cursor is hidden while over the stream (the host's
|
|
||||||
/// composited cursor, tracking our absolute sends, is the one you see) and reappears
|
|
||||||
/// the moment it leaves the view — AppKit applies/removes the rect's cursor for us,
|
|
||||||
/// so there is no hide/unhide balancing to get wrong. Capture model instead hides
|
|
||||||
/// globally via `CursorCapture` (the pointer can't leave the view there).
|
|
||||||
override public func resetCursorRects() {
|
|
||||||
if captured && desktopMouse {
|
|
||||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
|
||||||
} else {
|
|
||||||
super.resetCursorRects()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single local monitor for motion + buttons, installed only while captured. A local
|
/// A single local monitor for motion + buttons, installed only while captured. A local
|
||||||
/// monitor is more robust than view overrides for relative motion: it sidesteps the
|
/// monitor is more robust than view overrides for relative motion: it sidesteps the
|
||||||
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
|
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
|
||||||
@@ -497,12 +473,12 @@ public final class StreamLayerView: NSView {
|
|||||||
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
||||||
/// inert locally.
|
/// inert locally.
|
||||||
///
|
///
|
||||||
/// In the desktop mouse model the cursor is NOT frozen, so bare `.mouseMoved` events are
|
/// In client-side-cursor mode the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||||
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
||||||
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
||||||
private func installMouseMonitor() {
|
private func installMouseMonitor() {
|
||||||
guard mouseEventMonitor == nil else { return }
|
guard mouseEventMonitor == nil else { return }
|
||||||
if desktopMouse {
|
if cursorVisible {
|
||||||
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
||||||
window?.acceptsMouseMovedEvents = true
|
window?.acceptsMouseMovedEvents = true
|
||||||
}
|
}
|
||||||
@@ -514,8 +490,8 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
||||||
switch event.type {
|
switch event.type {
|
||||||
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||||
if self.desktopMouse {
|
if self.cursorVisible {
|
||||||
// Desktop mouse model: forward the ABSOLUTE position (mapped through the
|
// Client-side cursor: forward the ABSOLUTE position (mapped through the
|
||||||
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
||||||
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
||||||
if let p = self.hostPoint(from: event) {
|
if let p = self.hostPoint(from: event) {
|
||||||
@@ -633,27 +609,14 @@ public final class StreamLayerView: NSView {
|
|||||||
// be a cursor trap with dead input.
|
// be a cursor trap with dead input.
|
||||||
self?.releaseCapture()
|
self?.releaseCapture()
|
||||||
}
|
}
|
||||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop) live — the SDL clients' identical
|
// ⌘⇧C flips the client-side cursor live. Only the key window's stream owns it (same
|
||||||
// chord. Only the key window's stream owns it (same guard as the ⌘⎋ capture toggle).
|
// guard as the ⌘⎋ capture toggle). Re-engage capture in the new mode so disassociation
|
||||||
// Re-engage capture in the new model so disassociation and the absolute/relative
|
// and the absolute/relative forwarding choice swap atomically — releaseCapture restores
|
||||||
// forwarding choice swap atomically — releaseCapture restores the old model's grab
|
// the old mode's grab (if any), engageCapture installs the new one.
|
||||||
// (if any), engageCapture installs the new one. On a gamescope host the chord is a
|
// ⌘⇧C would flip the client-side cursor live — NEUTERED while the feature is disabled
|
||||||
// no-op: its EIS grants only a relative pointer, so the desktop model's absolute
|
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
||||||
// sends would be silently dropped (pointer stuck = "all input dead").
|
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
||||||
capture.onToggleMouseMode = { [weak self] in
|
capture.onToggleCursor = {}
|
||||||
guard let self, self.window?.isKeyWindow == true,
|
|
||||||
let conn = self.connection else { return }
|
|
||||||
guard conn.resolvedCompositor != .gamescope else {
|
|
||||||
streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let wasCaptured = self.captured
|
|
||||||
if wasCaptured { self.releaseCapture() }
|
|
||||||
self.desktopMouse.toggle()
|
|
||||||
if wasCaptured { self.engageCapture(fromClick: false) }
|
|
||||||
self.window?.invalidateCursorRects(for: self)
|
|
||||||
streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
|
|
||||||
}
|
|
||||||
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
||||||
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
||||||
capture.onReleaseCapture = { [weak self] in
|
capture.onReleaseCapture = { [weak self] in
|
||||||
@@ -680,18 +643,15 @@ public final class StreamLayerView: NSView {
|
|||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|
||||||
// Desktop (absolute) mouse model — resolved at session start from the mouseMode
|
// Client-side cursor is TEMPORARILY DISABLED. It positions the host cursor with ABSOLUTE
|
||||||
// setting, gated by the host's compositor: gamescope's input socket (EIS) grants
|
// events, but gamescope's input socket (EIS) grants only a relative pointer, so those are
|
||||||
// only a relative pointer, so absolute sends would be silently dropped there
|
// silently dropped — the pointer never moves and clicks/scroll land on the stuck position
|
||||||
// (pointer stuck = "all input dead") — pinned to capture. ⌃⌥⇧M flips it live.
|
// (looks like "all input dead"). gamescope is exactly the compositor Auto enabled it for.
|
||||||
let mode = MouseInputMode(
|
// Forced off until per-compositor gating (KWin/GNOME/Sway have absolute) or a synthetic-
|
||||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? ""
|
// cursor-over-relative path lands; the resolution logic below is kept for that. See the
|
||||||
) ?? .capture
|
// ⌘⇧C handler (also neutered) and the cursorMode setting (hidden).
|
||||||
let absOK = connection.resolvedCompositor != .gamescope
|
cursorVisible = false
|
||||||
desktopMouse = mode == .desktop && absOK
|
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||||
if mode == .desktop && !absOK {
|
|
||||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
|
|||||||
@@ -94,11 +94,8 @@ public enum DefaultsKey {
|
|||||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||||
public static let enable444 = "punktfunk.enable444"
|
public static let enable444 = "punktfunk.enable444"
|
||||||
public static let hosts = "punktfunk.hosts"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
/// "desktop" (uncaptured absolute pointer) — the cross-client `mouse_mode`. Replaces the
|
public static let cursorMode = "punktfunk.cursorMode"
|
||||||
/// never-shipped "punktfunk.cursorMode" (auto/always/never client-side-cursor setting,
|
|
||||||
/// which was hidden while disabled and had no readers).
|
|
||||||
public static let mouseMode = "punktfunk.mouseMode"
|
|
||||||
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||||
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||||
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||||
|
|||||||
@@ -69,14 +69,6 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
|||||||
"The cursor jumps to your finger — a tap clicks there",
|
"The cursor jumps to your finger — a tap clicks there",
|
||||||
"Real multi-touch reaches the host — for touch-native apps",
|
"Real multi-touch reaches the host — for touch-native apps",
|
||||||
];
|
];
|
||||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
|
||||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
|
||||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
|
||||||
const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"];
|
|
||||||
const MOUSE_MODE_CAPTIONS: &[&str] = &[
|
|
||||||
"Pointer locks to the stream — relative motion, best for games",
|
|
||||||
"Pointer moves freely in and out — best for remote desktop work",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||||
const APP_LICENSE: &str = concat!(
|
const APP_LICENSE: &str = concat!(
|
||||||
@@ -550,20 +542,6 @@ pub fn show(
|
|||||||
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mouse_row = ChoiceRow::new(
|
|
||||||
&dialog,
|
|
||||||
inline,
|
|
||||||
"Mouse input",
|
|
||||||
MOUSE_MODE_CAPTIONS[0],
|
|
||||||
MOUSE_MODE_LABELS,
|
|
||||||
);
|
|
||||||
{
|
|
||||||
let w = mouse_row.widget().clone();
|
|
||||||
mouse_row.connect_changed(move |i| {
|
|
||||||
let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1);
|
|
||||||
set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let inhibit_row = adw::SwitchRow::builder()
|
let inhibit_row = adw::SwitchRow::builder()
|
||||||
.title("Capture system shortcuts")
|
.title("Capture system shortcuts")
|
||||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||||
@@ -740,12 +718,6 @@ pub fn show(
|
|||||||
touch_row.set_selected(touch_i as u32);
|
touch_row.set_selected(touch_i as u32);
|
||||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
||||||
let mouse_i = MOUSE_MODES
|
|
||||||
.iter()
|
|
||||||
.position(|&m| m == s.mouse_mode)
|
|
||||||
.unwrap_or(0);
|
|
||||||
mouse_row.set_selected(mouse_i as u32);
|
|
||||||
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]);
|
|
||||||
let comp_i = COMPOSITORS
|
let comp_i = COMPOSITORS
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&c| c == s.compositor)
|
.position(|&c| c == s.compositor)
|
||||||
@@ -816,7 +788,6 @@ pub fn show(
|
|||||||
touch_group.add(touch_row.widget());
|
touch_group.add(touch_row.widget());
|
||||||
// Group titles are Pango markup — the ampersand must be an entity.
|
// Group titles are Pango markup — the ampersand must be an entity.
|
||||||
let kbm_group = group("Keyboard & mouse", "");
|
let kbm_group = group("Keyboard & mouse", "");
|
||||||
kbm_group.add(mouse_row.widget());
|
|
||||||
kbm_group.add(&inhibit_row);
|
kbm_group.add(&inhibit_row);
|
||||||
kbm_group.add(&invert_row);
|
kbm_group.add(&invert_row);
|
||||||
input.add(&touch_group);
|
input.add(&touch_group);
|
||||||
@@ -896,8 +867,6 @@ pub fn show(
|
|||||||
}
|
}
|
||||||
s.touch_mode =
|
s.touch_mode =
|
||||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||||
s.mouse_mode =
|
|
||||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
|
||||||
s.forward_pad = chosen_pin.borrow().clone();
|
s.forward_pad = chosen_pin.borrow().clone();
|
||||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
mouse_mode: settings_at_start.mouse_mode(),
|
|
||||||
invert_scroll: settings_at_start.invert_scroll,
|
invert_scroll: settings_at_start.invert_scroll,
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
|
|||||||
@@ -172,11 +172,6 @@ mod session_main {
|
|||||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||||
// pump) pins one manually.
|
// pump) pins one manually.
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
|
||||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
|
||||||
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
|
||||||
// compositors only).
|
|
||||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
|
||||||
mic_enabled: settings.mic_enabled,
|
mic_enabled: settings.mic_enabled,
|
||||||
clipboard,
|
clipboard,
|
||||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
@@ -434,7 +429,6 @@ mod session_main {
|
|||||||
v => v,
|
v => v,
|
||||||
},
|
},
|
||||||
touch_mode: settings.touch_mode(),
|
touch_mode: settings.touch_mode(),
|
||||||
mouse_mode: settings.mouse_mode(),
|
|
||||||
invert_scroll: settings.invert_scroll,
|
invert_scroll: settings.invert_scroll,
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
|
|||||||
@@ -90,13 +90,6 @@ const TOUCH_MODES: &[(&str, &str)] = &[
|
|||||||
("pointer", "Direct pointer"),
|
("pointer", "Direct pointer"),
|
||||||
("touch", "Touch passthrough"),
|
("touch", "Touch passthrough"),
|
||||||
];
|
];
|
||||||
/// Physical-mouse presets: `(stored value, display label)` — capture (pointer lock,
|
|
||||||
/// relative, for games) vs desktop (uncaptured absolute pointer, for remote desktop
|
|
||||||
/// work). Ctrl+Alt+Shift+M flips the model live in-stream.
|
|
||||||
const MOUSE_MODES: &[(&str, &str)] = &[
|
|
||||||
("capture", "Capture (games)"),
|
|
||||||
("desktop", "Desktop (absolute)"),
|
|
||||||
];
|
|
||||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||||
const COMPOSITORS: &[(&str, &str)] = &[
|
const COMPOSITORS: &[(&str, &str)] = &[
|
||||||
@@ -401,10 +394,6 @@ pub(crate) fn settings_page(
|
|||||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||||
});
|
});
|
||||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
|
||||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
|
||||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
|
||||||
});
|
|
||||||
let invert_scroll_toggle =
|
let invert_scroll_toggle =
|
||||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||||
s.invert_scroll = on
|
s.invert_scroll = on
|
||||||
@@ -553,13 +542,6 @@ pub(crate) fn settings_page(
|
|||||||
out.extend(group(
|
out.extend(group(
|
||||||
Some("Keyboard & mouse"),
|
Some("Keyboard & mouse"),
|
||||||
vec![
|
vec![
|
||||||
described(
|
|
||||||
mouse_combo,
|
|
||||||
"Capture locks the pointer to the stream and sends relative motion — \
|
|
||||||
best for games. Desktop leaves the pointer free to enter and leave \
|
|
||||||
the stream and sends absolute positions — best for remote desktop \
|
|
||||||
work. Ctrl+Alt+Shift+M switches live.",
|
|
||||||
),
|
|
||||||
described(
|
described(
|
||||||
shortcuts_toggle,
|
shortcuts_toggle,
|
||||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||||
|
|||||||
@@ -868,10 +868,6 @@ mod pipewire {
|
|||||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||||
serial: u64,
|
serial: u64,
|
||||||
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
|
||||||
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
|
||||||
hot_x: i32,
|
|
||||||
hot_y: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
@@ -888,8 +884,6 @@ mod pipewire {
|
|||||||
h: self.bh,
|
h: self.bh,
|
||||||
rgba: self.rgba.clone(),
|
rgba: self.rgba.clone(),
|
||||||
serial: self.serial,
|
serial: self.serial,
|
||||||
hot_x: self.hot_x.max(0) as u32,
|
|
||||||
hot_y: self.hot_y.max(0) as u32,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1386,8 +1380,6 @@ mod pipewire {
|
|||||||
cursor.visible = true;
|
cursor.visible = true;
|
||||||
cursor.x = pos_x - hot_x;
|
cursor.x = pos_x - hot_x;
|
||||||
cursor.y = pos_y - hot_y;
|
cursor.y = pos_y - hot_y;
|
||||||
cursor.hot_x = hot_x;
|
|
||||||
cursor.hot_y = hot_y;
|
|
||||||
if bmp_off == 0 {
|
if bmp_off == 0 {
|
||||||
// Position-only update — keep the cached bitmap.
|
// Position-only update — keep the cached bitmap.
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -876,10 +876,16 @@ impl Worker {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let pref = self
|
let pref = match self.pad_info(id) {
|
||||||
.pad_info(id)
|
// Steam Input's virtual pad standing in front of the Deck's built-in controls (the
|
||||||
.map(|p| p.pref)
|
// only-pad-forwarded case, [`Self::forwarded_ids`]): declare the DECK kind, not the
|
||||||
.unwrap_or(GamepadPref::Xbox360);
|
// wrapper's Xbox 360 identity. [`Self::auto_pref`] already resolves the SESSION
|
||||||
|
// default this way, but a current host honors the per-pad arrival over the session
|
||||||
|
// default — so without this the host builds an X-Box 360 pad on a real Deck.
|
||||||
|
Some(p) if p.steam_virtual && is_steam_deck() => GamepadPref::SteamDeck,
|
||||||
|
Some(p) => p.pref,
|
||||||
|
None => GamepadPref::Xbox360,
|
||||||
|
};
|
||||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||||
Ok(pad) => {
|
Ok(pad) => {
|
||||||
let mut slot = Slot::new(id, index, pref, pad);
|
let mut slot = Slot::new(id, index, pref, pad);
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ pub struct SessionParams {
|
|||||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||||
pub clipboard: bool,
|
pub clipboard: bool,
|
||||||
/// Advertise `quic::CLIENT_CAP_CURSOR`: this embedder renders the host cursor locally
|
|
||||||
/// (the presenter's cursor channel, design/remote-desktop-sweep.md M2), so the host may
|
|
||||||
/// stop compositing the pointer into the video. Only set when the embedder actually
|
|
||||||
/// draws it (the SDL presenter in desktop mouse mode) — a session that advertises it
|
|
||||||
/// without rendering streams with NO visible cursor. The host answers `HOST_CAP_CURSOR`
|
|
||||||
/// when its capture can forward (Linux portal, not gamescope/Windows).
|
|
||||||
pub cursor_forward: bool,
|
|
||||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||||
/// `video::Decoder::new`).
|
/// `video::Decoder::new`).
|
||||||
pub decoder: String,
|
pub decoder: String,
|
||||||
@@ -262,11 +255,6 @@ fn pump(
|
|||||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||||
if params.cursor_forward {
|
|
||||||
punktfunk_core::quic::CLIENT_CAP_CURSOR
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
params.launch.clone(),
|
params.launch.clone(),
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
|
|||||||
@@ -325,6 +325,11 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
|
|||||||
"Client and host versions don't match — update both to the same release.".into()
|
"Client and host versions don't match — update both to the same release.".into()
|
||||||
}
|
}
|
||||||
R::Busy => "The host is busy with another session.".into(),
|
R::Busy => "The host is busy with another session.".into(),
|
||||||
|
R::SetupFailed => {
|
||||||
|
"The host accepted the connection but couldn't start the stream — the host's log \
|
||||||
|
(web console → Log) has the cause."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,48 +461,6 @@ impl TouchMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a physical mouse drives the host — the desktop-sweep mouse model
|
|
||||||
/// (design/remote-desktop-sweep.md M1). Stored stringly in [`Settings::mouse_mode`] so the
|
|
||||||
/// file stays readable; parsed with [`MouseMode::from_name`].
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum MouseMode {
|
|
||||||
/// Pointer lock (relative deltas, hidden cursor) — the game model, and the default:
|
|
||||||
/// the only cursor you see is the host's.
|
|
||||||
Capture,
|
|
||||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
|
||||||
/// motion goes on the wire as absolute positions through the letterbox. The remote
|
|
||||||
/// desktop model. Requires a host injector with absolute support (not gamescope).
|
|
||||||
Desktop,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MouseMode {
|
|
||||||
/// Cycle/picker order (also the settings pickers' option order).
|
|
||||||
pub const ALL: [MouseMode; 2] = [MouseMode::Capture, MouseMode::Desktop];
|
|
||||||
|
|
||||||
/// Parse the persisted name, defaulting to `Capture` for unset/unknown values.
|
|
||||||
pub fn from_name(s: &str) -> MouseMode {
|
|
||||||
match s {
|
|
||||||
"desktop" => MouseMode::Desktop,
|
|
||||||
_ => MouseMode::Capture,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
|
||||||
pub fn as_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MouseMode::Capture => "capture",
|
|
||||||
MouseMode::Desktop => "desktop",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MouseMode::Capture => "Capture (games)",
|
|
||||||
MouseMode::Desktop => "Desktop (absolute)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -532,12 +495,6 @@ pub struct Settings {
|
|||||||
/// stores load as trackpad.
|
/// stores load as trackpad.
|
||||||
#[serde(default = "default_touch_mode")]
|
#[serde(default = "default_touch_mode")]
|
||||||
pub touch_mode: String,
|
pub touch_mode: String,
|
||||||
/// How a physical mouse drives the host: a [`MouseMode`] name — `"capture"` (default,
|
|
||||||
/// pointer lock + relative) or `"desktop"` (uncaptured absolute pointer). Read at
|
|
||||||
/// connect via [`Settings::mouse_mode`]. `default` so pre-existing stores load as
|
|
||||||
/// capture — today's behavior.
|
|
||||||
#[serde(default = "default_mouse_mode")]
|
|
||||||
pub mouse_mode: String,
|
|
||||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||||
pub inhibit_shortcuts: bool,
|
pub inhibit_shortcuts: bool,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
@@ -625,10 +582,6 @@ fn default_touch_mode() -> String {
|
|||||||
"trackpad".into()
|
"trackpad".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_mouse_mode() -> String {
|
|
||||||
"capture".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -656,10 +609,6 @@ impl Settings {
|
|||||||
TouchMode::from_name(&self.touch_mode)
|
TouchMode::from_name(&self.touch_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mouse_mode(&self) -> MouseMode {
|
|
||||||
MouseMode::from_name(&self.mouse_mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||||
pub fn preferred_codec(&self) -> u8 {
|
pub fn preferred_codec(&self) -> u8 {
|
||||||
match self.codec.as_str() {
|
match self.codec.as_str() {
|
||||||
@@ -687,7 +636,6 @@ impl Default for Settings {
|
|||||||
forward_pad: String::new(),
|
forward_pad: String::new(),
|
||||||
compositor: "auto".into(),
|
compositor: "auto".into(),
|
||||||
touch_mode: "trackpad".into(),
|
touch_mode: "trackpad".into(),
|
||||||
mouse_mode: "capture".into(),
|
|
||||||
inhibit_shortcuts: true,
|
inhibit_shortcuts: true,
|
||||||
mic_enabled: false,
|
mic_enabled: false,
|
||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -29,11 +29,10 @@ enum RowId {
|
|||||||
Pad,
|
Pad,
|
||||||
PadType,
|
PadType,
|
||||||
Touch,
|
Touch,
|
||||||
Mouse,
|
|
||||||
Stats,
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: [RowId; 14] = [
|
const ROWS: [RowId; 13] = [
|
||||||
RowId::Resolution,
|
RowId::Resolution,
|
||||||
RowId::Refresh,
|
RowId::Refresh,
|
||||||
RowId::Bitrate,
|
RowId::Bitrate,
|
||||||
@@ -46,7 +45,6 @@ const ROWS: [RowId; 14] = [
|
|||||||
RowId::Pad,
|
RowId::Pad,
|
||||||
RowId::PadType,
|
RowId::PadType,
|
||||||
RowId::Touch,
|
RowId::Touch,
|
||||||
RowId::Mouse,
|
|
||||||
RowId::Stats,
|
RowId::Stats,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -253,7 +251,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
"Touch mode",
|
"Touch mode",
|
||||||
s.touch_mode().label().into(),
|
s.touch_mode().label().into(),
|
||||||
),
|
),
|
||||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
|
||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
@@ -295,11 +292,6 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||||
}
|
}
|
||||||
RowId::Mouse => {
|
|
||||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
|
||||||
for games), Desktop leaves it free and sends absolute positions. \
|
|
||||||
Ctrl+Alt+Shift+M switches live while streaming."
|
|
||||||
}
|
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
@@ -375,11 +367,6 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||||
}
|
}
|
||||||
RowId::Mouse => {
|
|
||||||
let cur = MouseMode::ALL.iter().position(|m| *m == s.mouse_mode());
|
|
||||||
step_option(cur, MouseMode::ALL.len(), delta, wrap)
|
|
||||||
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
|
|
||||||
}
|
|
||||||
RowId::Stats => {
|
RowId::Stats => {
|
||||||
let cur = StatsVerbosity::ALL
|
let cur = StatsVerbosity::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -523,33 +510,6 @@ mod tests {
|
|||||||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mouse_mode_steps_and_wraps() {
|
|
||||||
let (mut settings, pads) = ctx_parts();
|
|
||||||
assert_eq!(settings.mouse_mode, "capture");
|
|
||||||
let library = crate::library::LibraryShared::default();
|
|
||||||
let mut ctx = Ctx {
|
|
||||||
hosts: &[],
|
|
||||||
library: &library,
|
|
||||||
settings: &mut settings,
|
|
||||||
pads: &pads,
|
|
||||||
deck: false,
|
|
||||||
device_name: "t",
|
|
||||||
t: 0.0,
|
|
||||||
};
|
|
||||||
// Capture → Desktop, then a step past the end is a boundary.
|
|
||||||
assert!(
|
|
||||||
!adjust(RowId::Mouse, -1, false, &mut ctx),
|
|
||||||
"already first = thud"
|
|
||||||
);
|
|
||||||
assert!(adjust(RowId::Mouse, 1, false, &mut ctx));
|
|
||||||
assert_eq!(ctx.settings.mouse_mode, "desktop");
|
|
||||||
assert!(!adjust(RowId::Mouse, 1, false, &mut ctx), "last = thud");
|
|
||||||
// A wraps back to the first.
|
|
||||||
assert!(adjust(RowId::Mouse, 1, true, &mut ctx));
|
|
||||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_value_snaps_to_first() {
|
fn unknown_value_snaps_to_first() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -196,11 +196,6 @@ pub struct CursorOverlay {
|
|||||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||||
/// Bumps whenever `rgba`/`w`/`h` change; stable across position-only moves.
|
/// Bumps whenever `rgba`/`w`/`h` change; stable across position-only moves.
|
||||||
pub serial: u64,
|
pub serial: u64,
|
||||||
/// Hotspot (the pixel that IS the pointer position) within `w`×`h`. The blend paths ignore
|
|
||||||
/// it (`x`/`y` are already hotspot-adjusted); the cursor-forward channel ships it to the
|
|
||||||
/// client so a locally-drawn OS cursor points with the right pixel.
|
|
||||||
pub hot_x: u32,
|
|
||||||
pub hot_y: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
|||||||
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
||||||
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
||||||
// hang the worker forever.
|
// hang the worker forever.
|
||||||
let (_keepalive, context, mut events) = match tokio::time::timeout(
|
let (_keepalive, context, mut events, output_hint) = match tokio::time::timeout(
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
connect(source),
|
connect(source),
|
||||||
)
|
)
|
||||||
@@ -130,6 +130,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
|||||||
tracing::info!("libei: EIS connected — awaiting devices");
|
tracing::info!("libei: EIS connected — awaiting devices");
|
||||||
|
|
||||||
let mut state = EiState::new();
|
let mut state = EiState::new();
|
||||||
|
state.output_hint = output_hint;
|
||||||
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
||||||
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
||||||
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
||||||
@@ -177,20 +178,28 @@ type Connected = (
|
|||||||
Box<dyn Send>,
|
Box<dyn Send>,
|
||||||
ei::Context,
|
ei::Context,
|
||||||
reis::tokio::EiConvertEventStream,
|
reis::tokio::EiConvertEventStream,
|
||||||
|
// The compositor's output size ("WxH" relay-file hint) — the scale target for absolute
|
||||||
|
// coordinates when the EIS advertises only a degenerate region (gamescope). `None` for
|
||||||
|
// the portal/Mutter paths, whose regions are real.
|
||||||
|
Option<(u32, u32)>,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Reach an EIS server per `source` and run the EI sender handshake.
|
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||||
async fn connect(source: EiSource) -> Result<Connected> {
|
async fn connect(source: EiSource) -> Result<Connected> {
|
||||||
let (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
|
let (keepalive, stream, output_hint): (Box<dyn Send>, UnixStream, Option<(u32, u32)>) =
|
||||||
|
match source {
|
||||||
EiSource::Portal => {
|
EiSource::Portal => {
|
||||||
let (rd, session, fd) = connect_portal().await?;
|
let (rd, session, fd) = connect_portal().await?;
|
||||||
(Box::new((rd, session)), UnixStream::from(fd))
|
(Box::new((rd, session)), UnixStream::from(fd), None)
|
||||||
}
|
}
|
||||||
EiSource::MutterEis => {
|
EiSource::MutterEis => {
|
||||||
let (keepalive, fd) = connect_mutter().await?;
|
let (keepalive, fd) = connect_mutter().await?;
|
||||||
(keepalive, UnixStream::from(fd))
|
(keepalive, UnixStream::from(fd), None)
|
||||||
|
}
|
||||||
|
EiSource::SocketPathFile(file) => {
|
||||||
|
let (stream, hint) = connect_socket_file(&file).await?;
|
||||||
|
(Box::new(()), stream, hint)
|
||||||
}
|
}
|
||||||
EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?),
|
|
||||||
};
|
};
|
||||||
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
|
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
|
||||||
// Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
|
// Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
|
||||||
@@ -206,7 +215,7 @@ async fn connect(source: EiSource) -> Result<Connected> {
|
|||||||
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
|
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
|
||||||
})?
|
})?
|
||||||
.map_err(|e| anyhow!("EI handshake: {e}"))?;
|
.map_err(|e| anyhow!("EI handshake: {e}"))?;
|
||||||
Ok((keepalive, context, events))
|
Ok((keepalive, context, events, output_hint))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
|
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
|
||||||
@@ -294,8 +303,10 @@ async fn connect_mutter() -> Result<(Box<dyn Send>, std::os::fd::OwnedFd)> {
|
|||||||
|
|
||||||
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
|
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
|
||||||
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
||||||
/// mirroring libei's own `LIBEI_SOCKET` semantics.
|
/// mirroring libei's own `LIBEI_SOCKET` semantics. Line 2 of the relay file, when present,
|
||||||
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
/// carries the compositor's output size as `WxH` — returned as the absolute-coordinate scale
|
||||||
|
/// hint (gamescope's EIS region is degenerate, so the geometry can't come from the protocol).
|
||||||
|
async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Option<(u32, u32)>)> {
|
||||||
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
||||||
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
||||||
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
||||||
@@ -319,7 +330,12 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Ok(s) = std::fs::read_to_string(file) {
|
if let Ok(s) = std::fs::read_to_string(file) {
|
||||||
let name = s.trim();
|
let mut file_lines = s.lines();
|
||||||
|
let name = file_lines.next().unwrap_or("").trim();
|
||||||
|
let hint = file_lines.next().and_then(|l| {
|
||||||
|
let (w, h) = l.trim().split_once('x')?;
|
||||||
|
Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?))
|
||||||
|
});
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
let full = if name.starts_with('/') {
|
let full = if name.starts_with('/') {
|
||||||
std::path::PathBuf::from(name)
|
std::path::PathBuf::from(name)
|
||||||
@@ -334,7 +350,7 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
|||||||
logged = name.to_string();
|
logged = name.to_string();
|
||||||
}
|
}
|
||||||
match UnixStream::connect(&full) {
|
match UnixStream::connect(&full) {
|
||||||
Ok(stream) => return Ok(stream),
|
Ok(stream) => return Ok((stream, hint)),
|
||||||
// Refused = socket file exists but no listener yet (or a dead session);
|
// Refused = socket file exists but no listener yet (or a dead session);
|
||||||
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
||||||
// up — retry. Anything else (e.g. permission) is a real failure.
|
// up — retry. Anything else (e.g. permission) is a real failure.
|
||||||
@@ -386,6 +402,22 @@ struct EiState {
|
|||||||
held_keys: Vec<u32>,
|
held_keys: Vec<u32>,
|
||||||
held_buttons: Vec<u32>,
|
held_buttons: Vec<u32>,
|
||||||
held_touches: Vec<u32>,
|
held_touches: Vec<u32>,
|
||||||
|
/// The touch id currently degraded to the absolute pointer ([`EiState::degrade_touch`]) —
|
||||||
|
/// the "primary finger" while the EIS has no touchscreen device. `None` between touches.
|
||||||
|
degraded_touch: Option<u32>,
|
||||||
|
/// The compositor's output size (relay-file "WxH" hint) — scale target for absolute
|
||||||
|
/// coordinates when the device's region is degenerate/absent (gamescope). Without it the
|
||||||
|
/// fallback is raw client pixels, correct only when the stream runs at the output's size.
|
||||||
|
output_hint: Option<(u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
||||||
|
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
|
||||||
|
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
|
||||||
|
/// explodes a center tap to x≈1e9, which the compositor clamps to the far corner. 16384
|
||||||
|
/// comfortably covers real multi-monitor layouts while rejecting the sentinel.
|
||||||
|
fn sane_region(r: &reis::event::Region) -> bool {
|
||||||
|
r.width > 0 && r.height > 0 && r.width <= 16_384 && r.height <= 16_384
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
||||||
@@ -422,6 +454,8 @@ impl EiState {
|
|||||||
held_keys: Vec::new(),
|
held_keys: Vec::new(),
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
held_touches: Vec::new(),
|
held_touches: Vec::new(),
|
||||||
|
degraded_touch: None,
|
||||||
|
output_hint: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +464,10 @@ impl EiState {
|
|||||||
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
||||||
/// touch-up frames before the devices disappear.
|
/// touch-up frames before the devices disappear.
|
||||||
fn release_all(&mut self, ctx: &ei::Context) {
|
fn release_all(&mut self, ctx: &ei::Context) {
|
||||||
|
// A degraded touch is held as a synthesized left button (in `held_buttons`), so the
|
||||||
|
// loop below releases it — but the primary-finger latch must clear too, or the NEXT
|
||||||
|
// session's first TouchDown reads as a second finger and is ignored.
|
||||||
|
self.degraded_touch = None;
|
||||||
let (keys, buttons, touches) = (
|
let (keys, buttons, touches) = (
|
||||||
std::mem::take(&mut self.held_keys),
|
std::mem::take(&mut self.held_keys),
|
||||||
std::mem::take(&mut self.held_buttons),
|
std::mem::take(&mut self.held_buttons),
|
||||||
@@ -506,6 +544,8 @@ impl EiState {
|
|||||||
keyboard = dev.has_capability(DeviceCapability::Keyboard),
|
keyboard = dev.has_capability(DeviceCapability::Keyboard),
|
||||||
button = dev.has_capability(DeviceCapability::Button),
|
button = dev.has_capability(DeviceCapability::Button),
|
||||||
scroll = dev.has_capability(DeviceCapability::Scroll),
|
scroll = dev.has_capability(DeviceCapability::Scroll),
|
||||||
|
regions = dev.regions().len(),
|
||||||
|
region0 = ?dev.regions().first().map(|r| (r.x, r.y, r.width, r.height)),
|
||||||
"libei: device RESUMED (now emittable)"
|
"libei: device RESUMED (now emittable)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -537,8 +577,85 @@ impl EiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Degrade touch to a single-finger ABSOLUTE POINTER on compositors whose EIS never
|
||||||
|
/// creates a touchscreen device (gamescope's "Gamescope Virtual Input" advertises
|
||||||
|
/// pointer/pointer_abs/button but no touch — observed live; headless KWin the same).
|
||||||
|
/// Down = abs-move + left press, move = abs-move, up = left release — synthesized through
|
||||||
|
/// the normal [`EiState::inject`] Mouse* paths so region mapping, held-state tracking and
|
||||||
|
/// [`EiState::release_all`] all apply. Only the FIRST finger drives the pointer; later
|
||||||
|
/// fingers are ignored (a pointer has no second contact — a pinch degrades to a drag).
|
||||||
|
fn degrade_touch(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||||
|
const GS_BUTTON_LEFT: u32 = 1;
|
||||||
|
match ev.kind {
|
||||||
|
InputKind::TouchDown => {
|
||||||
|
if self.degraded_touch.is_some() {
|
||||||
|
return; // secondary finger — single-pointer degradation
|
||||||
|
}
|
||||||
|
self.degraded_touch = Some(ev.code);
|
||||||
|
static NOTED: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
if !NOTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
tracing::info!(
|
||||||
|
"compositor's EIS has no touchscreen device — degrading touch to a \
|
||||||
|
single-finger absolute pointer (tap = left click; multi-touch \
|
||||||
|
gestures unavailable)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.inject(
|
||||||
|
&InputEvent {
|
||||||
|
kind: InputKind::MouseMoveAbs,
|
||||||
|
..*ev
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
self.inject(
|
||||||
|
&InputEvent {
|
||||||
|
kind: InputKind::MouseButtonDown,
|
||||||
|
code: GS_BUTTON_LEFT,
|
||||||
|
..*ev
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
InputKind::TouchMove if self.degraded_touch == Some(ev.code) => {
|
||||||
|
self.inject(
|
||||||
|
&InputEvent {
|
||||||
|
kind: InputKind::MouseMoveAbs,
|
||||||
|
..*ev
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
InputKind::TouchUp if self.degraded_touch == Some(ev.code) => {
|
||||||
|
self.degraded_touch = None;
|
||||||
|
self.inject(
|
||||||
|
&InputEvent {
|
||||||
|
kind: InputKind::MouseButtonUp,
|
||||||
|
code: GS_BUTTON_LEFT,
|
||||||
|
..*ev
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Translate and emit one client input event, committing it as a single `frame`.
|
/// Translate and emit one client input event, committing it as a single `frame`.
|
||||||
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||||
|
// No ei_touchscreen device but an absolute pointer exists → degrade rather than drop
|
||||||
|
// (the game-mode "touch simply does not work" trap). Checked per event, not latched:
|
||||||
|
// a touchscreen device appearing later (compositor restart, capability change) takes
|
||||||
|
// over seamlessly on the next touch.
|
||||||
|
if matches!(
|
||||||
|
ev.kind,
|
||||||
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
|
||||||
|
) && self.device_for(DeviceCapability::Touch).is_none()
|
||||||
|
&& self.device_for(DeviceCapability::PointerAbsolute).is_some()
|
||||||
|
{
|
||||||
|
self.degrade_touch(ev, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cap = match ev.kind {
|
let cap = match ev.kind {
|
||||||
InputKind::MouseMove => DeviceCapability::Pointer,
|
InputKind::MouseMove => DeviceCapability::Pointer,
|
||||||
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
|
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
|
||||||
@@ -605,16 +722,31 @@ impl EiState {
|
|||||||
InputKind::MouseMoveAbs => {
|
InputKind::MouseMoveAbs => {
|
||||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||||
let h = (ev.flags & 0xffff) as f32;
|
let h = (ev.flags & 0xffff) as f32;
|
||||||
match (
|
match slot.interface::<ei::PointerAbsolute>() {
|
||||||
slot.interface::<ei::PointerAbsolute>(),
|
Some(p) if w > 0.0 && h > 0.0 => {
|
||||||
slot.regions().first(),
|
// Map the normalized client position into the device's first region —
|
||||||
) {
|
// but only when the region looks like a real output geometry.
|
||||||
(Some(p), Some(region)) if w > 0.0 && h > 0.0 => {
|
// gamescope's "Gamescope Virtual Input" advertises a degenerate
|
||||||
// Map the normalized client position into the device's first region.
|
// (0,0,INT32_MAX,INT32_MAX) region meaning "coordinates are raw":
|
||||||
|
// normalizing into it explodes a center tap to x≈1e9, which gamescope
|
||||||
|
// clamps to the far corner (the observed cursor-parked-at-1279,799).
|
||||||
|
// There the managed session runs at the client's mode, so client
|
||||||
|
// pixels ARE output pixels: emit them raw.
|
||||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||||
let x = region.x as f32 + nx * region.width as f32;
|
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
||||||
let y = region.y as f32 + ny * region.height as f32;
|
Some(region) => (
|
||||||
|
region.x as f32 + nx * region.width as f32,
|
||||||
|
region.y as f32 + ny * region.height as f32,
|
||||||
|
),
|
||||||
|
// Degenerate/absent region: scale into the relay-file output hint
|
||||||
|
// (correct even when the client streams at a different resolution
|
||||||
|
// than the session runs); raw client pixels as the last resort.
|
||||||
|
None => match self.output_hint {
|
||||||
|
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||||
|
None => (ev.x as f32, ev.y as f32),
|
||||||
|
},
|
||||||
|
};
|
||||||
p.motion_absolute(x, y);
|
p.motion_absolute(x, y);
|
||||||
}
|
}
|
||||||
_ => emitted = false,
|
_ => emitted = false,
|
||||||
@@ -680,12 +812,21 @@ impl EiState {
|
|||||||
InputKind::TouchDown | InputKind::TouchMove => {
|
InputKind::TouchDown | InputKind::TouchMove => {
|
||||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||||
let h = (ev.flags & 0xffff) as f32;
|
let h = (ev.flags & 0xffff) as f32;
|
||||||
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
|
match slot.interface::<ei::Touchscreen>() {
|
||||||
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
|
Some(t) if w > 0.0 && h > 0.0 => {
|
||||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||||
let x = region.x as f32 + nx * region.width as f32;
|
// Same degenerate-region fallback ladder as MouseMoveAbs.
|
||||||
let y = region.y as f32 + ny * region.height as f32;
|
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
||||||
|
Some(region) => (
|
||||||
|
region.x as f32 + nx * region.width as f32,
|
||||||
|
region.y as f32 + ny * region.height as f32,
|
||||||
|
),
|
||||||
|
None => match self.output_hint {
|
||||||
|
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||||
|
None => (ev.x as f32, ev.y as f32),
|
||||||
|
},
|
||||||
|
};
|
||||||
if ev.kind == InputKind::TouchDown {
|
if ev.kind == InputKind::TouchDown {
|
||||||
t.down(ev.code, x, y);
|
t.down(ev.code, x, y);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
//! Client-side cursor rendering (design/remote-desktop-sweep.md M2): the host forwards the
|
|
||||||
//! pointer's SHAPE (reliable control stream, cached by serial) and per-frame STATE (lossy
|
|
||||||
//! `0xD0` — position/visibility), and WE draw it as a real OS cursor — pointer feel stops
|
|
||||||
//! paying the video round-trip (the Parsec/RDP model). Active only when the session
|
|
||||||
//! negotiated it (`HOST_CAP_CURSOR` in the Welcome — the host stopped compositing then) and
|
|
||||||
//! only applied while the DESKTOP mouse model is engaged: under capture the pointer is
|
|
||||||
//! relative-locked (SDL hides it) and games draw their own cursor in-frame.
|
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
|
||||||
use punktfunk_core::quic::{CursorState, HOST_CAP_CURSOR};
|
|
||||||
use sdl3::mouse::{Cursor, MouseUtil, SystemCursor};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Shape serials cached at most — cursors cycle through a handful of shapes (arrow, I-beam,
|
|
||||||
/// resize…); a runaway host can't grow the map past this (the cache resets, shapes re-arrive
|
|
||||||
/// on the reliable stream via the serial-miss path).
|
|
||||||
const SHAPE_CACHE_MAX: usize = 64;
|
|
||||||
|
|
||||||
pub struct CursorChannel {
|
|
||||||
/// The Welcome carried `HOST_CAP_CURSOR` — the host forwards instead of compositing.
|
|
||||||
negotiated: bool,
|
|
||||||
/// Serial → built OS cursor. An SDL `Cursor` must outlive its `set()`, so the cache owns
|
|
||||||
/// every shape ever applied this session (bounded by [`SHAPE_CACHE_MAX`]).
|
|
||||||
shapes: HashMap<u32, Cursor>,
|
|
||||||
/// The serial currently installed via `Cursor::set` (`None` = default/system cursor).
|
|
||||||
installed: Option<u32>,
|
|
||||||
/// Latest `0xD0` state (latest-wins across a drained batch).
|
|
||||||
state: Option<CursorState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorChannel {
|
|
||||||
pub fn new(connector: &NativeClient) -> CursorChannel {
|
|
||||||
let negotiated = connector.host_caps() & HOST_CAP_CURSOR != 0;
|
|
||||||
if negotiated {
|
|
||||||
tracing::info!("cursor channel negotiated — host cursor renders locally");
|
|
||||||
}
|
|
||||||
CursorChannel {
|
|
||||||
negotiated,
|
|
||||||
shapes: HashMap::new(),
|
|
||||||
installed: None,
|
|
||||||
state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the host forwards the cursor this session (it no longer composites one).
|
|
||||||
pub fn negotiated(&self) -> bool {
|
|
||||||
self.negotiated
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain the two planes and apply the newest state — once per run-loop iteration.
|
|
||||||
/// `desktop_active` = the desktop mouse model is engaged (captured + desktop): only then
|
|
||||||
/// do we own the local cursor's shape/visibility; under capture SDL's relative mode owns
|
|
||||||
/// it, and released the system cursor must look normal.
|
|
||||||
pub fn pump(&mut self, connector: &NativeClient, mouse: &MouseUtil, desktop_active: bool) {
|
|
||||||
if !self.negotiated {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while let Ok(shape) = connector.next_cursor_shape(Duration::ZERO) {
|
|
||||||
if self.shapes.len() >= SHAPE_CACHE_MAX {
|
|
||||||
// Degenerate host: reset — live shapes re-install via the serial-miss path.
|
|
||||||
self.shapes.clear();
|
|
||||||
self.installed = None;
|
|
||||||
}
|
|
||||||
let mut data = shape.rgba;
|
|
||||||
let built = sdl3::surface::Surface::from_data(
|
|
||||||
&mut data,
|
|
||||||
shape.w as u32,
|
|
||||||
shape.h as u32,
|
|
||||||
shape.w as u32 * 4,
|
|
||||||
sdl3::pixels::PixelFormat::RGBA32,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
.and_then(|surf| {
|
|
||||||
Cursor::from_surface(&surf, shape.hot_x as i32, shape.hot_y as i32)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
});
|
|
||||||
match built {
|
|
||||||
Ok(cursor) => {
|
|
||||||
// A re-sent serial replaces its entry; force re-install if it's current.
|
|
||||||
if self.installed == Some(shape.serial) {
|
|
||||||
self.installed = None;
|
|
||||||
}
|
|
||||||
self.shapes.insert(shape.serial, cursor);
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, w = shape.w, h = shape.h,
|
|
||||||
"cursor shape rejected by SDL — keeping the previous cursor"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Ok(st) = connector.next_cursor_state(Duration::ZERO) {
|
|
||||||
self.state = Some(st); // latest wins
|
|
||||||
}
|
|
||||||
|
|
||||||
if !desktop_active {
|
|
||||||
// Capture mode / released: hand the cursor back to the system default so a
|
|
||||||
// released pointer over the window doesn't wear the host's shape.
|
|
||||||
if self.installed.take().is_some() {
|
|
||||||
Cursor::from_system(SystemCursor::Arrow)
|
|
||||||
.map(|c| c.set())
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(st) = self.state else { return };
|
|
||||||
if st.visible() && self.installed != Some(st.serial) {
|
|
||||||
if let Some(cursor) = self.shapes.get(&st.serial) {
|
|
||||||
cursor.set();
|
|
||||||
self.installed = Some(st.serial);
|
|
||||||
}
|
|
||||||
// Serial miss: the (reliable) shape hasn't landed yet — keep the previous
|
|
||||||
// cursor for the RTT rather than flashing default.
|
|
||||||
}
|
|
||||||
// Visibility follows the host (a host app hid its pointer ⇒ ours hides too). Queried,
|
|
||||||
// not shadowed, so apply_capture's own show/hide calls can never desync us.
|
|
||||||
if mouse.is_cursor_showing() != st.visible() {
|
|
||||||
mouse.show_cursor(st.visible());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,17 +14,10 @@
|
|||||||
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
|
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
|
||||||
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
|
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
|
||||||
//! otherwise send a datagram per event).
|
//! otherwise send a datagram per event).
|
||||||
//!
|
|
||||||
//! The DESKTOP mouse model (design/remote-desktop-sweep.md M1) reuses this same engage/
|
|
||||||
//! release state but never locks the pointer: the local cursor moves freely (hidden over
|
|
||||||
//! the window — the host's composited cursor is the one you see) and motion goes on the
|
|
||||||
//! wire as absolute positions through the letterbox (`MouseMoveAbs`, latest-wins per loop
|
|
||||||
//! iteration). Requires a host injector with absolute support — gamescope's EIS is
|
|
||||||
//! relative-only, so sessions there are pinned to capture ([`Capture::new`] `abs_ok`).
|
|
||||||
|
|
||||||
use crate::keymap_sdl;
|
use crate::keymap_sdl;
|
||||||
use crate::touch::{Abs, Act, Gestures};
|
use crate::touch::{Abs, Act, Gestures};
|
||||||
use pf_client_core::trust::{MouseMode, TouchMode};
|
use pf_client_core::trust::TouchMode;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -48,13 +41,6 @@ pub struct Capture {
|
|||||||
held_buttons: HashSet<u32>,
|
held_buttons: HashSet<u32>,
|
||||||
/// Relative motion not yet on the wire, summed per loop iteration.
|
/// Relative motion not yet on the wire, summed per loop iteration.
|
||||||
pending_rel: (i32, i32),
|
pending_rel: (i32, i32),
|
||||||
/// Desktop-model position not yet on the wire, latest-wins per loop iteration.
|
|
||||||
pending_abs: Option<Abs>,
|
|
||||||
/// The desktop (absolute, uncaptured) mouse model is active. Flipped live by the
|
|
||||||
/// Ctrl+Alt+Shift+M chord; never true unless `abs_ok`.
|
|
||||||
desktop: bool,
|
|
||||||
/// The host injector accepts `MouseMoveAbs` (any compositor but gamescope).
|
|
||||||
abs_ok: bool,
|
|
||||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
||||||
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||||
scroll_acc: (f64, f64),
|
scroll_acc: (f64, f64),
|
||||||
@@ -84,14 +70,10 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Capture {
|
impl Capture {
|
||||||
/// `abs_ok` = the host injector accepts absolute pointer events; without it the
|
|
||||||
/// desktop model is unavailable and `mouse_mode` silently resolves to capture.
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
touch_mode: TouchMode,
|
touch_mode: TouchMode,
|
||||||
invert_scroll: bool,
|
invert_scroll: bool,
|
||||||
mouse_mode: MouseMode,
|
|
||||||
abs_ok: bool,
|
|
||||||
) -> Capture {
|
) -> Capture {
|
||||||
Capture {
|
Capture {
|
||||||
connector,
|
connector,
|
||||||
@@ -100,9 +82,6 @@ impl Capture {
|
|||||||
held_keys: HashSet::new(),
|
held_keys: HashSet::new(),
|
||||||
held_buttons: HashSet::new(),
|
held_buttons: HashSet::new(),
|
||||||
pending_rel: (0, 0),
|
pending_rel: (0, 0),
|
||||||
pending_abs: None,
|
|
||||||
desktop: abs_ok && mouse_mode == MouseMode::Desktop,
|
|
||||||
abs_ok,
|
|
||||||
scroll_acc: (0.0, 0.0),
|
scroll_acc: (0.0, 0.0),
|
||||||
touch_slots: HashMap::new(),
|
touch_slots: HashMap::new(),
|
||||||
touch_mode,
|
touch_mode,
|
||||||
@@ -115,24 +94,6 @@ impl Capture {
|
|||||||
self.captured
|
self.captured
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The desktop (absolute, uncaptured) mouse model is active.
|
|
||||||
pub fn desktop(&self) -> bool {
|
|
||||||
self.desktop
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip capture ⇄ desktop (the Ctrl+Alt+Shift+M chord). `None` = the host can't take
|
|
||||||
/// absolute pointer events (gamescope), so the chord has nothing to offer; otherwise
|
|
||||||
/// the new desktop state. Motion gathered under the old model never crosses modes.
|
|
||||||
pub fn toggle_desktop(&mut self) -> Option<bool> {
|
|
||||||
if !self.abs_ok {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.desktop = !self.desktop;
|
|
||||||
self.pending_rel = (0, 0);
|
|
||||||
self.pending_abs = None;
|
|
||||||
Some(self.desktop)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a regained focus should re-engage: yes unless the user released
|
/// Whether a regained focus should re-engage: yes unless the user released
|
||||||
/// deliberately (the chord keeps its meaning across an Alt-Tab).
|
/// deliberately (the chord keeps its meaning across an Alt-Tab).
|
||||||
pub fn should_reengage(&self) -> bool {
|
pub fn should_reengage(&self) -> bool {
|
||||||
@@ -156,7 +117,6 @@ impl Capture {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.pending_rel = (0, 0); // never flush motion gathered while captured
|
self.pending_rel = (0, 0); // never flush motion gathered while captured
|
||||||
self.pending_abs = None;
|
|
||||||
for vk in self.held_keys.drain() {
|
for vk in self.held_keys.drain() {
|
||||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||||
}
|
}
|
||||||
@@ -172,42 +132,22 @@ impl Capture {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward the coalesced motion, if any — one datagram per loop iteration. Only one
|
/// Forward the coalesced motion delta, if any — one datagram per loop iteration.
|
||||||
/// of the two stores is ever populated (the run loop routes by [`desktop`](Self::desktop)).
|
|
||||||
pub fn flush_motion(&mut self) {
|
pub fn flush_motion(&mut self) {
|
||||||
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
||||||
if dx != 0 || dy != 0 {
|
if dx != 0 || dy != 0 {
|
||||||
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
||||||
}
|
}
|
||||||
if let Some(a) = self.pending_abs.take() {
|
|
||||||
send(
|
|
||||||
&self.connector,
|
|
||||||
InputKind::MouseMoveAbs,
|
|
||||||
0,
|
|
||||||
a.x,
|
|
||||||
a.y,
|
|
||||||
Self::touch_flags(a.w, a.h),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
|
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
|
||||||
pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
|
pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
|
||||||
if self.captured && !self.desktop {
|
if self.captured {
|
||||||
self.pending_rel.0 += xrel as i32;
|
self.pending_rel.0 += xrel as i32;
|
||||||
self.pending_rel.1 += yrel as i32;
|
self.pending_rel.1 += yrel as i32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Desktop-model motion: the cursor's position mapped into the letterboxed content
|
|
||||||
/// rect. Latest-wins — intermediate positions carry no information the final one
|
|
||||||
/// doesn't (unlike deltas, which must sum).
|
|
||||||
pub fn on_motion_abs(&mut self, abs: Abs) {
|
|
||||||
if self.captured && self.desktop {
|
|
||||||
self.pending_abs = Some(abs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) {
|
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) {
|
||||||
if !self.captured {
|
if !self.captured {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod csc;
|
pub mod csc;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
|
||||||
pub mod cursor;
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod d3d11;
|
pub mod d3d11;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
+20
-113
@@ -20,11 +20,11 @@ use crate::vk::{FrameInput, Presenter};
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use pf_client_core::gamepad::GamepadService;
|
use pf_client_core::gamepad::GamepadService;
|
||||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||||
use pf_client_core::video::VulkanDecodeDevice;
|
use pf_client_core::video::VulkanDecodeDevice;
|
||||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, Mode};
|
use punktfunk_core::config::Mode;
|
||||||
use sdl3::event::{Event, WindowEvent};
|
use sdl3::event::{Event, WindowEvent};
|
||||||
use sdl3::keyboard::Mod;
|
use sdl3::keyboard::Mod;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -48,11 +48,6 @@ pub struct SessionOpts {
|
|||||||
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
||||||
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
||||||
pub touch_mode: TouchMode,
|
pub touch_mode: TouchMode,
|
||||||
/// Physical-mouse model: `Capture` (pointer lock + relative, the default) or `Desktop`
|
|
||||||
/// (uncaptured absolute pointer — design/remote-desktop-sweep.md M1). Ctrl+Alt+Shift+M
|
|
||||||
/// flips it live; silently resolves to capture on hosts without absolute injection
|
|
||||||
/// (gamescope).
|
|
||||||
pub mouse_mode: MouseMode,
|
|
||||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
||||||
pub invert_scroll: bool,
|
pub invert_scroll: bool,
|
||||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||||
@@ -233,9 +228,6 @@ struct StreamState {
|
|||||||
/// window-normalized position must be re-based onto the content rect). `None` until
|
/// window-normalized position must be re-based onto the content rect). `None` until
|
||||||
/// the first frame; touches before then have nothing to map onto and are dropped.
|
/// the first frame; touches before then have nothing to map onto and are dropped.
|
||||||
last_video: Option<(u32, u32)>,
|
last_video: Option<(u32, u32)>,
|
||||||
/// Client-side cursor rendering (M2 cursor channel) — created with the connector; inert
|
|
||||||
/// when the host didn't negotiate the channel.
|
|
||||||
cursor_chan: Option<crate::cursor::CursorChannel>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -266,7 +258,6 @@ impl StreamState {
|
|||||||
frames: wake_rx,
|
frames: wake_rx,
|
||||||
connector: None,
|
connector: None,
|
||||||
capture: None,
|
capture: None,
|
||||||
cursor_chan: None,
|
|
||||||
force_software,
|
force_software,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
ready_announced: false,
|
ready_announced: false,
|
||||||
@@ -499,7 +490,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
WindowEvent::FocusLost => {
|
WindowEvent::FocusLost => {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if cap.release(false) {
|
if cap.release(false) {
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
tracing::info!("focus lost — input released");
|
tracing::info!("focus lost — input released");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,7 +501,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if cap.should_reengage() {
|
if cap.should_reengage() {
|
||||||
cap.engage();
|
cap.engage();
|
||||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
apply_capture(&mut window, &mouse, true);
|
||||||
tracing::info!("focus gained — input recaptured");
|
tracing::info!("focus gained — input recaptured");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,39 +537,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if cap.captured() {
|
if cap.captured() {
|
||||||
cap.release(true);
|
cap.release(true);
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
} else {
|
} else {
|
||||||
cap.engage();
|
cap.engage();
|
||||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
apply_capture(&mut window, &mouse, true);
|
||||||
}
|
}
|
||||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Mouse model flip (capture ⇄ desktop) — applies immediately when
|
|
||||||
// engaged; a released stream just changes what the next engage does.
|
|
||||||
if chord && sc == Scancode::M {
|
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
|
||||||
match cap.toggle_desktop() {
|
|
||||||
Some(desktop) => {
|
|
||||||
if cap.captured() {
|
|
||||||
apply_capture(&mut window, &mouse, true, desktop);
|
|
||||||
}
|
|
||||||
tracing::info!(desktop, "chord: mouse mode");
|
|
||||||
}
|
|
||||||
None => tracing::info!(
|
|
||||||
"chord: mouse mode — host has no absolute pointer \
|
|
||||||
(gamescope), staying captured"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if chord && sc == Scancode::D {
|
if chord && sc == Scancode::D {
|
||||||
if let Some(st) = &mut stream {
|
if let Some(st) = &mut stream {
|
||||||
tracing::info!("chord: disconnect");
|
tracing::info!("chord: disconnect");
|
||||||
st.request_quit();
|
st.request_quit();
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
// The pump emits Ended(None); the end path routes per mode.
|
// The pump emits Ended(None); the end path routes per mode.
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -611,42 +583,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
cap.on_key_up(sc);
|
cap.on_key_up(sc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::MouseMotion {
|
Event::MouseMotion { xrel, yrel, .. } => {
|
||||||
x, y, xrel, yrel, ..
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
} => {
|
|
||||||
if let Some(st) = stream.as_mut() {
|
|
||||||
let video = st.last_video;
|
|
||||||
if let Some(cap) = st.capture.as_mut() {
|
|
||||||
if cap.desktop() {
|
|
||||||
// Desktop model: the cursor's window position through the
|
|
||||||
// letterbox (same mapping as a pointer-mode finger).
|
|
||||||
// Before the first decoded frame there is nothing to map
|
|
||||||
// onto — dropped, like touch.
|
|
||||||
if let Some(video) = video {
|
|
||||||
let (lw, lh) = window.size();
|
|
||||||
let nx = x / lw.max(1) as f32;
|
|
||||||
let ny = y / lh.max(1) as f32;
|
|
||||||
let (ax, ay, aw, ah) =
|
|
||||||
finger_to_content(window.size_in_pixels(), video, nx, ny);
|
|
||||||
cap.on_motion_abs(Abs {
|
|
||||||
x: ax,
|
|
||||||
y: ay,
|
|
||||||
w: aw,
|
|
||||||
h: ah,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cap.on_motion(xrel, yrel);
|
cap.on_motion(xrel, yrel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::MouseButtonDown { mouse_btn, .. } => {
|
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if !cap.captured() {
|
if !cap.captured() {
|
||||||
// The engaging click is suppressed toward the host.
|
// The engaging click is suppressed toward the host.
|
||||||
cap.engage();
|
cap.engage();
|
||||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
apply_capture(&mut window, &mouse, true);
|
||||||
} else {
|
} else {
|
||||||
cap.on_button_down(mouse_btn);
|
cap.on_button_down(mouse_btn);
|
||||||
}
|
}
|
||||||
@@ -748,17 +695,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
cap.flush_motion();
|
cap.flush_motion();
|
||||||
}
|
}
|
||||||
// Cursor channel (M2): drain forwarded shape/state and drive the local OS cursor —
|
|
||||||
// only meaningful in the desktop mouse model (capture's relative lock hides it).
|
|
||||||
if let Some(st) = stream.as_mut() {
|
|
||||||
if let (Some(chan), Some(c)) = (st.cursor_chan.as_mut(), st.connector.as_ref()) {
|
|
||||||
let desktop_active = st
|
|
||||||
.capture
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|cap| cap.captured() && cap.desktop());
|
|
||||||
chan.pump(c, &mouse, desktop_active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text input follows the overlay's editing state (edge-triggered).
|
// Text input follows the overlay's editing state (edge-triggered).
|
||||||
let want_text = overlay.as_ref().is_some_and(|o| o.text_input_active());
|
let want_text = overlay.as_ref().is_some_and(|o| o.text_input_active());
|
||||||
@@ -778,7 +714,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
while escape_rx.try_recv().is_ok() {
|
while escape_rx.try_recv().is_ok() {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if cap.release(true) {
|
if cap.release(true) {
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fullscreen && !opts.fullscreen {
|
if fullscreen && !opts.fullscreen {
|
||||||
@@ -791,7 +727,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(st) = &mut stream {
|
if let Some(st) = &mut stream {
|
||||||
tracing::info!("controller chord: disconnect");
|
tracing::info!("controller chord: disconnect");
|
||||||
st.request_quit();
|
st.request_quit();
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,28 +818,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
.ok();
|
.ok();
|
||||||
gamepad.attach(c.clone());
|
gamepad.attach(c.clone());
|
||||||
st.clock_offset = Some(c.clock_offset_shared());
|
st.clock_offset = Some(c.clock_offset_shared());
|
||||||
// gamescope's EIS grants only a relative pointer — absolute sends
|
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
|
||||||
// would be dropped, so the desktop model is pinned off there. Auto
|
|
||||||
// (an older host that didn't say) stays allowed: Windows hosts and
|
|
||||||
// pre-Welcome-compositor Linux hosts both take absolute.
|
|
||||||
let abs_ok = c.resolved_compositor != CompositorPref::Gamescope;
|
|
||||||
if opts.mouse_mode == MouseMode::Desktop && !abs_ok {
|
|
||||||
tracing::info!(
|
|
||||||
"desktop mouse mode unavailable on a gamescope host \
|
|
||||||
(relative-only input) — using capture"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut cap = Capture::new(
|
|
||||||
c.clone(),
|
|
||||||
opts.touch_mode,
|
|
||||||
opts.invert_scroll,
|
|
||||||
opts.mouse_mode,
|
|
||||||
abs_ok,
|
|
||||||
);
|
|
||||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
apply_capture(&mut window, &mouse, true);
|
||||||
st.capture = Some(cap);
|
st.capture = Some(cap);
|
||||||
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
|
|
||||||
st.connector = Some(c);
|
st.connector = Some(c);
|
||||||
if let Some(f) = opts.on_connected.as_mut() {
|
if let Some(f) = opts.on_connected.as_mut() {
|
||||||
f(fingerprint);
|
f(fingerprint);
|
||||||
@@ -952,7 +870,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(st) = stream.take() {
|
if let Some(st) = stream.take() {
|
||||||
st.shutdown();
|
st.shutdown();
|
||||||
}
|
}
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
if let Some(o) = overlay.as_mut() {
|
if let Some(o) = overlay.as_mut() {
|
||||||
// A user-canceled dial ends silently — no error scene.
|
// A user-canceled dial ends silently — no error scene.
|
||||||
if canceled {
|
if canceled {
|
||||||
@@ -969,7 +887,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(cap) = &mut st.capture {
|
if let Some(cap) = &mut st.capture {
|
||||||
cap.release(true);
|
cap.release(true);
|
||||||
}
|
}
|
||||||
apply_capture(&mut window, &mouse, false, false);
|
apply_capture(&mut window, &mouse, false);
|
||||||
match &mode {
|
match &mode {
|
||||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||||
ModeCtl::Browse(_) => {
|
ModeCtl::Browse(_) => {
|
||||||
@@ -1559,22 +1477,11 @@ impl ResizeIndicator {
|
|||||||
/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process
|
/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process
|
||||||
/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor
|
/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor
|
||||||
/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts).
|
/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts).
|
||||||
///
|
fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool) {
|
||||||
/// The `desktop` mouse model never locks: the pointer roams (and leaves the window)
|
mouse.set_relative_mouse_mode(window, on);
|
||||||
/// freely, the local cursor is hidden over the window — the host's composited cursor,
|
|
||||||
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
|
|
||||||
/// who draws it) — and system chords stay local (a remote desktop is something you
|
|
||||||
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
|
|
||||||
fn apply_capture(
|
|
||||||
window: &mut sdl3::video::Window,
|
|
||||||
mouse: &sdl3::mouse::MouseUtil,
|
|
||||||
on: bool,
|
|
||||||
desktop: bool,
|
|
||||||
) {
|
|
||||||
mouse.set_relative_mouse_mode(window, on && !desktop);
|
|
||||||
mouse.show_cursor(!on);
|
mouse.show_cursor(!on);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
window.set_keyboard_grab(on && !desktop);
|
window.set_keyboard_grab(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
|
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
|
||||||
@@ -1692,7 +1599,7 @@ struct PresentedWindow {
|
|||||||
|
|
||||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||||
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||||
Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
||||||
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||||
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ pub use session::{session_epoch, try_recover_session};
|
|||||||
#[path = "vdisplay/routing.rs"]
|
#[path = "vdisplay/routing.rs"]
|
||||||
pub(crate) mod routing;
|
pub(crate) mod routing;
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
apply_input_env, managed_session_available, restore_managed_session,
|
||||||
wants_dedicated_game_session,
|
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
|
|||||||
@@ -1116,6 +1116,26 @@ pub fn schedule_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does any DRM connector report a physically `connected` display? Scans
|
||||||
|
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
|
||||||
|
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
|
||||||
|
/// headless box (VM, panel-less mini PC) has none — in which case a "restore to the physical
|
||||||
|
/// panel" can only fail, gamescope having no output to drive. Errors (no DRM at all, sysfs
|
||||||
|
/// unreadable) read as headless: the safe direction is keeping the working session.
|
||||||
|
fn physical_display_connected() -> bool {
|
||||||
|
connected_connector_under(std::path::Path::new("/sys/class/drm"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`physical_display_connected`] against an arbitrary sysfs root (the unit-testable core).
|
||||||
|
fn connected_connector_under(base: &std::path::Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(base) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
entries.flatten().any(|e| {
|
||||||
|
std::fs::read_to_string(e.path().join("status")).is_ok_and(|s| s.trim() == "connected")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||||
@@ -1127,6 +1147,19 @@ fn do_restore_tv_session() {
|
|||||||
{
|
{
|
||||||
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if *took {
|
if *took {
|
||||||
|
// A box with no physically connected display (a VM, a panel-less mini PC) has no
|
||||||
|
// "physical gaming session" to restore TO: removing the drop-in and restarting the
|
||||||
|
// target just crash-loops gamescope (no output to drive) and strands every later
|
||||||
|
// connect on "no usable compositor". Keep the headless session — and the takeover
|
||||||
|
// state, so a same-mode reconnect reuses it warm — instead. Checked at restore time
|
||||||
|
// (not connect time) so plugging a panel in later restores normally.
|
||||||
|
if !physical_display_connected() {
|
||||||
|
tracing::info!(
|
||||||
|
"gamescope (SteamOS): no physical display connected — keeping the headless \
|
||||||
|
session (nothing to restore to)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
*took = false;
|
*took = false;
|
||||||
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
@@ -1226,15 +1259,31 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|||||||
/// session). Shared by the attach and host-managed-session paths.
|
/// session). Shared by the attach and host-managed-session paths.
|
||||||
fn point_injector_at_eis() {
|
fn point_injector_at_eis() {
|
||||||
match find_gamescope_eis_socket() {
|
match find_gamescope_eis_socket() {
|
||||||
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
|
Some(sock) => {
|
||||||
|
// Relay format: line 1 = socket, optional line 2 = the session's CURRENT output
|
||||||
|
// size as "WxH". gamescope's EIS advertises only a degenerate INT32_MAX region, so
|
||||||
|
// the injector can't learn the output geometry from the protocol — the hint lets
|
||||||
|
// it scale normalized client positions correctly even when the client streams at
|
||||||
|
// a different resolution than the session runs (foreign attach, supersample).
|
||||||
|
let size = current_gamescope_output_size();
|
||||||
|
let body = match size {
|
||||||
|
Some((w, h)) => format!("{sock}\n{w}x{h}"),
|
||||||
|
None => sock.clone(),
|
||||||
|
};
|
||||||
|
match std::fs::write(ei_socket_file(), body) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
tracing::info!(
|
||||||
|
socket = %sock,
|
||||||
|
output = ?size,
|
||||||
|
"gamescope: pointed injector at the session's EIS socket"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"gamescope: could not write the EIS relay file — input may not reach the session"
|
"gamescope: could not write the EIS relay file — input may not reach the session"
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
None => tracing::warn!(
|
None => tracing::warn!(
|
||||||
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
||||||
),
|
),
|
||||||
@@ -1524,7 +1573,35 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
|
use super::{
|
||||||
|
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
|
||||||
|
shape_dedicated_command,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_status_scan() {
|
||||||
|
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||||
|
let mk = |name: &str, status: Option<&str>| {
|
||||||
|
let dir = base.join(name);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
if let Some(s) = status {
|
||||||
|
std::fs::write(dir.join("status"), s).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Headless layout: device + render nodes only (no status files) → not connected.
|
||||||
|
mk("card0", None);
|
||||||
|
mk("renderD128", None);
|
||||||
|
assert!(!connected_connector_under(&base));
|
||||||
|
// Connectors present but nothing plugged in → still not connected.
|
||||||
|
mk("card0-HDMI-A-1", Some("disconnected\n"));
|
||||||
|
assert!(!connected_connector_under(&base));
|
||||||
|
// A live panel → connected.
|
||||||
|
mk("card0-eDP-1", Some("connected\n"));
|
||||||
|
assert!(connected_connector_under(&base));
|
||||||
|
// A missing base dir (no DRM at all) reads as headless.
|
||||||
|
assert!(!connected_connector_under(&base.join("nope")));
|
||||||
|
std::fs::remove_dir_all(&base).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
|
|||||||
@@ -207,6 +207,20 @@ pub fn cancel_pending_tv_restore() {
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn cancel_pending_tv_restore() {}
|
pub fn cancel_pending_tv_restore() {}
|
||||||
|
|
||||||
|
/// Can the MANAGED gamescope path stand a session up from nothing on this box (SteamOS's
|
||||||
|
/// `gamescope-session` launcher or Bazzite's `gamescope-session-plus` present)? Lets the connect
|
||||||
|
/// path route a "no live graphical session" box to the gamescope takeover — which rebuilds the
|
||||||
|
/// session at the client's mode — instead of failing the connect. Always `false` off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn managed_session_available() -> bool {
|
||||||
|
gamescope::managed_session_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn managed_session_available() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||||
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
||||||
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||||
|
|||||||
@@ -861,7 +861,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
||||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
// Mark the path inactive AND unpin its modes: per the SetDisplayConfig
|
||||||
|
// contract a path being turned OFF needs BOTH mode indexes marked invalid,
|
||||||
|
// and leaving them referencing the queried mode entries gets the whole
|
||||||
|
// supplied config rejected with 0x57 ERROR_INVALID_PARAMETER on some
|
||||||
|
// driver/topology combinations (field-reported: exclusive mode left the
|
||||||
|
// physical panel lit, every retry failing 0x57). Writing the all-ones
|
||||||
|
// sentinel to the whole union is also correct under the virtual-mode-aware
|
||||||
|
// interpretation (cloneGroupId/sourceModeInfoIdx both become their 0xffff
|
||||||
|
// INVALID values).
|
||||||
|
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE;
|
||||||
|
p.sourceInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||||
|
p.targetInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||||
others += 1;
|
others += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1574,10 +1574,6 @@ unsafe fn connect_ex_impl(
|
|||||||
// themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8`
|
// themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8`
|
||||||
// variant can carry it if a passthrough embedder ever needs it.
|
// variant can carry it if a passthrough embedder ever needs it.
|
||||||
None,
|
None,
|
||||||
// No client_caps in the C ABI yet either: cursor-channel opt-in for Apple/Android
|
|
||||||
// arrives with the ABI v11 cursor poll fns — until an embedder can RENDER the
|
|
||||||
// forwarded cursor it must not ask the host to stop compositing it.
|
|
||||||
0,
|
|
||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
|||||||
use self::control::{CtrlRequest, Negotiated};
|
use self::control::{CtrlRequest, Negotiated};
|
||||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||||
use self::planes::{
|
use self::planes::{
|
||||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE,
|
||||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
RUMBLE_QUEUE,
|
||||||
};
|
};
|
||||||
use self::probe::ProbeState;
|
use self::probe::ProbeState;
|
||||||
use self::pump::run_pump;
|
use self::pump::run_pump;
|
||||||
@@ -93,12 +93,6 @@ pub struct NativeClient {
|
|||||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||||
/// [`quic::VIDEO_CAP_HOST_TIMING`]; an older host simply never sends any).
|
/// [`quic::VIDEO_CAP_HOST_TIMING`]; an older host simply never sends any).
|
||||||
host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
|
host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
|
||||||
/// Inbound cursor shapes (control-stream [`crate::quic::CursorShape`]) — only a session
|
|
||||||
/// that advertised [`quic::CLIENT_CAP_CURSOR`] against a [`quic::HOST_CAP_CURSOR`] host
|
|
||||||
/// ever receives any.
|
|
||||||
cursor_shape: Mutex<Receiver<crate::quic::CursorShape>>,
|
|
||||||
/// Inbound per-frame cursor state — `0xD0` datagrams (same negotiation gate as shapes).
|
|
||||||
cursor_state: Mutex<Receiver<crate::quic::CursorState>>,
|
|
||||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||||
/// Bounded ([`MIC_QUEUE`]): a wedged worker drops fresh frames (logged) instead of queueing
|
/// Bounded ([`MIC_QUEUE`]): a wedged worker drops fresh frames (logged) instead of queueing
|
||||||
@@ -322,12 +316,6 @@ impl NativeClient {
|
|||||||
// display's EDID so host apps tone-map to the client's real panel; `None` = unknown/SDR
|
// display's EDID so host apps tone-map to the client's real panel; `None` = unknown/SDR
|
||||||
// (the host keeps its built-in EDID defaults). See [`crate::quic::Hello::display_hdr`].
|
// (the host keeps its built-in EDID defaults). See [`crate::quic::Hello::display_hdr`].
|
||||||
display_hdr: Option<HdrMeta>,
|
display_hdr: Option<HdrMeta>,
|
||||||
// Non-video client capabilities ([`crate::quic::Hello::client_caps`]) — set
|
|
||||||
// [`crate::quic::CLIENT_CAP_CURSOR`] ONLY if this embedder actually renders the host
|
|
||||||
// cursor locally (shape + state planes): the host stops compositing the pointer into
|
|
||||||
// the video for a session that advertises it, so a non-rendering embedder that sets it
|
|
||||||
// streams with NO visible cursor at all. `0` = today's composited behavior.
|
|
||||||
client_caps: u8,
|
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
@@ -349,10 +337,6 @@ impl NativeClient {
|
|||||||
let (clip_event_tx, clip_event_rx) =
|
let (clip_event_tx, clip_event_rx) =
|
||||||
std::sync::mpsc::sync_channel::<ClipEventCore>(CLIP_EVENT_QUEUE);
|
std::sync::mpsc::sync_channel::<ClipEventCore>(CLIP_EVENT_QUEUE);
|
||||||
let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<ClipCommand>();
|
let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<ClipCommand>();
|
||||||
let (cursor_shape_tx, cursor_shape_rx) =
|
|
||||||
std::sync::mpsc::sync_channel::<crate::quic::CursorShape>(CURSOR_SHAPE_QUEUE);
|
|
||||||
let (cursor_state_tx, cursor_state_rx) =
|
|
||||||
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
|
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let quit = Arc::new(AtomicBool::new(false));
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
@@ -406,7 +390,6 @@ impl NativeClient {
|
|||||||
video_codecs,
|
video_codecs,
|
||||||
preferred_codec,
|
preferred_codec,
|
||||||
display_hdr,
|
display_hdr,
|
||||||
client_caps,
|
|
||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
@@ -418,8 +401,6 @@ impl NativeClient {
|
|||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
cursor_shape_tx,
|
|
||||||
cursor_state_tx,
|
|
||||||
input_rx,
|
input_rx,
|
||||||
mic_rx,
|
mic_rx,
|
||||||
rich_input_rx,
|
rich_input_rx,
|
||||||
@@ -464,8 +445,6 @@ impl NativeClient {
|
|||||||
hidout: Mutex::new(hidout_rx),
|
hidout: Mutex::new(hidout_rx),
|
||||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||||
host_timing: Mutex::new(host_timing_rx),
|
host_timing: Mutex::new(host_timing_rx),
|
||||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
|
||||||
cursor_state: Mutex::new(cursor_state_rx),
|
|
||||||
input_tx,
|
input_tx,
|
||||||
mic_tx,
|
mic_tx,
|
||||||
rich_input_tx,
|
rich_input_tx,
|
||||||
@@ -913,32 +892,6 @@ impl NativeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the next host cursor shape (design/remote-desktop-sweep.md M2): RGBA bitmap +
|
|
||||||
/// hotspot, sent on pointer-bitmap change over the reliable control stream. The embedder
|
|
||||||
/// caches by `serial` and builds an OS cursor from it; [`NativeClient::next_cursor_state`]
|
|
||||||
/// references shapes by serial. Only a session that advertised
|
|
||||||
/// [`crate::quic::CLIENT_CAP_CURSOR`] against a capable host receives any. Same
|
|
||||||
/// timeout/closed semantics as [`NativeClient::next_hidout`].
|
|
||||||
pub fn next_cursor_shape(&self, timeout: Duration) -> Result<crate::quic::CursorShape> {
|
|
||||||
match self.cursor_shape.lock().unwrap().recv_timeout(timeout) {
|
|
||||||
Ok(s) => Ok(s),
|
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull the next per-frame cursor state (`0xD0`): position, visibility and the M3
|
|
||||||
/// relative-mode hint, referencing a shape by serial. Latest-wins — an embedder should
|
|
||||||
/// drain the queue and apply only the newest. Same negotiation gate and timeout/closed
|
|
||||||
/// semantics as [`NativeClient::next_cursor_shape`].
|
|
||||||
pub fn next_cursor_state(&self, timeout: Duration) -> Result<crate::quic::CursorState> {
|
|
||||||
match self.cursor_state.lock().unwrap().recv_timeout(timeout) {
|
|
||||||
Ok(s) => Ok(s),
|
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull the next per-AU host timing (0xCF): the host's capture→sent duration for one access
|
/// Pull the next per-AU host timing (0xCF): the host's capture→sent duration for one access
|
||||||
/// unit, correlated to the AU by `pts_ns`. Feeds the unified stats HUD's `host` / `network`
|
/// unit, correlated to the AU by `pts_ns`. Feeds the unified stats HUD's `host` / `network`
|
||||||
/// split (`network = (received + clock_offset − pts) − host_us`); a stats consumer should
|
/// split (`network = (received + clock_offset − pts) − host_us`); a stats consumer should
|
||||||
|
|||||||
@@ -86,10 +86,22 @@ impl NativeClient {
|
|||||||
// A typed application close from the host (pairing not armed / armed for a
|
// A typed application close from the host (pairing not armed / armed for a
|
||||||
// different device / rate-limited / version mismatch) beats the generic
|
// different device / rate-limited / version mismatch) beats the generic
|
||||||
// transport error the aborted exchange produced — it is the actual answer.
|
// transport error the aborted exchange produced — it is the actual answer.
|
||||||
Err(e) => Err(match reject_from_close(&conn) {
|
// Same close-vs-stream-error race as the connect handshake: give the
|
||||||
|
// host's CONNECTION_CLOSE a short grace to be processed before deciding
|
||||||
|
// the error was plain transport trouble.
|
||||||
|
Err(e) => {
|
||||||
|
if conn.close_reason().is_none() {
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(300),
|
||||||
|
conn.closed(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(match reject_from_close(&conn) {
|
||||||
Some(r) => PunktfunkError::Rejected(r),
|
Some(r) => PunktfunkError::Rejected(r),
|
||||||
None => e,
|
None => e,
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
ok => ok,
|
ok => ok,
|
||||||
};
|
};
|
||||||
// Always tell the host we're done so it never blocks at its read — code 0 on
|
// Always tell the host we're done so it never blocks at its read — code 0 on
|
||||||
|
|||||||
@@ -35,16 +35,6 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512;
|
|||||||
/// a dropped fetch-request makes the serving stream time out and reset cleanly.
|
/// a dropped fetch-request makes the serving stream time out and reset cleanly.
|
||||||
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
||||||
|
|
||||||
/// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap
|
|
||||||
/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a
|
|
||||||
/// serial mismatch against `0xD0` state heals it visually within a shape-change period.
|
|
||||||
pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8;
|
|
||||||
|
|
||||||
/// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the
|
|
||||||
/// embedder drains per present; a tiny ring only bridges scheduling jitter. Overflow drops the
|
|
||||||
/// newest (try_send), healed by the very next frame's datagram.
|
|
||||||
pub(crate) const CURSOR_STATE_QUEUE: usize = 8;
|
|
||||||
|
|
||||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AudioPacket {
|
pub struct AudioPacket {
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
cursor_shape_tx,
|
|
||||||
cursor_state_tx,
|
|
||||||
input_rx,
|
input_rx,
|
||||||
mut mic_rx,
|
mut mic_rx,
|
||||||
mut rich_input_rx,
|
mut rich_input_rx,
|
||||||
@@ -125,7 +123,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
clock_offset: clock_offset.clone(),
|
clock_offset: clock_offset.clone(),
|
||||||
clock_gen: clock_gen.clone(),
|
clock_gen: clock_gen.clone(),
|
||||||
clip_event_tx: clip_event_tx.clone(),
|
clip_event_tx: clip_event_tx.clone(),
|
||||||
cursor_shape_tx,
|
|
||||||
}
|
}
|
||||||
.run(),
|
.run(),
|
||||||
);
|
);
|
||||||
@@ -139,7 +136,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
cursor_state_tx,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches
|
// Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ pub(super) struct ControlTask {
|
|||||||
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
|
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
|
||||||
/// clipboard task uses for fetch data.
|
/// clipboard task uses for fetch data.
|
||||||
pub(super) clip_event_tx: std::sync::mpsc::SyncSender<ClipEventCore>,
|
pub(super) clip_event_tx: std::sync::mpsc::SyncSender<ClipEventCore>,
|
||||||
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
|
||||||
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
|
||||||
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlTask {
|
impl ControlTask {
|
||||||
@@ -39,7 +36,6 @@ impl ControlTask {
|
|||||||
clock_offset,
|
clock_offset,
|
||||||
clock_gen,
|
clock_gen,
|
||||||
clip_event_tx,
|
clip_event_tx,
|
||||||
cursor_shape_tx,
|
|
||||||
} = self;
|
} = self;
|
||||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||||
@@ -171,10 +167,6 @@ impl ControlTask {
|
|||||||
seq: offer.seq,
|
seq: offer.seq,
|
||||||
kinds: offer.kinds,
|
kinds: offer.kinds,
|
||||||
});
|
});
|
||||||
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
|
|
||||||
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
|
|
||||||
// an overflowing ring drops the newest shape — the next change resends.
|
|
||||||
let _ = cursor_shape_tx.try_send(shape);
|
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
tag = ?msg.first(),
|
tag = ?msg.first(),
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
// One parameter per demuxed plane — grouping them into a struct would just move the field
|
|
||||||
// list one hop away from the single call site.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) async fn run(
|
pub(super) async fn run(
|
||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
audio_tx: std::sync::mpsc::SyncSender<AudioPacket>,
|
audio_tx: std::sync::mpsc::SyncSender<AudioPacket>,
|
||||||
@@ -14,7 +11,6 @@ pub(super) async fn run(
|
|||||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||||
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
|
|
||||||
) {
|
) {
|
||||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||||
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||||
@@ -77,11 +73,6 @@ pub(super) async fn run(
|
|||||||
let _ = host_timing_tx.try_send(t);
|
let _ = host_timing_tx.try_send(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(&crate::quic::CURSOR_STATE_MAGIC) => {
|
|
||||||
if let Some(s) = crate::quic::decode_cursor_state_datagram(&d) {
|
|
||||||
let _ = cursor_state_tx.try_send(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // unknown tag — a newer host; ignore
|
_ => {} // unknown tag — a newer host; ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,10 +143,6 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
|||||||
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
||||||
// tone-map to the client's real panel). `None` = unknown/SDR.
|
// tone-map to the client's real panel). `None` = unknown/SDR.
|
||||||
display_hdr,
|
display_hdr,
|
||||||
// NOT unconditional like HOST_TIMING above: CLIENT_CAP_CURSOR makes the host
|
|
||||||
// stop compositing the pointer, so only an embedder that actually renders the
|
|
||||||
// cursor locally may set it (the embedder decides, we pass through).
|
|
||||||
client_caps: args.client_caps,
|
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -244,9 +240,22 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
|||||||
negotiated,
|
negotiated,
|
||||||
host_caps,
|
host_caps,
|
||||||
}),
|
}),
|
||||||
Err(e) => Err(match reject_from_close(&conn) {
|
Err(e) => {
|
||||||
|
// The host's typed close can land a beat AFTER the stream error it caused: the
|
||||||
|
// stream reset/FIN and the CONNECTION_CLOSE are in flight together, and quinn can
|
||||||
|
// hand the reader its mid-frame EOF before it processes the close. Give the close a
|
||||||
|
// short grace to arrive so a host-side setup failure renders as its real reason
|
||||||
|
// ("the host could not start the stream session") instead of "control stream
|
||||||
|
// finished mid-frame". No-op when the connection already closed (or never will —
|
||||||
|
// bounded by the timeout).
|
||||||
|
if conn.close_reason().is_none() {
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), conn.closed())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(match reject_from_close(&conn) {
|
||||||
Some(r) => PunktfunkError::Rejected(r),
|
Some(r) => PunktfunkError::Rejected(r),
|
||||||
None => e,
|
None => e,
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ pub(crate) struct WorkerArgs {
|
|||||||
pub(crate) video_codecs: u8,
|
pub(crate) video_codecs: u8,
|
||||||
pub(crate) preferred_codec: u8,
|
pub(crate) preferred_codec: u8,
|
||||||
pub(crate) display_hdr: Option<HdrMeta>,
|
pub(crate) display_hdr: Option<HdrMeta>,
|
||||||
pub(crate) client_caps: u8,
|
|
||||||
pub(crate) launch: Option<String>,
|
pub(crate) launch: Option<String>,
|
||||||
pub(crate) pin: Option<[u8; 32]>,
|
pub(crate) pin: Option<[u8; 32]>,
|
||||||
pub(crate) identity: Option<(String, String)>,
|
pub(crate) identity: Option<(String, String)>,
|
||||||
@@ -39,8 +38,6 @@ pub(crate) struct WorkerArgs {
|
|||||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
|
||||||
pub(crate) cursor_state_tx: SyncSender<crate::quic::CursorState>,
|
|
||||||
pub(crate) input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
pub(crate) input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
pub(crate) mic_rx: tokio::sync::mpsc::Receiver<(u32, u64, Vec<u8>)>,
|
pub(crate) mic_rx: tokio::sync::mpsc::Receiver<(u32, u64, Vec<u8>)>,
|
||||||
pub(crate) rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
pub(crate) rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub enum PunktfunkStatus {
|
|||||||
RejectedSuperseded = -26,
|
RejectedSuperseded = -26,
|
||||||
RejectedWireVersion = -27,
|
RejectedWireVersion = -27,
|
||||||
RejectedBusy = -28,
|
RejectedBusy = -28,
|
||||||
|
RejectedSetupFailed = -29,
|
||||||
Panic = -99,
|
Panic = -99,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ impl PunktfunkError {
|
|||||||
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
||||||
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
||||||
R::Busy => PunktfunkStatus::RejectedBusy,
|
R::Busy => PunktfunkStatus::RejectedBusy,
|
||||||
|
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,24 +71,6 @@ pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
|||||||
/// trailing `host_caps` byte — no wire-layout change.
|
/// trailing `host_caps` byte — no wire-layout change.
|
||||||
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||||
|
|
||||||
/// [`Hello::client_caps`] bit: the client renders the host cursor LOCALLY
|
|
||||||
/// (design/remote-desktop-sweep.md M2). It consumes [`CursorShape`](super::control::CursorShape)
|
|
||||||
/// control messages (RGBA bitmap + hotspot, cached by serial) and per-frame
|
|
||||||
/// [`CursorState`](super::datagram::CursorState) `0xD0` datagrams (position/visibility), and
|
|
||||||
/// draws the pointer itself — so the host must STOP compositing the cursor into the video
|
|
||||||
/// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
|
|
||||||
/// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
|
|
||||||
/// an older or incapable host nothing changes.
|
|
||||||
pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
|
||||||
|
|
||||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
|
||||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
|
||||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
|
||||||
/// frame). Set only when the client asked via [`CLIENT_CAP_CURSOR`]; when both bits agree the
|
|
||||||
/// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
|
|
||||||
/// [`CursorState`](super::datagram::CursorState) instead.
|
|
||||||
pub const HOST_CAP_CURSOR: u8 = 0x04;
|
|
||||||
|
|
||||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
/// advertise this.
|
/// advertise this.
|
||||||
|
|||||||
@@ -783,83 +783,6 @@ impl ClipFetchHdr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cursor channel (design/remote-desktop-sweep.md M2) --------------------------------------
|
|
||||||
// The host cursor, forwarded out-of-band so the CLIENT draws it as a real OS cursor (the
|
|
||||||
// Parsec/RDP model) instead of paying the video round-trip. Shape (rare, needs reliability)
|
|
||||||
// rides here on the control stream; per-frame position/visibility rides the lossy `0xD0`
|
|
||||||
// datagram plane ([`super::datagram::CursorState`]). Active only when the client's
|
|
||||||
// [`CLIENT_CAP_CURSOR`](super::caps::CLIENT_CAP_CURSOR) met the host's
|
|
||||||
// [`HOST_CAP_CURSOR`](super::caps::HOST_CAP_CURSOR) — the host stops compositing then.
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
|
|
||||||
pub const MSG_CURSOR_SHAPE: u8 = 0x50;
|
|
||||||
|
|
||||||
/// Per-side pixel cap for a forwarded cursor bitmap. The control-stream frame is length-prefixed
|
|
||||||
/// with a `u16`, so a whole message must fit 65535 bytes — 128×128 RGBA (65536 B) already
|
|
||||||
/// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
|
|
||||||
/// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
|
|
||||||
/// larger before forwarding, so the cap is invisible to clients.
|
|
||||||
pub const CURSOR_SHAPE_MAX_SIDE: u16 = 120;
|
|
||||||
|
|
||||||
/// `host → client` ([`MSG_CURSOR_SHAPE`]): one cursor shape, sent when the pointer's bitmap
|
|
||||||
/// changes (never per-frame — [`super::datagram::CursorState`] carries the motion). The client
|
|
||||||
/// caches shapes by `serial` and re-installs a cached one without any bitmap crossing again
|
|
||||||
/// (the RDP pointer-cache idea for free: re-showing a known serial is a 14-byte
|
|
||||||
/// [`super::datagram::CursorState`], not a resend).
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CursorShape {
|
|
||||||
/// Bitmap identity — bumped by the host's capture layer only on shape change; position
|
|
||||||
/// moves keep the serial stable. [`super::datagram::CursorState::serial`] references it.
|
|
||||||
pub serial: u32,
|
|
||||||
/// Bitmap dimensions in pixels, `1..=`[`CURSOR_SHAPE_MAX_SIDE`] each.
|
|
||||||
pub w: u16,
|
|
||||||
pub h: u16,
|
|
||||||
/// Hotspot (the pixel that IS the pointer position), within `w`×`h`.
|
|
||||||
pub hot_x: u16,
|
|
||||||
pub hot_y: u16,
|
|
||||||
/// Straight-alpha RGBA8, exactly `w * h * 4` bytes, no padding.
|
|
||||||
pub rgba: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorShape {
|
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
|
||||||
// magic[0..4] type[4] serial[5..9] w[9..11] h[11..13] hot_x[13..15] hot_y[15..17] rgba…
|
|
||||||
let mut b = Vec::with_capacity(17 + self.rgba.len());
|
|
||||||
b.extend_from_slice(CTL_MAGIC);
|
|
||||||
b.push(MSG_CURSOR_SHAPE);
|
|
||||||
b.extend_from_slice(&self.serial.to_le_bytes());
|
|
||||||
b.extend_from_slice(&self.w.to_le_bytes());
|
|
||||||
b.extend_from_slice(&self.h.to_le_bytes());
|
|
||||||
b.extend_from_slice(&self.hot_x.to_le_bytes());
|
|
||||||
b.extend_from_slice(&self.hot_y.to_le_bytes());
|
|
||||||
b.extend_from_slice(&self.rgba);
|
|
||||||
b
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode(b: &[u8]) -> Result<CursorShape> {
|
|
||||||
if b.len() < 17 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CURSOR_SHAPE {
|
|
||||||
return Err(PunktfunkError::InvalidArg("bad CursorShape"));
|
|
||||||
}
|
|
||||||
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
|
||||||
let (w, h) = (u16at(9), u16at(11));
|
|
||||||
if w == 0 || h == 0 || w > CURSOR_SHAPE_MAX_SIDE || h > CURSOR_SHAPE_MAX_SIDE {
|
|
||||||
return Err(PunktfunkError::InvalidArg("bad CursorShape dims"));
|
|
||||||
}
|
|
||||||
if b.len() != 17 + (w as usize) * (h as usize) * 4 {
|
|
||||||
return Err(PunktfunkError::InvalidArg("bad CursorShape len"));
|
|
||||||
}
|
|
||||||
Ok(CursorShape {
|
|
||||||
serial: u32::from_le_bytes(b[5..9].try_into().unwrap()),
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
hot_x: u16at(13),
|
|
||||||
hot_y: u16at(15),
|
|
||||||
rgba: b[17..].to_vec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::config::Mode;
|
use crate::config::Mode;
|
||||||
@@ -1224,42 +1147,4 @@ mod tests {
|
|||||||
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn cursor_shape_roundtrip() {
|
|
||||||
let s = CursorShape {
|
|
||||||
serial: 7,
|
|
||||||
w: 2,
|
|
||||||
h: 3,
|
|
||||||
hot_x: 1,
|
|
||||||
hot_y: 2,
|
|
||||||
rgba: (0..2 * 3 * 4).map(|i| i as u8).collect(),
|
|
||||||
};
|
|
||||||
assert_eq!(CursorShape::decode(&s.encode()).unwrap(), s);
|
|
||||||
// Max-side shape still fits the u16 control frame with headroom.
|
|
||||||
let side = CURSOR_SHAPE_MAX_SIDE;
|
|
||||||
let big = CursorShape {
|
|
||||||
serial: u32::MAX,
|
|
||||||
w: side,
|
|
||||||
h: side,
|
|
||||||
hot_x: side - 1,
|
|
||||||
hot_y: 0,
|
|
||||||
rgba: vec![0xAB; side as usize * side as usize * 4],
|
|
||||||
};
|
|
||||||
let bytes = big.encode();
|
|
||||||
assert!(bytes.len() <= u16::MAX as usize, "must fit a control frame");
|
|
||||||
assert_eq!(CursorShape::decode(&bytes).unwrap(), big);
|
|
||||||
// Rejections: zero / oversize dims, and a length that disagrees with them.
|
|
||||||
let mut zero = s.encode();
|
|
||||||
zero[9] = 0;
|
|
||||||
zero[10] = 0;
|
|
||||||
assert!(CursorShape::decode(&zero).is_err());
|
|
||||||
let mut oversize = s.encode();
|
|
||||||
oversize[9..11].copy_from_slice(&(CURSOR_SHAPE_MAX_SIDE + 1).to_le_bytes());
|
|
||||||
assert!(CursorShape::decode(&oversize).is_err());
|
|
||||||
let mut short = s.encode();
|
|
||||||
short.pop();
|
|
||||||
assert!(CursorShape::decode(&short).is_err());
|
|
||||||
// Distinct from the neighboring vocabulary.
|
|
||||||
assert!(ClipState::decode(&s.encode()).is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,75 +606,6 @@ pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cursor-state datagram tag, host → client (design/remote-desktop-sweep.md M2). Next tag after
|
|
||||||
/// [`HOST_TIMING_MAGIC`]. Sent once per captured frame while the cursor channel is negotiated
|
|
||||||
/// ([`CLIENT_CAP_CURSOR`](super::caps::CLIENT_CAP_CURSOR) ∧
|
|
||||||
/// [`HOST_CAP_CURSOR`](super::caps::HOST_CAP_CURSOR)) — per-frame resend makes the plane
|
|
||||||
/// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
|
|
||||||
/// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
|
|
||||||
/// datagram only moves/hides the pointer.
|
|
||||||
pub const CURSOR_STATE_MAGIC: u8 = 0xD0;
|
|
||||||
|
|
||||||
/// [`CursorState::flags`] bit: the host cursor is visible.
|
|
||||||
pub const CURSOR_VISIBLE: u8 = 0x01;
|
|
||||||
/// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
|
|
||||||
/// relative/captured (M3 auto-flip; advisory, user override always wins).
|
|
||||||
pub const CURSOR_RELATIVE_HINT: u8 = 0x02;
|
|
||||||
|
|
||||||
/// Per-frame host-cursor state (position, visibility, mode hint). `x`/`y` are the pointer
|
|
||||||
/// position (hotspot point, not bitmap top-left) in the host OUTPUT's pixel space — the same
|
|
||||||
/// space the video mode describes, so the client maps through its letterbox exactly like it
|
|
||||||
/// maps touches, in reverse.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CursorState {
|
|
||||||
/// The [`CursorShape`](super::control::CursorShape) serial this state refers to. A client
|
|
||||||
/// that has no cached shape for it keeps its previous cursor until the (reliable) shape
|
|
||||||
/// message lands — at worst one control-stream RTT of stale shape, never a wrong position.
|
|
||||||
pub serial: u32,
|
|
||||||
/// Bitfield of [`CURSOR_VISIBLE`] / [`CURSOR_RELATIVE_HINT`].
|
|
||||||
pub flags: u8,
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorState {
|
|
||||||
pub fn visible(&self) -> bool {
|
|
||||||
self.flags & CURSOR_VISIBLE != 0
|
|
||||||
}
|
|
||||||
pub fn relative_hint(&self) -> bool {
|
|
||||||
self.flags & CURSOR_RELATIVE_HINT != 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire length of a [`CURSOR_STATE_MAGIC`] datagram: tag + u32 serial + flags + 2 × i32 = 14.
|
|
||||||
const CURSOR_STATE_LEN: usize = 1 + 4 + 1 + 8;
|
|
||||||
|
|
||||||
/// Encode a [`CursorState`] into a [`CURSOR_STATE_MAGIC`] datagram.
|
|
||||||
pub fn encode_cursor_state_datagram(s: &CursorState) -> Vec<u8> {
|
|
||||||
let mut b = Vec::with_capacity(CURSOR_STATE_LEN);
|
|
||||||
b.push(CURSOR_STATE_MAGIC);
|
|
||||||
b.extend_from_slice(&s.serial.to_le_bytes());
|
|
||||||
b.push(s.flags);
|
|
||||||
b.extend_from_slice(&s.x.to_le_bytes());
|
|
||||||
b.extend_from_slice(&s.y.to_le_bytes());
|
|
||||||
b
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a [`CURSOR_STATE_MAGIC`] datagram → [`CursorState`]. `None` on bad tag or a short
|
|
||||||
/// buffer (the fixed length bounds every read before it happens; a longer buffer is tolerated
|
|
||||||
/// for append-extension, like 0xCF).
|
|
||||||
pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
|
||||||
if b.len() < CURSOR_STATE_LEN || b[0] != CURSOR_STATE_MAGIC {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(CursorState {
|
|
||||||
serial: u32::from_le_bytes(b[1..5].try_into().unwrap()),
|
|
||||||
flags: b[5],
|
|
||||||
x: i32::from_le_bytes(b[6..10].try_into().unwrap()),
|
|
||||||
y: i32::from_le_bytes(b[10..14].try_into().unwrap()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::quic::*;
|
use crate::quic::*;
|
||||||
@@ -991,32 +922,4 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn cursor_state_roundtrip() {
|
|
||||||
for (flags, x, y) in [
|
|
||||||
(CURSOR_VISIBLE, 0i32, 0i32),
|
|
||||||
(CURSOR_VISIBLE | CURSOR_RELATIVE_HINT, -5, 2160),
|
|
||||||
(0, i32::MIN, i32::MAX),
|
|
||||||
] {
|
|
||||||
let s = CursorState {
|
|
||||||
serial: 42,
|
|
||||||
flags,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
};
|
|
||||||
let d = encode_cursor_state_datagram(&s);
|
|
||||||
assert_eq!(decode_cursor_state_datagram(&d), Some(s));
|
|
||||||
assert_eq!(s.visible(), flags & CURSOR_VISIBLE != 0);
|
|
||||||
assert_eq!(s.relative_hint(), flags & CURSOR_RELATIVE_HINT != 0);
|
|
||||||
// Append-extensible like 0xCF: a longer buffer still parses the known prefix.
|
|
||||||
let mut ext = d.clone();
|
|
||||||
ext.push(0xFF);
|
|
||||||
assert_eq!(decode_cursor_state_datagram(&ext), Some(s));
|
|
||||||
// Short / wrong tag are rejected before any read.
|
|
||||||
assert_eq!(decode_cursor_state_datagram(&d[..d.len() - 1]), None);
|
|
||||||
let mut bad = d.clone();
|
|
||||||
bad[0] = HOST_TIMING_MAGIC;
|
|
||||||
assert_eq!(decode_cursor_state_datagram(&bad), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,15 +83,6 @@ pub struct Hello {
|
|||||||
/// forcing the earlier placeholders. Omitted by older clients / when the client has no HDR
|
/// forcing the earlier placeholders. Omitted by older clients / when the client has no HDR
|
||||||
/// display (decodes to `None` — the host keeps its built-in EDID defaults).
|
/// display (decodes to `None` — the host keeps its built-in EDID defaults).
|
||||||
pub display_hdr: Option<HdrMeta>,
|
pub display_hdr: Option<HdrMeta>,
|
||||||
/// Non-video client capabilities — a bitfield of [`CLIENT_CAP_CURSOR`] (the client renders
|
|
||||||
/// the host cursor locally; the host stops compositing it and forwards shape + state
|
|
||||||
/// instead). Appended as a single byte AFTER `display_hdr`; because that block is a fixed
|
|
||||||
/// [`super::datagram::HDR_META_BODY_LEN`]-byte optional with no placeholder form, presence is
|
|
||||||
/// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after
|
|
||||||
/// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This
|
|
||||||
/// caps everything after `display_hdr` at `HDR_META_BODY_LEN − 1` bytes total — document any
|
|
||||||
/// future field here and mind the budget. Omitted when zero and by older clients (→ `0`).
|
|
||||||
pub client_caps: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||||
@@ -253,13 +244,8 @@ impl Hello {
|
|||||||
let vcodecs_present = self.video_codecs != 0;
|
let vcodecs_present = self.video_codecs != 0;
|
||||||
let pref_present = self.preferred_codec != 0;
|
let pref_present = self.preferred_codec != 0;
|
||||||
let hdr_present = self.display_hdr.is_some();
|
let hdr_present = self.display_hdr.is_some();
|
||||||
let ccaps_present = self.client_caps != 0;
|
let need_placeholders =
|
||||||
let need_placeholders = self.video_caps != 0
|
self.video_caps != 0 || ac_present || vcodecs_present || pref_present || hdr_present;
|
||||||
|| ac_present
|
|
||||||
|| vcodecs_present
|
|
||||||
|| pref_present
|
|
||||||
|| hdr_present
|
|
||||||
|| ccaps_present;
|
|
||||||
match (&self.name, &self.launch) {
|
match (&self.name, &self.launch) {
|
||||||
(None, None) if !need_placeholders => {}
|
(None, None) if !need_placeholders => {}
|
||||||
(name, _) => {
|
(name, _) => {
|
||||||
@@ -280,27 +266,21 @@ impl Hello {
|
|||||||
b.push(self.video_caps);
|
b.push(self.video_caps);
|
||||||
}
|
}
|
||||||
// audio_channels: emitted when non-stereo OR a later field follows.
|
// audio_channels: emitted when non-stereo OR a later field follows.
|
||||||
if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present {
|
if ac_present || vcodecs_present || pref_present || hdr_present {
|
||||||
b.push(self.audio_channels);
|
b.push(self.audio_channels);
|
||||||
}
|
}
|
||||||
// video_codecs: emitted when non-zero OR a later field follows.
|
// video_codecs: emitted when non-zero OR a later field follows.
|
||||||
if vcodecs_present || pref_present || hdr_present || ccaps_present {
|
if vcodecs_present || pref_present || hdr_present {
|
||||||
b.push(self.video_codecs);
|
b.push(self.video_codecs);
|
||||||
}
|
}
|
||||||
// preferred_codec: emitted when non-zero OR a later field follows.
|
// preferred_codec: emitted when non-zero OR display_hdr follows.
|
||||||
if pref_present || hdr_present || ccaps_present {
|
if pref_present || hdr_present {
|
||||||
b.push(self.preferred_codec);
|
b.push(self.preferred_codec);
|
||||||
}
|
}
|
||||||
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if
|
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body. Last field; omitted when `None`.
|
||||||
// later fields follow (no placeholder form — the decoder disambiguates by remaining
|
|
||||||
// length, which caps the post-HDR tail at HDR_META_BODY_LEN − 1 bytes).
|
|
||||||
if let Some(m) = &self.display_hdr {
|
if let Some(m) = &self.display_hdr {
|
||||||
super::datagram::write_hdr_meta_body(m, &mut b);
|
super::datagram::write_hdr_meta_body(m, &mut b);
|
||||||
}
|
}
|
||||||
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero.
|
|
||||||
if ccaps_present {
|
|
||||||
b.push(self.client_caps);
|
|
||||||
}
|
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,26 +346,9 @@ impl Hello {
|
|||||||
preferred_codec: b.get(tail + 3).copied().unwrap_or(0),
|
preferred_codec: b.get(tail + 3).copied().unwrap_or(0),
|
||||||
// Optional trailing HdrMeta body (fixed length) — absent on an older client / a
|
// Optional trailing HdrMeta body (fixed length) — absent on an older client / a
|
||||||
// client without an HDR display → `None` (the host keeps its EDID defaults).
|
// client without an HDR display → `None` (the host keeps its EDID defaults).
|
||||||
// Presence is decided by REMAINING LENGTH (there is no placeholder form for the
|
display_hdr: b
|
||||||
// fixed block): ≥ HDR_META_BODY_LEN bytes after `preferred_codec` ⇒ the block is
|
.get(tail + 4..tail + 4 + super::datagram::HDR_META_BODY_LEN)
|
||||||
// there and post-HDR fields follow it; fewer ⇒ no block, the bytes ARE the post-HDR
|
.map(super::datagram::read_hdr_meta_body),
|
||||||
// fields. Sound as long as the post-HDR tail stays under HDR_META_BODY_LEN bytes.
|
|
||||||
display_hdr: (b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN)
|
|
||||||
.then(|| {
|
|
||||||
b.get(tail + 4..tail + 4 + super::datagram::HDR_META_BODY_LEN)
|
|
||||||
.map(super::datagram::read_hdr_meta_body)
|
|
||||||
})
|
|
||||||
.flatten(),
|
|
||||||
// client_caps: the byte after the HDR block when present, else directly at tail+4.
|
|
||||||
client_caps: {
|
|
||||||
let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN
|
|
||||||
{
|
|
||||||
tail + 4 + super::datagram::HDR_META_BODY_LEN
|
|
||||||
} else {
|
|
||||||
tail + 4
|
|
||||||
};
|
|
||||||
b.get(off).copied().unwrap_or(0)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,7 +829,6 @@ mod tests {
|
|||||||
video_codecs: CODEC_H264 | CODEC_HEVC,
|
video_codecs: CODEC_H264 | CODEC_HEVC,
|
||||||
preferred_codec: CODEC_H264,
|
preferred_codec: CODEC_H264,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
let enc = h.encode();
|
let enc = h.encode();
|
||||||
let dec = Hello::decode(&enc).unwrap();
|
let dec = Hello::decode(&enc).unwrap();
|
||||||
@@ -943,7 +905,6 @@ mod tests {
|
|||||||
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
|
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
|
||||||
preferred_codec: CODEC_HEVC,
|
preferred_codec: CODEC_HEVC,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
let s = Start {
|
let s = Start {
|
||||||
@@ -974,7 +935,6 @@ mod tests {
|
|||||||
video_codecs: 0,
|
video_codecs: 0,
|
||||||
preferred_codec: 0,
|
preferred_codec: 0,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
let enc = h.encode();
|
let enc = h.encode();
|
||||||
assert_eq!(enc.len(), 26);
|
assert_eq!(enc.len(), 26);
|
||||||
@@ -1092,7 +1052,6 @@ mod tests {
|
|||||||
video_codecs: 0,
|
video_codecs: 0,
|
||||||
preferred_codec: 0,
|
preferred_codec: 0,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
let enc = base.encode();
|
let enc = base.encode();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1144,7 +1103,6 @@ mod tests {
|
|||||||
video_codecs: 0,
|
video_codecs: 0,
|
||||||
preferred_codec: 0,
|
preferred_codec: 0,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||||
let with_launch = Hello {
|
let with_launch = Hello {
|
||||||
@@ -1204,7 +1162,6 @@ mod tests {
|
|||||||
video_codecs: 0,
|
video_codecs: 0,
|
||||||
preferred_codec: 0,
|
preferred_codec: 0,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
};
|
};
|
||||||
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
|
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
|
||||||
let vol = HdrMeta {
|
let vol = HdrMeta {
|
||||||
@@ -1272,7 +1229,6 @@ mod tests {
|
|||||||
video_codecs: 0,
|
video_codecs: 0,
|
||||||
preferred_codec: 0,
|
preferred_codec: 0,
|
||||||
display_hdr: None,
|
display_hdr: None,
|
||||||
client_caps: 0,
|
|
||||||
}
|
}
|
||||||
.encode();
|
.encode();
|
||||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
@@ -1286,66 +1242,4 @@ mod tests {
|
|||||||
.encode();
|
.encode();
|
||||||
assert!(Hello::decode(&pr).is_err());
|
assert!(Hello::decode(&pr).is_err());
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn hello_client_caps_roundtrip_and_back_compat() {
|
|
||||||
let base = Hello {
|
|
||||||
abi_version: 2,
|
|
||||||
mode: Mode {
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
refresh_hz: 60,
|
|
||||||
},
|
|
||||||
compositor: CompositorPref::Auto,
|
|
||||||
gamepad: GamepadPref::Auto,
|
|
||||||
bitrate_kbps: 0,
|
|
||||||
name: None,
|
|
||||||
launch: None,
|
|
||||||
video_caps: 0,
|
|
||||||
audio_channels: 2,
|
|
||||||
video_codecs: 0,
|
|
||||||
preferred_codec: 0,
|
|
||||||
display_hdr: None,
|
|
||||||
client_caps: 0,
|
|
||||||
};
|
|
||||||
let vol = HdrMeta {
|
|
||||||
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
|
|
||||||
white_point: [15635, 16450],
|
|
||||||
max_display_mastering_luminance: 8_000_000,
|
|
||||||
min_display_mastering_luminance: 500,
|
|
||||||
max_cll: 0,
|
|
||||||
max_fall: 400,
|
|
||||||
};
|
|
||||||
// caps WITHOUT an HDR block: the single byte after preferred_codec (remaining < the
|
|
||||||
// fixed block length, so the decoder must NOT read it as a truncated HdrMeta).
|
|
||||||
let caps_only = Hello {
|
|
||||||
client_caps: CLIENT_CAP_CURSOR,
|
|
||||||
..base.clone()
|
|
||||||
};
|
|
||||||
assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only);
|
|
||||||
// caps AND the HDR block: caps lands after the fixed block.
|
|
||||||
let both = Hello {
|
|
||||||
display_hdr: Some(vol),
|
|
||||||
client_caps: CLIENT_CAP_CURSOR,
|
|
||||||
..base.clone()
|
|
||||||
};
|
|
||||||
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
|
||||||
// HDR without caps stays byte-identical to the pre-caps wire form and decodes caps 0.
|
|
||||||
let hdr_only = Hello {
|
|
||||||
display_hdr: Some(vol),
|
|
||||||
..base.clone()
|
|
||||||
};
|
|
||||||
assert_eq!(Hello::decode(&hdr_only.encode()).unwrap(), hdr_only);
|
|
||||||
// An older client (no trailing byte at all) decodes to 0.
|
|
||||||
assert_eq!(Hello::decode(&base.encode()).unwrap().client_caps, 0);
|
|
||||||
// An older HOST reading a caps-bearing Hello: its decode simply never looks past the
|
|
||||||
// fields it knows — nothing before the caps byte moved.
|
|
||||||
let enc = both.encode();
|
|
||||||
assert_eq!(
|
|
||||||
Hello::decode(&enc[..enc.len() - 1]).unwrap(),
|
|
||||||
Hello {
|
|
||||||
client_caps: 0,
|
|
||||||
..both.clone()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65;
|
|||||||
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
||||||
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||||
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
||||||
|
/// The host admitted the connection but could not stand the stream session up (compositor /
|
||||||
|
/// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||||
|
/// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||||
|
/// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||||
|
/// finished mid-frame") — indistinguishable from transport trouble.
|
||||||
|
pub const SETUP_FAILED_CLOSE_CODE: u32 = 0x68;
|
||||||
|
|
||||||
/// Why a host turned a connection away, decoded from the QUIC application close code — the
|
/// Why a host turned a connection away, decoded from the QUIC application close code — the
|
||||||
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
||||||
@@ -59,6 +65,9 @@ pub enum RejectReason {
|
|||||||
WireVersionMismatch,
|
WireVersionMismatch,
|
||||||
/// The host refused admission because a conflicting session is live.
|
/// The host refused admission because a conflicting session is live.
|
||||||
Busy,
|
Busy,
|
||||||
|
/// The host admitted the connection but failed to start the stream session (host-side
|
||||||
|
/// setup error — the host log has the specific cause).
|
||||||
|
SetupFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RejectReason {
|
impl RejectReason {
|
||||||
@@ -75,6 +84,7 @@ impl RejectReason {
|
|||||||
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
||||||
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
||||||
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
||||||
|
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,6 +101,7 @@ impl RejectReason {
|
|||||||
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
||||||
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
||||||
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
||||||
|
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +118,7 @@ impl RejectReason {
|
|||||||
Self::Superseded => "superseded",
|
Self::Superseded => "superseded",
|
||||||
Self::WireVersionMismatch => "wire-version",
|
Self::WireVersionMismatch => "wire-version",
|
||||||
Self::Busy => "busy",
|
Self::Busy => "busy",
|
||||||
|
Self::SetupFailed => "setup-failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +137,7 @@ impl std::fmt::Display for RejectReason {
|
|||||||
Self::Superseded => "a newer request from this device replaced this one",
|
Self::Superseded => "a newer request from this device replaced this one",
|
||||||
Self::WireVersionMismatch => "client and host versions do not match",
|
Self::WireVersionMismatch => "client and host versions do not match",
|
||||||
Self::Busy => "the host is busy with another session",
|
Self::Busy => "the host is busy with another session",
|
||||||
|
Self::SetupFailed => "the host could not start the stream session",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +146,7 @@ impl std::fmt::Display for RejectReason {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const ALL: [RejectReason; 9] = [
|
const ALL: [RejectReason; 10] = [
|
||||||
RejectReason::PairingNotArmed,
|
RejectReason::PairingNotArmed,
|
||||||
RejectReason::PairingBoundToOtherDevice,
|
RejectReason::PairingBoundToOtherDevice,
|
||||||
RejectReason::PairingRateLimited,
|
RejectReason::PairingRateLimited,
|
||||||
@@ -143,6 +156,7 @@ mod tests {
|
|||||||
RejectReason::Superseded,
|
RejectReason::Superseded,
|
||||||
RejectReason::WireVersionMismatch,
|
RejectReason::WireVersionMismatch,
|
||||||
RejectReason::Busy,
|
RejectReason::Busy,
|
||||||
|
RejectReason::SetupFailed,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -164,7 +178,7 @@ mod tests {
|
|||||||
fn foreign_codes_stay_untyped() {
|
fn foreign_codes_stay_untyped() {
|
||||||
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
|
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
|
||||||
// never read as a host rejection.
|
// never read as a host rejection.
|
||||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x68, u32::MAX] {
|
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x69, u32::MAX] {
|
||||||
assert_eq!(RejectReason::from_close_code(code), None);
|
assert_eq!(RejectReason::from_close_code(code), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,36 @@ pub fn input_test() -> Result<()> {
|
|||||||
y,
|
y,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
};
|
};
|
||||||
|
// `PUNKTFUNK_INPUT_TEST_ABS=WxH` (e.g. 1280x800): exercise ABSOLUTE pointer moves instead —
|
||||||
|
// steps through the corners + center of the given surface, 1s apart, so an observer
|
||||||
|
// (`DISPLAY=:0 xdotool getmouselocation`) can verify each jump. This is the degraded-touch
|
||||||
|
// path (touch → MouseMoveAbs), so it validates game-mode touch without a client.
|
||||||
|
if let Ok(dims) = std::env::var("PUNKTFUNK_INPUT_TEST_ABS") {
|
||||||
|
let (w, h) = dims
|
||||||
|
.split_once('x')
|
||||||
|
.and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)))
|
||||||
|
.unwrap_or((1280, 800));
|
||||||
|
let flags = (w << 16) | (h & 0xffff);
|
||||||
|
let pts = [
|
||||||
|
(100, 100),
|
||||||
|
(w as i32 - 100, 100),
|
||||||
|
(w as i32 - 100, h as i32 - 100),
|
||||||
|
(100, h as i32 - 100),
|
||||||
|
(w as i32 / 2, h as i32 / 2),
|
||||||
|
];
|
||||||
|
tracing::info!(w, h, "input-test: ABS mode — corners + center, 1s apart");
|
||||||
|
for (x, y) in pts {
|
||||||
|
let mut e = ev(InputKind::MouseMoveAbs, 0, x, y);
|
||||||
|
e.flags = flags;
|
||||||
|
if let Err(err) = inj.inject(&e) {
|
||||||
|
tracing::warn!(error = %format!("{err:#}"), "input-test: abs inject failed");
|
||||||
|
}
|
||||||
|
tracing::info!(x, y, "input-test: abs move emitted");
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
tracing::info!("input-test: done (abs)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
|
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ pub fn start(
|
|||||||
// Same scheduling posture as the native path's capture/encode thread (Linux nice -10 /
|
// Same scheduling posture as the native path's capture/encode thread (Linux nice -10 /
|
||||||
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
|
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
|
||||||
crate::native::boost_thread_priority(true);
|
crate::native::boost_thread_priority(true);
|
||||||
|
// A GameStream viewer may be video-only too — hold the suspend/idle inhibitor for
|
||||||
|
// this stream's lifetime (plane parity with the native LiveSessionGuard).
|
||||||
|
let _sleep = crate::sleep_inhibit::hold();
|
||||||
tracing::info!(?cfg, "video stream starting");
|
tracing::info!(?cfg, "video stream starting");
|
||||||
// Lifecycle events + the script-facing marker file, plane parity with the native loop
|
// Lifecycle events + the script-facing marker file, plane parity with the native loop
|
||||||
// (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for
|
// (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ mod send_pacing;
|
|||||||
mod service;
|
mod service;
|
||||||
mod session_plan;
|
mod session_plan;
|
||||||
mod session_status;
|
mod session_status;
|
||||||
|
mod sleep_inhibit;
|
||||||
mod spike;
|
mod spike;
|
||||||
mod stats_recorder;
|
mod stats_recorder;
|
||||||
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
|
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ mod handshake;
|
|||||||
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
|
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
|
||||||
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
|
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
|
||||||
mod control;
|
mod control;
|
||||||
/// Cursor-forward channel (M2): the encode loop's shape/state emission.
|
|
||||||
mod cursor_fwd;
|
|
||||||
|
|
||||||
/// The capture→encode→send data plane (plan §W1); `serve_session` dispatches the synthetic or
|
/// The capture→encode→send data plane (plan §W1); `serve_session` dispatches the synthetic or
|
||||||
/// virtual source here (`synthetic_stream` / `virtual_stream`) and hands the latter a
|
/// virtual source here (`synthetic_stream` / `virtual_stream`) and hands the latter a
|
||||||
@@ -425,6 +423,10 @@ pub(crate) async fn serve(
|
|||||||
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
||||||
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
||||||
let sem_session = sem.clone();
|
let sem_session = sem.clone();
|
||||||
|
// Kept for the error path below: `serve_session` consumes `conn`, but a setup failure
|
||||||
|
// must still close the connection with a typed reason (quinn connections are cheap
|
||||||
|
// Arc-handle clones).
|
||||||
|
let conn_err = conn.clone();
|
||||||
sessions.spawn(async move {
|
sessions.spawn(async move {
|
||||||
match serve_session(
|
match serve_session(
|
||||||
conn,
|
conn,
|
||||||
@@ -447,7 +449,23 @@ pub(crate) async fn serve(
|
|||||||
"closed before the control handshake (reachability probe)"
|
"closed before the control handshake (reachability probe)"
|
||||||
),
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
|
// Make the failure legible to the client (the [`close_rejected`] discipline,
|
||||||
|
// extended to EVERY session error): a setup failure that just drops the
|
||||||
|
// connection reaches the client as a bare close mid-control-frame ("control
|
||||||
|
// stream finished mid-frame") — indistinguishable from transport trouble.
|
||||||
|
// Close with the typed setup-failed code, carrying the error text in the
|
||||||
|
// reason bytes for client-side logs. When a gate already closed with its own
|
||||||
|
// typed code, or the peer closed first, this close is a no-op (first wins).
|
||||||
|
let detail = format!("{e:#}");
|
||||||
|
let mut cut = detail.len().min(256);
|
||||||
|
while !detail.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
conn_err.close(
|
||||||
|
punktfunk_core::reject::SETUP_FAILED_CLOSE_CODE.into(),
|
||||||
|
&detail.as_bytes()[..cut],
|
||||||
|
);
|
||||||
|
tracing::warn!(%peer, error = %detail, "session ended with error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -945,15 +963,6 @@ async fn serve_session(
|
|||||||
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
|
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
|
||||||
let (reconfig_result_tx, reconfig_result_rx) =
|
let (reconfig_result_tx, reconfig_result_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
|
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
|
||||||
// Cursor-forward bridge (M2): the encode loop diffs each frame's cursor serial and hands
|
|
||||||
// changed SHAPES here; the control task (the control stream's sole writer) sends them.
|
|
||||||
// Same shape as `probe_result_tx`. Wired even when the channel wasn't negotiated — it
|
|
||||||
// just never fires then.
|
|
||||||
let (cursor_shape_tx, cursor_shape_rx) =
|
|
||||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
|
||||||
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
|
|
||||||
// (handshake::cursor_forward is the single predicate both read).
|
|
||||||
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
|
|
||||||
// Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes
|
// Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes
|
||||||
// it here; the data-plane send loop reads + applies it per frame. Disabled (pinned) when
|
// it here; the data-plane send loop reads + applies it per frame. Disabled (pinned) when
|
||||||
// PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report.
|
// PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report.
|
||||||
@@ -989,7 +998,6 @@ async fn serve_session(
|
|||||||
probe_tx,
|
probe_tx,
|
||||||
probe_result_rx,
|
probe_result_rx,
|
||||||
reconfig_result_rx,
|
reconfig_result_rx,
|
||||||
cursor_shape_rx,
|
|
||||||
clip_enabled,
|
clip_enabled,
|
||||||
clip,
|
clip,
|
||||||
));
|
));
|
||||||
@@ -1375,8 +1383,6 @@ async fn serve_session(
|
|||||||
fec_target: fec_target_dp,
|
fec_target: fec_target_dp,
|
||||||
conn: conn_stream,
|
conn: conn_stream,
|
||||||
timing_conn,
|
timing_conn,
|
||||||
cursor_forward,
|
|
||||||
cursor_shape_tx,
|
|
||||||
probe_seq,
|
probe_seq,
|
||||||
streamed_au,
|
streamed_au,
|
||||||
stats: stats_dp,
|
stats: stats_dp,
|
||||||
@@ -2043,7 +2049,6 @@ mod tests {
|
|||||||
0, // video_codecs (HEVC-only)
|
0, // video_codecs (HEVC-only)
|
||||||
0, // preferred_codec
|
0, // preferred_codec
|
||||||
None, // display_hdr
|
None, // display_hdr
|
||||||
0, // client_caps
|
|
||||||
None, // launch
|
None, // launch
|
||||||
None, // pin (TOFU)
|
None, // pin (TOFU)
|
||||||
None, // identity (host doesn't require pairing)
|
None, // identity (host doesn't require pairing)
|
||||||
@@ -2214,7 +2219,6 @@ mod tests {
|
|||||||
0, // video_codecs (0 → HEVC-only)
|
0, // video_codecs (0 → HEVC-only)
|
||||||
0, // preferred_codec (auto)
|
0, // preferred_codec (auto)
|
||||||
None, // display_hdr
|
None, // display_hdr
|
||||||
0, // client_caps
|
|
||||||
None, // launch
|
None, // launch
|
||||||
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
|
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
|
||||||
Some((cert, key)),
|
Some((cert, key)),
|
||||||
@@ -2282,7 +2286,6 @@ mod tests {
|
|||||||
0, // video_codecs
|
0, // video_codecs
|
||||||
0, // preferred_codec
|
0, // preferred_codec
|
||||||
None, // display_hdr
|
None, // display_hdr
|
||||||
0, // client_caps
|
|
||||||
None, // launch
|
None, // launch
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -2312,7 +2315,6 @@ mod tests {
|
|||||||
0, // video_codecs
|
0, // video_codecs
|
||||||
0, // preferred_codec
|
0, // preferred_codec
|
||||||
None, // display_hdr
|
None, // display_hdr
|
||||||
0, // client_caps
|
|
||||||
None, // launch
|
None, // launch
|
||||||
Some(host_fp),
|
Some(host_fp),
|
||||||
Some((cert.clone(), key.clone())),
|
Some((cert.clone(), key.clone())),
|
||||||
|
|||||||
@@ -92,8 +92,24 @@ pub(super) fn resolve_compositor(
|
|||||||
let available = crate::vdisplay::available();
|
let available = crate::vdisplay::available();
|
||||||
let chosen = match pick_compositor(pref, &available, detected) {
|
let chosen = match pick_compositor(pref, &available, detected) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
|
// No live session, but the MANAGED gamescope infra exists (SteamOS's
|
||||||
|
// `gamescope-session`, Bazzite's `gamescope-session-plus`): route to the gamescope
|
||||||
|
// backend anyway — its managed path stands the session up from nothing at the
|
||||||
|
// client's mode (drop-in takeover / session relaunch), so a dead gaming session
|
||||||
|
// self-heals on the next connect instead of bouncing every client until someone
|
||||||
|
// restarts it by hand. (The trap that motivated this: a headless SteamOS box whose
|
||||||
|
// gamescope died — every connect failed "no usable compositor" even though the
|
||||||
|
// takeover could rebuild it.) Not under an operator pin: an explicit
|
||||||
|
// `PUNKTFUNK_COMPOSITOR` keeps its exact, hand-configured meaning.
|
||||||
|
None if !overridden && crate::vdisplay::managed_session_available() => {
|
||||||
|
tracing::info!(
|
||||||
|
"no live graphical session — managed gamescope infra present; routing to \
|
||||||
|
the managed takeover to revive the session"
|
||||||
|
);
|
||||||
|
Compositor::Gamescope
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
// No live session — the state a compositor crash leaves behind (gnome-shell
|
// The state a compositor crash leaves behind (gnome-shell
|
||||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||||
// its next knock lands in the recovered desktop.
|
// its next knock lands in the recovered desktop.
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ pub(super) async fn run(
|
|||||||
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||||
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||||
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
|
||||||
clip_enabled: Arc<AtomicBool>,
|
clip_enabled: Arc<AtomicBool>,
|
||||||
clip: pf_clipboard::ClipCoord,
|
clip: pf_clipboard::ClipCoord,
|
||||||
) {
|
) {
|
||||||
@@ -263,15 +262,6 @@ pub(super) async fn run(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shape = cursor_shape_rx.recv() => {
|
|
||||||
// Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap.
|
|
||||||
// Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by
|
|
||||||
// construction (cursor_fwd downscales).
|
|
||||||
let Some(shape) = shape else { break }; // data plane gone
|
|
||||||
if io::write_msg(&mut ctrl_send, &shape.encode()).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offer = clip_offer_rx.recv(), if !clip_offer_closed => {
|
offer = clip_offer_rx.recv(), if !clip_offer_closed => {
|
||||||
// Host copied → the coordinator minted a `ClipOffer`; forward it to the client
|
// Host copied → the coordinator minted a `ClipOffer`; forward it to the client
|
||||||
// (only while sync is on — a race with a just-received disable would otherwise
|
// (only while sync is on — a race with a just-received disable would otherwise
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
//! Cursor-forward channel, host side (design/remote-desktop-sweep.md M2).
|
|
||||||
//!
|
|
||||||
//! When the session negotiated the cursor channel (client `CLIENT_CAP_CURSOR` met our
|
|
||||||
//! `HOST_CAP_CURSOR`), the encoder stops blending the pointer into the video
|
|
||||||
//! (`SessionPlan::cursor_blend = false`) and the encode loop forwards it out-of-band instead:
|
|
||||||
//! the SHAPE (bitmap + hotspot, rare) rides the reliable control stream via the control-task
|
|
||||||
//! bridge, per-tick STATE (position/visibility, 14 B) rides a lossy `0xD0` datagram — resent
|
|
||||||
//! every iteration so loss self-heals with no refresh timer.
|
|
||||||
|
|
||||||
use punktfunk_core::quic::{
|
|
||||||
encode_cursor_state_datagram, CursorShape, CursorState, CURSOR_SHAPE_MAX_SIDE, CURSOR_VISIBLE,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Per-session forward state, owned by the encode loop (the thread that binds frames).
|
|
||||||
pub(super) struct CursorForwarder {
|
|
||||||
/// Serial of the last shape handed to the control-task bridge (`None` = none yet).
|
|
||||||
sent_serial: Option<u64>,
|
|
||||||
/// Last visible pointer position (hotspot point, frame px) — held across hidden spans so
|
|
||||||
/// a hide still states WHERE the pointer was (the M3 reappear position).
|
|
||||||
last_pos: (i32, i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorForwarder {
|
|
||||||
pub(super) fn new() -> CursorForwarder {
|
|
||||||
CursorForwarder {
|
|
||||||
sent_serial: None,
|
|
||||||
last_pos: (0, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called once per encode-loop iteration with the bound frame's overlay (also on repeat
|
|
||||||
/// iterations — the state datagram is the plane's loss heal, so it goes out every tick).
|
|
||||||
/// `None` overlay = hidden pointer (or no bitmap yet): state only, `visible` clear.
|
|
||||||
pub(super) fn tick(
|
|
||||||
&mut self,
|
|
||||||
cursor: Option<&pf_frame::CursorOverlay>,
|
|
||||||
conn: &quinn::Connection,
|
|
||||||
shape_tx: &tokio::sync::mpsc::UnboundedSender<CursorShape>,
|
|
||||||
) {
|
|
||||||
let flags = match cursor {
|
|
||||||
Some(ov) => {
|
|
||||||
if self.sent_serial != Some(ov.serial) {
|
|
||||||
if let Some(shape) = shape_from_overlay(ov) {
|
|
||||||
// Bridge full ⇒ control task gone ⇒ session is tearing down anyway.
|
|
||||||
let _ = shape_tx.send(shape);
|
|
||||||
self.sent_serial = Some(ov.serial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.last_pos = (ov.x + ov.hot_x as i32, ov.y + ov.hot_y as i32);
|
|
||||||
CURSOR_VISIBLE
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
let state = CursorState {
|
|
||||||
serial: self.sent_serial.unwrap_or(0) as u32,
|
|
||||||
flags,
|
|
||||||
x: self.last_pos.0,
|
|
||||||
y: self.last_pos.1,
|
|
||||||
};
|
|
||||||
let _ = conn.send_datagram(encode_cursor_state_datagram(&state).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the wire shape from a capture overlay, integer-downscaling (nearest-neighbor) anything
|
|
||||||
/// over [`CURSOR_SHAPE_MAX_SIDE`] so the message always fits the u16-length control frame.
|
|
||||||
/// Real cursors are far under the cap — the scale path is a correctness backstop for XL
|
|
||||||
/// accessibility cursors, not a quality path. `None` on a malformed overlay (short buffer).
|
|
||||||
fn shape_from_overlay(ov: &pf_frame::CursorOverlay) -> Option<CursorShape> {
|
|
||||||
let px = (ov.w as usize).checked_mul(ov.h as usize)?.checked_mul(4)?;
|
|
||||||
if ov.w == 0 || ov.h == 0 || ov.rgba.len() < px {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let max = CURSOR_SHAPE_MAX_SIDE as u32;
|
|
||||||
let f = ov.w.max(ov.h).div_ceil(max).max(1);
|
|
||||||
let (w, h) = (ov.w.div_ceil(f), ov.h.div_ceil(f));
|
|
||||||
let rgba = if f == 1 {
|
|
||||||
ov.rgba.as_ref().clone()
|
|
||||||
} else {
|
|
||||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
|
||||||
for y in 0..h {
|
|
||||||
for x in 0..w {
|
|
||||||
let (sx, sy) = ((x * f).min(ov.w - 1), (y * f).min(ov.h - 1));
|
|
||||||
let o = ((sy * ov.w + sx) * 4) as usize;
|
|
||||||
out.extend_from_slice(&ov.rgba[o..o + 4]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
};
|
|
||||||
Some(CursorShape {
|
|
||||||
serial: ov.serial as u32,
|
|
||||||
w: w as u16,
|
|
||||||
h: h as u16,
|
|
||||||
hot_x: (ov.hot_x / f).min(w - 1) as u16,
|
|
||||||
hot_y: (ov.hot_y / f).min(h - 1) as u16,
|
|
||||||
rgba,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
fn overlay(w: u32, h: u32, hot: (u32, u32)) -> pf_frame::CursorOverlay {
|
|
||||||
pf_frame::CursorOverlay {
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
rgba: Arc::new((0..w * h * 4).map(|i| i as u8).collect()),
|
|
||||||
serial: 3,
|
|
||||||
hot_x: hot.0,
|
|
||||||
hot_y: hot.1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn small_shape_passes_through() {
|
|
||||||
let s = shape_from_overlay(&overlay(32, 32, (4, 5))).unwrap();
|
|
||||||
assert_eq!((s.w, s.h, s.hot_x, s.hot_y, s.serial), (32, 32, 4, 5, 3));
|
|
||||||
assert_eq!(s.rgba.len(), 32 * 32 * 4);
|
|
||||||
// Encodes within the u16 control-frame cap.
|
|
||||||
assert!(s.encode().len() <= u16::MAX as usize);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oversize_shape_downscales_with_hotspot() {
|
|
||||||
// 256² → f = ceil(256/120) = 3 → 86² (256.div_ceil(3)), hotspot scales with it.
|
|
||||||
let s = shape_from_overlay(&overlay(256, 256, (255, 0))).unwrap();
|
|
||||||
assert!(s.w <= CURSOR_SHAPE_MAX_SIDE && s.h <= CURSOR_SHAPE_MAX_SIDE);
|
|
||||||
assert_eq!(s.rgba.len(), s.w as usize * s.h as usize * 4);
|
|
||||||
assert!(s.hot_x < s.w && s.hot_y < s.h);
|
|
||||||
assert!(s.encode().len() <= u16::MAX as usize);
|
|
||||||
// The scaled message must decode (dims within the cap).
|
|
||||||
assert_eq!(CursorShape::decode(&s.encode()).unwrap(), s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_buffer_rejected() {
|
|
||||||
let mut ov = overlay(8, 8, (0, 0));
|
|
||||||
ov.rgba = Arc::new(vec![0; 8]);
|
|
||||||
assert!(shape_from_overlay(&ov).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,22 +8,6 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Whether this session forwards the cursor out-of-band (design/remote-desktop-sweep.md M2):
|
|
||||||
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
|
||||||
/// capture path can deliver cursor metadata separately from the frame — today that is the
|
|
||||||
/// Linux portal `SPA_META_Cursor` path only: not gamescope (its capture paints no cursor at
|
|
||||||
/// all), not Windows (DWM composites into the IDD frame — M2c). THE single predicate: the
|
|
||||||
/// Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off wiring both read it,
|
|
||||||
/// so they can never disagree.
|
|
||||||
pub(super) fn cursor_forward(
|
|
||||||
client_caps: u8,
|
|
||||||
compositor: Option<crate::vdisplay::Compositor>,
|
|
||||||
) -> bool {
|
|
||||||
cfg!(target_os = "linux")
|
|
||||||
&& client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR != 0
|
|
||||||
&& compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for
|
/// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for
|
||||||
/// mid-stream renegotiation afterwards). `first` is the already-read first control message.
|
/// mid-stream renegotiation afterwards). `first` is the already-read first control message.
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
@@ -460,14 +444,6 @@ pub(super) async fn negotiate(
|
|||||||
punktfunk_core::quic::HOST_CAP_CLIPBOARD
|
punktfunk_core::quic::HOST_CAP_CLIPBOARD
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
|
||||||
// Cursor channel granted (client asked + this capture path can deliver cursor
|
|
||||||
// metadata out of the frame) — the client turns its local renderer on ONLY when
|
|
||||||
// it sees this bit, and serve_session wires forwarding from the same predicate.
|
|
||||||
| if cursor_forward(hello.client_caps, compositor) {
|
|
||||||
punktfunk_core::quic::HOST_CAP_CURSOR
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
},
|
||||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||||
|
|||||||
@@ -938,14 +938,6 @@ pub(super) struct SessionContext {
|
|||||||
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
|
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
|
||||||
/// `host+network` latency stage. `None` = older client, no emission.
|
/// `host+network` latency stage. `None` = older client, no emission.
|
||||||
pub(super) timing_conn: Option<quinn::Connection>,
|
pub(super) timing_conn: Option<quinn::Connection>,
|
||||||
/// The session negotiated the cursor channel (design/remote-desktop-sweep.md M2 —
|
|
||||||
/// `handshake::cursor_forward`): the encoder does NOT blend the pointer into the video;
|
|
||||||
/// the encode loop forwards shape (via `cursor_shape_tx`) + per-tick `0xD0` state instead.
|
|
||||||
pub(super) cursor_forward: bool,
|
|
||||||
/// SHAPE bridge to the control task (the control stream's sole writer) — mirrors
|
|
||||||
/// `probe_result_tx`. Inert when `cursor_forward` is false.
|
|
||||||
pub(super) cursor_shape_tx:
|
|
||||||
tokio::sync::mpsc::UnboundedSender<punktfunk_core::quic::CursorShape>,
|
|
||||||
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may
|
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may
|
||||||
/// run mid-session in the probe index space (its reassembler keeps a separate probe window).
|
/// run mid-session in the probe index space (its reassembler keeps a separate probe window).
|
||||||
/// `false` = older client whose single-window reassembler would drop probe-space frames as
|
/// `false` = older client whose single-window reassembler would drop probe-space frames as
|
||||||
@@ -995,10 +987,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
ctx.bit_depth,
|
ctx.bit_depth,
|
||||||
ctx.chroma,
|
ctx.chroma,
|
||||||
ctx.codec,
|
ctx.codec,
|
||||||
// Blend the pointer into the video only where the capture HAS one (not gamescope) AND
|
ctx.compositor != pf_vdisplay::Compositor::Gamescope,
|
||||||
// the client is not drawing it locally (the M2 cursor channel — blending too would
|
|
||||||
// show it twice).
|
|
||||||
ctx.compositor != pf_vdisplay::Compositor::Gamescope && !ctx.cursor_forward,
|
|
||||||
);
|
);
|
||||||
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
||||||
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
||||||
@@ -1032,8 +1021,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
fec_target,
|
fec_target,
|
||||||
conn,
|
conn,
|
||||||
timing_conn,
|
timing_conn,
|
||||||
cursor_forward,
|
|
||||||
cursor_shape_tx,
|
|
||||||
probe_seq,
|
probe_seq,
|
||||||
streamed_au,
|
streamed_au,
|
||||||
stats,
|
stats,
|
||||||
@@ -1047,13 +1034,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||||
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
|
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
|
||||||
// Cursor-forward state (M2): shape-serial diffing + the per-tick 0xD0 state send. The
|
|
||||||
// encoder was told not to blend (SessionPlan above), so from the first frame the client's
|
|
||||||
// locally-drawn cursor is the only one.
|
|
||||||
let mut cursor_fwd = cursor_forward.then(super::cursor_fwd::CursorForwarder::new);
|
|
||||||
if cursor_forward {
|
|
||||||
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
|
|
||||||
}
|
|
||||||
if streamed_wire {
|
if streamed_wire {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
||||||
@@ -2005,12 +1985,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cursor channel (M2): every iteration — new frame OR repeat — states the pointer
|
|
||||||
// (self-healing under datagram loss) and forwards a changed shape via the control
|
|
||||||
// bridge. `frame` is the newest bound frame either way.
|
|
||||||
if let Some(fwd) = cursor_fwd.as_mut() {
|
|
||||||
fwd.tick(frame.cursor.as_ref(), &conn, &cursor_shape_tx);
|
|
||||||
}
|
|
||||||
if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) {
|
if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) {
|
||||||
let secs = diag_at.elapsed().as_secs_f64();
|
let secs = diag_at.elapsed().as_secs_f64();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -158,9 +158,25 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
|||||||
if bun.exists() && runner.exists() {
|
if bun.exists() && runner.exists() {
|
||||||
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||||
}
|
}
|
||||||
|
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload
|
||||||
|
// out user-scoped under ~/.local — wrapper, private bun, and bundle mirroring the deb's
|
||||||
|
// /usr layout — because a system package can't exist there.
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let home = std::path::Path::new(&home);
|
||||||
|
let wrapper = home.join(".local/bin/punktfunk-scripting");
|
||||||
|
if wrapper.exists() {
|
||||||
|
return Ok((wrapper, Vec::new()));
|
||||||
|
}
|
||||||
|
let bun = home.join(".local/lib/punktfunk-scripting/bun");
|
||||||
|
let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js");
|
||||||
|
if bun.exists() && runner.exists() {
|
||||||
|
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
bail!(
|
bail!(
|
||||||
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
|
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
|
||||||
`sudo apt install punktfunk-scripting`)"
|
`sudo apt install punktfunk-scripting`; SteamOS: re-run \
|
||||||
|
scripts/steamdeck/install.sh)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,12 +114,18 @@ pub fn register(
|
|||||||
session: session_ref(&session),
|
session: session_ref(&session),
|
||||||
});
|
});
|
||||||
registry().lock().unwrap().push(session);
|
registry().lock().unwrap().push(session);
|
||||||
LiveSessionGuard { id }
|
LiveSessionGuard {
|
||||||
|
id,
|
||||||
|
_sleep: crate::sleep_inhibit::hold(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes its session from the registry when dropped (any scope exit of the native video loop).
|
/// Removes its session from the registry when dropped (any scope exit of the native video loop).
|
||||||
pub struct LiveSessionGuard {
|
pub struct LiveSessionGuard {
|
||||||
id: u64,
|
id: u64,
|
||||||
|
/// While any native session lives, the box must not auto-suspend under a passive viewer
|
||||||
|
/// ([`crate::sleep_inhibit`]) — refcounted, released with the guard.
|
||||||
|
_sleep: crate::sleep_inhibit::StreamHold,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for LiveSessionGuard {
|
impl Drop for LiveSessionGuard {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host
|
||||||
|
//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a
|
||||||
|
//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer
|
||||||
|
//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and
|
||||||
|
//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted
|
||||||
|
//! across planes (native sessions + GameStream media): the first hold acquires, the last drop
|
||||||
|
//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on.
|
||||||
|
//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
|
||||||
|
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
/// RAII share of the host-wide inhibitor — hold one per live session/stream.
|
||||||
|
pub struct StreamHold(());
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
count: u32,
|
||||||
|
/// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fd: Option<ashpd::zbus::zvariant::OwnedFd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> &'static Mutex<State> {
|
||||||
|
static S: OnceLock<Mutex<State>> = OnceLock::new();
|
||||||
|
S.get_or_init(|| {
|
||||||
|
Mutex::new(State {
|
||||||
|
count: 0,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fd: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a share; the underlying inhibitor is acquired on the 0→1 edge.
|
||||||
|
pub fn hold() -> StreamHold {
|
||||||
|
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
st.count += 1;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if st.count == 1 && st.fd.is_none() {
|
||||||
|
st.fd = acquire();
|
||||||
|
}
|
||||||
|
StreamHold(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StreamHold {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
st.count = st.count.saturating_sub(1);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if st.count == 0 && st.fd.take().is_some() {
|
||||||
|
tracing::info!("released the sleep/idle inhibitor (no live sessions)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on
|
||||||
|
/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either.
|
||||||
|
/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn acquire() -> Option<ashpd::zbus::zvariant::OwnedFd> {
|
||||||
|
let fd = std::thread::spawn(|| -> Option<ashpd::zbus::zvariant::OwnedFd> {
|
||||||
|
use ashpd::zbus;
|
||||||
|
// zbus's blocking API is configured out by ashpd's feature set — drive the async API on
|
||||||
|
// a private current-thread runtime instead (still on this plain thread; see fn doc).
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
let attempt: zbus::Result<zbus::zvariant::OwnedFd> = rt.block_on(async {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let reply = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.freedesktop.login1"),
|
||||||
|
"/org/freedesktop/login1",
|
||||||
|
Some("org.freedesktop.login1.Manager"),
|
||||||
|
"Inhibit",
|
||||||
|
&("sleep:idle", "Punktfunk", "a client is streaming", "block"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
reply.body().deserialize()
|
||||||
|
});
|
||||||
|
match attempt {
|
||||||
|
Ok(fd) => Some(fd),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"could not take a logind sleep/idle inhibitor — the box may auto-suspend \
|
||||||
|
under a passive (video-only) viewer"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if fd.is_some() {
|
||||||
|
tracing::info!("holding a logind sleep/idle inhibitor while clients stream");
|
||||||
|
}
|
||||||
|
fd
|
||||||
|
}
|
||||||
@@ -70,6 +70,10 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
|
|||||||
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
|
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
|
||||||
and SteamOS's current gamescope is fine; this only bites if you've pinned an old one.
|
and SteamOS's current gamescope is fine; this only bites if you've pinned an old one.
|
||||||
- **The mouse cursor isn't included in the captured image** — a gamescope limitation for now.
|
- **The mouse cursor isn't included in the captured image** — a gamescope limitation for now.
|
||||||
|
- **Touch arrives as a single-finger pointer.** gamescope's virtual input device has no
|
||||||
|
touchscreen, so the host maps a client's touchscreen to an absolute pointer: taps click exactly
|
||||||
|
where you touch and drags work, but multi-touch gestures (pinch) aren't available in Gaming
|
||||||
|
Mode. The desktop path has full multi-touch.
|
||||||
- **HDR isn't supported on the gamescope path** — gamescope's capture output is 8-bit. SDR streams
|
- **HDR isn't supported on the gamescope path** — gamescope's capture output is 8-bit. SDR streams
|
||||||
normally.
|
normally.
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ punktfunk-host plugins add playnite # or: rom-manager
|
|||||||
punktfunk-host plugins enable # turn the runner on (once)
|
punktfunk-host plugins enable # turn the runner on (once)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On **SteamOS** the [host installer](/docs/steamos-host) ships the runner automatically (user-scoped
|
||||||
|
under `~/.local` — the read-only `/usr` can't take the package). If the console reports the runner
|
||||||
|
isn't installed on an older setup, re-run `scripts/steamdeck/update.sh` once.
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="Windows">
|
<Tab value="Windows">
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,17 @@ bash ~/punktfunk/scripts/steamdeck/install.sh
|
|||||||
It is idempotent — safe to re-run. In one pass it:
|
It is idempotent — safe to re-run. In one pass it:
|
||||||
|
|
||||||
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
||||||
2. builds `punktfunk-host` (and the web console),
|
2. builds `punktfunk-host`, the web console, and the **plugin/script runner** (so the console's
|
||||||
|
[plugin store](/docs/plugins) works out of the box — the runner service itself stays opt-in),
|
||||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||||
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||||
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
||||||
and seeds the KDE RemoteDesktop grant for Desktop-mode input — this step **prompts for your `sudo`
|
seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
||||||
|
atomic-update keep list** so OS updates carry it over — this step **prompts for your `sudo`
|
||||||
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
|
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
|
||||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||||
so they run without a login session).
|
so they run without a login session) plus a boot-time **rebuild check** that repairs the host
|
||||||
|
automatically if a SteamOS update ever breaks its library links.
|
||||||
|
|
||||||
Useful flags:
|
Useful flags:
|
||||||
|
|
||||||
@@ -144,8 +147,13 @@ bash ~/punktfunk/scripts/steamdeck/update.sh
|
|||||||
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
||||||
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||||
[Stream to a Steam Deck](/docs/steam-deck).
|
[Stream to a Steam Deck](/docs/steam-deck).
|
||||||
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
|
- **It survives OS updates — automatically.** SteamOS A/B updates rebuild `/etc` and can move
|
||||||
start after an update, just re-run `update.sh` to rebuild against the new base.
|
library versions; the installer defends both sides. The system tuning (gamepad udev rule,
|
||||||
|
`vhci-hcd`, UDP buffers) is registered on SteamOS's own atomic-update keep list
|
||||||
|
(`/etc/atomic-update.conf.d/`), so updates carry it over; and a boot-time check
|
||||||
|
(`punktfunk-rebuild-check`) probes the host binary and re-runs the build only if the new OS
|
||||||
|
actually broke its library links — you should never need to intervene. (Re-running `update.sh`
|
||||||
|
by hand still works and is harmless.)
|
||||||
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
||||||
|
|
||||||
Trouble? See [Troubleshooting](/docs/troubleshooting) and [Pairing](/docs/pairing).
|
Trouble? See [Troubleshooting](/docs/troubleshooting) and [Pairing](/docs/pairing).
|
||||||
|
|||||||
@@ -498,28 +498,6 @@
|
|||||||
#define HOST_CAP_CLIPBOARD 2
|
#define HOST_CAP_CLIPBOARD 2
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::client_caps`] bit: the client renders the host cursor LOCALLY
|
|
||||||
// (design/remote-desktop-sweep.md M2). It consumes [`CursorShape`](super::control::CursorShape)
|
|
||||||
// control messages (RGBA bitmap + hotspot, cached by serial) and per-frame
|
|
||||||
// [`CursorState`](super::datagram::CursorState) `0xD0` datagrams (position/visibility), and
|
|
||||||
// draws the pointer itself — so the host must STOP compositing the cursor into the video
|
|
||||||
// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
|
|
||||||
// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
|
|
||||||
// an older or incapable host nothing changes.
|
|
||||||
#define CLIENT_CAP_CURSOR 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
|
||||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
|
||||||
// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
|
||||||
// frame). Set only when the client asked via [`CLIENT_CAP_CURSOR`]; when both bits agree the
|
|
||||||
// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
|
|
||||||
// [`CursorState`](super::datagram::CursorState) instead.
|
|
||||||
#define HOST_CAP_CURSOR 4
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
@@ -772,20 +750,6 @@
|
|||||||
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
|
|
||||||
#define MSG_CURSOR_SHAPE 80
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Per-side pixel cap for a forwarded cursor bitmap. The control-stream frame is length-prefixed
|
|
||||||
// with a `u16`, so a whole message must fit 65535 bytes — 128×128 RGBA (65536 B) already
|
|
||||||
// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
|
|
||||||
// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
|
|
||||||
// larger before forwarding, so the cap is invisible to clients.
|
|
||||||
#define CURSOR_SHAPE_MAX_SIDE 120
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||||
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||||
@@ -874,28 +838,6 @@
|
|||||||
#define HOST_TIMING_MAGIC 207
|
#define HOST_TIMING_MAGIC 207
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Cursor-state datagram tag, host → client (design/remote-desktop-sweep.md M2). Next tag after
|
|
||||||
// [`HOST_TIMING_MAGIC`]. Sent once per captured frame while the cursor channel is negotiated
|
|
||||||
// ([`CLIENT_CAP_CURSOR`](super::caps::CLIENT_CAP_CURSOR) ∧
|
|
||||||
// [`HOST_CAP_CURSOR`](super::caps::HOST_CAP_CURSOR)) — per-frame resend makes the plane
|
|
||||||
// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
|
|
||||||
// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
|
|
||||||
// datagram only moves/hides the pointer.
|
|
||||||
#define CURSOR_STATE_MAGIC 208
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`CursorState::flags`] bit: the host cursor is visible.
|
|
||||||
#define CURSOR_VISIBLE 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
|
|
||||||
// relative/captured (M3 auto-flip; advisory, user override always wins).
|
|
||||||
#define CURSOR_RELATIVE_HINT 2
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||||
@@ -1035,6 +977,13 @@
|
|||||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||||
#define WIRE_VERSION_CLOSE_CODE 103
|
#define WIRE_VERSION_CLOSE_CODE 103
|
||||||
|
|
||||||
|
// The host admitted the connection but could not stand the stream session up (compositor /
|
||||||
|
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
|
||||||
|
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
|
||||||
|
// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||||
|
// finished mid-frame") — indistinguishable from transport trouble.
|
||||||
|
#define SETUP_FAILED_CLOSE_CODE 104
|
||||||
|
|
||||||
// Minimum supported multiplier (renders under native, upscaled on present).
|
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||||
#define MIN_SCALE 0.5
|
#define MIN_SCALE 0.5
|
||||||
|
|
||||||
@@ -1068,6 +1017,7 @@ enum PunktfunkStatus
|
|||||||
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
|
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
|
||||||
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
|
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
|
||||||
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
|
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
|
||||||
|
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
|
||||||
PUNKTFUNK_STATUS_PANIC = -99,
|
PUNKTFUNK_STATUS_PANIC = -99,
|
||||||
};
|
};
|
||||||
#ifndef __cplusplus
|
#ifndef __cplusplus
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# punktfunk — SteamOS atomic-update preserve list (drop-in for /etc/atomic-update.conf.d/).
|
||||||
|
#
|
||||||
|
# SteamOS's A/B updates rebuild /etc from scratch and carry over ONLY the files matched by
|
||||||
|
# /usr/lib/rauc/atomic-update-keep.conf plus these operator drop-ins (the drop-in dir itself is
|
||||||
|
# on Valve's keep list, so this file is self-preserving). Without it, every OS update silently
|
||||||
|
# strips the host's system integration: /dev/uhid access (virtual pads degrade to Xbox 360),
|
||||||
|
# the vhci-hcd autoload (no native Steam Deck pad), and the UDP buffer sizing (stream stutter).
|
||||||
|
# Same wildcard rules as the stock list: '*' matches within a path segment, '**' across.
|
||||||
|
|
||||||
|
## Virtual-gamepad device access (uinput/uhid/vhci for the input group)
|
||||||
|
/etc/udev/rules.d/60-punktfunk.rules
|
||||||
|
|
||||||
|
## vhci-hcd autoload (usbip transport for the native Steam Deck pad)
|
||||||
|
/etc/modules-load.d/punktfunk.conf
|
||||||
|
|
||||||
|
## UDP socket buffer sizing (32 MB, matches the deb's sysctl drop-in)
|
||||||
|
/etc/sysctl.d/99-punktfunk-net.conf
|
||||||
@@ -22,6 +22,15 @@ is only the build environment; `punktfunk-host` is launched directly, not via `d
|
|||||||
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
|
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
|
||||||
auto-selected by `PUNKTFUNK_ENCODER=auto`.
|
auto-selected by `PUNKTFUNK_ENCODER=auto`.
|
||||||
|
|
||||||
|
The honest trade-off: on-device building costs a slow first install (~10–15 min, ~1 GB of image +
|
||||||
|
toolchain) and adds moving parts (apt mirrors, rustup, bun) — the price of an install that can
|
||||||
|
always chase the OS. Both failure modes of that chase are automated away now:
|
||||||
|
`punktfunk-rebuild-check` rebuilds when an OS update breaks the binary's links, and the
|
||||||
|
atomic-update keep list preserves the `/etc` tuning. The eventual lighter-weight alternative is a
|
||||||
|
CI-prebuilt bundle with the volatile libraries (FFmpeg et al.) vendored under an `$ORIGIN` rpath —
|
||||||
|
OS-update-proof without a toolchain on the device — worth it once SteamOS host volume justifies
|
||||||
|
per-release artifact signing/hosting; the from-source path would stay as the dev/fallback route.
|
||||||
|
|
||||||
The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
|
The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
|
||||||
build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
|
build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
|
||||||
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
|
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
|
||||||
@@ -31,8 +40,9 @@ serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service
|
|||||||
|
|
||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host (+web) → write config → tune sysctl + `input` group (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger. |
|
| `install.sh` | Idempotent installer: ensure the `pf2` distrobox + toolchain → build host + web + **plugin runner** → write config → tune sysctl + udev + `vhci-hcd` + `input` group and **register it on SteamOS's atomic-update keep list** (sudo) → install + start `punktfunk-host` / `punktfunk-web` systemd **user** services with linger, plus the **rebuild check** below. |
|
||||||
| `update.sh` | Rebuild from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. |
|
| `update.sh` | Rebuild everything from the current source and restart the services (config + pairings persist). `--pull` does `git pull` first. Also retrofits anything a newer install.sh writes (runner, keep-list registration, rebuild check) onto older installs. |
|
||||||
|
| `rebuild-check.sh` | The post-OS-update self-heal (run by `punktfunk-rebuild-check.service` before the host at session start): `ldd`-probes the binary — milliseconds when healthy, a full `update.sh` rebuild only when a SteamOS update actually broke its library links. |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
|
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
|
||||||
@@ -57,10 +67,19 @@ default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (47992).
|
|||||||
here too and persists across updates.
|
here too and persists across updates.
|
||||||
- **Services:** `~/.config/systemd/user/punktfunk-host.service` (runs `serve --gamestream --mgmt-bind
|
- **Services:** `~/.config/systemd/user/punktfunk-host.service` (runs `serve --gamestream --mgmt-bind
|
||||||
0.0.0.0:47990`, `+ --open` if chosen — `--gamestream` adds the Moonlight-compat planes so the Deck's
|
0.0.0.0:47990`, `+ --open` if chosen — `--gamestream` adds the Moonlight-compat planes so the Deck's
|
||||||
Game Mode also streams to stock Moonlight; the native `punktfunk/1` plane is always on) and
|
Game Mode also streams to stock Moonlight; the native `punktfunk/1` plane is always on),
|
||||||
`punktfunk-web.service`. Linger is enabled so they run without a login session.
|
`punktfunk-web.service`, `punktfunk-rebuild-check.service` (post-OS-update self-heal, enabled), and
|
||||||
|
`punktfunk-scripting.service` (plugin runner, **opt-in** — enable it once you use plugins/scripts).
|
||||||
|
Linger is enabled so they run without a login session.
|
||||||
|
- **Plugin runner:** the deb's payload laid out user-scoped (read-only `/usr` can't take the
|
||||||
|
package): wrapper `~/.local/bin/punktfunk-scripting`, pinned `bun` in
|
||||||
|
`~/.local/lib/punktfunk-scripting/`, bundle in `~/.local/share/punktfunk-scripting/`.
|
||||||
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
|
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
|
||||||
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules`, and `$USER` in the `input` group.
|
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules` (`uinput`/`uhid` access),
|
||||||
|
`/etc/modules-load.d/punktfunk.conf` (`vhci-hcd` for the native Deck pad), `$USER` in the `input`
|
||||||
|
group — and `/etc/atomic-update.conf.d/punktfunk.conf`, which registers the three files on
|
||||||
|
SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an
|
||||||
|
update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
|
||||||
|
|
||||||
## Operating
|
## Operating
|
||||||
|
|
||||||
@@ -83,5 +102,7 @@ host advertises over mDNS as `_punktfunk._udp`, so clients discover it automatic
|
|||||||
for a headless host.
|
for a headless host.
|
||||||
- **WiFi tx ceiling** ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent);
|
- **WiFi tx ceiling** ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent);
|
||||||
fine for 1080p/1440p60. A wired dock lifts it.
|
fine for 1080p/1440p60. A wired dock lifts it.
|
||||||
- **After a major SteamOS update**, if the host won't start, run `update.sh` to rebuild against the new
|
- **After a SteamOS update** nothing should be needed: the `/etc` tuning survives via the
|
||||||
base libraries.
|
atomic-update keep list, and `punktfunk-rebuild-check` rebuilds the binary automatically if the
|
||||||
|
new base actually broke its library links (first session start after the update takes the build's
|
||||||
|
few minutes in that case). A manual `update.sh` remains harmless.
|
||||||
|
|||||||
@@ -153,6 +153,35 @@ cd '$SRC/web' && bun install --frozen-lockfile && bun run build
|
|||||||
ok "web console built"
|
ok "web console built"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- 2b. plugin runner (scripting) -----------------------------------------
|
||||||
|
# The console's plugin store and `punktfunk-host plugins …` shell out to the scripting runner —
|
||||||
|
# the SDK's runner CLI bundled to one self-contained JS, run on a pinned bun (it import()s the
|
||||||
|
# operator's .ts plugins; see packaging/debian/build-scripting-deb.sh). The .deb lays it out under
|
||||||
|
# /usr, which is read-only here, so ship the SAME payload user-scoped — wrapper + private bun +
|
||||||
|
# bundle under ~/.local, where the host's runner discovery looks after the /usr layouts.
|
||||||
|
log "Building the plugin runner (scripting)"
|
||||||
|
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
|
||||||
|
distrobox enter "$BOX" -- bash -lc "
|
||||||
|
set -e
|
||||||
|
export PATH=\$HOME/.bun/bin:\$PATH
|
||||||
|
cd '$SRC/sdk'
|
||||||
|
bun install --frozen-lockfile --ignore-scripts
|
||||||
|
bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\"
|
||||||
|
# Pin the runtime: copy the box's bun next to the bundle so a bun self-update (or a box rebuild)
|
||||||
|
# never changes what the runner executes under.
|
||||||
|
install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\"
|
||||||
|
"
|
||||||
|
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|
||||||
|
|| die "runner bundle missing the dynamic plugin import — wrong build"
|
||||||
|
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
|
||||||
|
#!/bin/sh
|
||||||
|
# Generated by scripts/steamdeck/install.sh — user-scoped punktfunk-scripting (the .deb's /usr/bin
|
||||||
|
# wrapper, relocated): the runner bundle on its private pinned bun.
|
||||||
|
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
|
||||||
|
WRAP
|
||||||
|
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
|
||||||
|
ok "plugin runner: ~/.local/bin/punktfunk-scripting"
|
||||||
|
|
||||||
# --- 3. config -------------------------------------------------------------
|
# --- 3. config -------------------------------------------------------------
|
||||||
log "Configuration ($CONFIG)"
|
log "Configuration ($CONFIG)"
|
||||||
mkdir -p "$CONFIG"
|
mkdir -p "$CONFIG"
|
||||||
@@ -245,6 +274,14 @@ if [ "$SUDO_OK" = 1 ]; then
|
|||||||
NEED_RELOGIN=1
|
NEED_RELOGIN=1
|
||||||
warn "added $USER to the 'input' group (applies on next login)"
|
warn "added $USER to the 'input' group (applies on next login)"
|
||||||
fi
|
fi
|
||||||
|
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
|
||||||
|
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
|
||||||
|
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
|
||||||
|
# /etc/atomic-update.conf.d/ (itself on the stock keep list, so it self-preserves).
|
||||||
|
if [ -f "$SRC/scripts/punktfunk-atomic-keep.conf" ]; then
|
||||||
|
sudo install -Dm644 "$SRC/scripts/punktfunk-atomic-keep.conf" /etc/atomic-update.conf.d/punktfunk.conf
|
||||||
|
ok "system tuning registered to survive SteamOS updates (atomic-update.conf.d)"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
||||||
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
||||||
@@ -306,6 +343,35 @@ EOF
|
|||||||
ok "punktfunk-web.service (port $WEB_PORT)"
|
ok "punktfunk-web.service (port $WEB_PORT)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The runner's user unit (OPT-IN, matching the .deb: installed but NOT enabled — the runner is
|
||||||
|
# inert until you add scripts/plugins). ExecStart is rewritten from the packaged /usr wrapper to
|
||||||
|
# the user-scoped one section 2b installed.
|
||||||
|
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
|
||||||
|
"$SRC/scripts/punktfunk-scripting.service" > "$UNITS/punktfunk-scripting.service"
|
||||||
|
ok "punktfunk-scripting.service (opt-in: systemctl --user enable --now punktfunk-scripting)"
|
||||||
|
|
||||||
|
# Post-OS-update self-heal: SteamOS A/B updates can bump library sonames the host binary links
|
||||||
|
# (FFmpeg/PipeWire/libva) — this oneshot probes the binary with ldd before punktfunk-host starts
|
||||||
|
# and re-runs update.sh only when it actually stopped loading. Milliseconds on a normal boot.
|
||||||
|
cat > "$UNITS/punktfunk-rebuild-check.service" <<EOF
|
||||||
|
# Generated by scripts/steamdeck/install.sh — rebuild the host if a SteamOS update broke its libs.
|
||||||
|
[Unit]
|
||||||
|
Description=punktfunk SteamOS post-update rebuild check
|
||||||
|
Before=punktfunk-host.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=$SRC/scripts/steamdeck/rebuild-check.sh
|
||||||
|
# A cold-ish rebuild is minutes, not seconds.
|
||||||
|
TimeoutStartSec=1800
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
chmod +x "$SRC/scripts/steamdeck/rebuild-check.sh" 2>/dev/null || true
|
||||||
|
systemctl --user enable punktfunk-rebuild-check.service 2>/dev/null || true
|
||||||
|
ok "punktfunk-rebuild-check.service (auto-rebuild after SteamOS updates)"
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; }
|
loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; }
|
||||||
# enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where
|
# enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where
|
||||||
@@ -327,7 +393,7 @@ echo
|
|||||||
log "Done — punktfunk host is running on this Steam Deck"
|
log "Done — punktfunk host is running on this Steam Deck"
|
||||||
echo " • Host status: systemctl --user status punktfunk-host"
|
echo " • Host status: systemctl --user status punktfunk-host"
|
||||||
if [ "$WITH_WEB" = 1 ]; then
|
if [ "$WITH_WEB" = 1 ]; then
|
||||||
echo " • Web console: http://${IP:-steamdeck.local}:$WEB_PORT (login: see $CONFIG/web.env)"
|
echo " • Web console: https://${IP:-steamdeck.local}:$WEB_PORT (login: see $CONFIG/web.env)"
|
||||||
echo " • Pair a device: open the web console → Devices → arm pairing → enter the PIN on the client"
|
echo " • Pair a device: open the web console → Devices → arm pairing → enter the PIN on the client"
|
||||||
fi
|
fi
|
||||||
if [ "$OPEN" = 1 ]; then
|
if [ "$OPEN" = 1 ]; then
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# punktfunk — SteamOS post-OS-update self-heal (runs before punktfunk-host at session start).
|
||||||
|
#
|
||||||
|
# The host binary links SteamOS system libraries (FFmpeg, PipeWire, libva, …). A SteamOS A/B
|
||||||
|
# update that bumps a soname leaves the binary unable to load — a silently dead host until
|
||||||
|
# someone remembers to re-run update.sh. This probe is the reliability backstop:
|
||||||
|
# * healthy binary → exit in milliseconds (every normal boot);
|
||||||
|
# * loader breakage → run scripts/steamdeck/update.sh (rebuild host + web + runner against
|
||||||
|
# the new library tree, restart services). The build container, source, and cargo caches
|
||||||
|
# all live under /home, which SteamOS updates never touch — so the rebuild is warm.
|
||||||
|
#
|
||||||
|
# Root is NOT needed: the /etc system tuning survives updates via the atomic-update keep list
|
||||||
|
# (see punktfunk-atomic-keep.conf); only the binary has to chase the OS libraries.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# systemd user units get a minimal PATH — distrobox commonly lives in ~/.local/bin.
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||||
|
BIN="$SRC/target-steamos/release/punktfunk-host"
|
||||||
|
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "punktfunk-host binary missing at $BIN — running a full rebuild" >&2
|
||||||
|
elif ! ldd "$BIN" 2>/dev/null | grep -q "not found"; then
|
||||||
|
exit 0 # every library resolves — nothing to do
|
||||||
|
else
|
||||||
|
echo "punktfunk-host no longer loads after a SteamOS update — its missing libraries:" >&2
|
||||||
|
ldd "$BIN" 2>/dev/null | grep "not found" >&2 || true
|
||||||
|
echo "rebuilding against the new OS tree (this takes a few minutes; streaming resumes after)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec bash "$SRC/scripts/steamdeck/update.sh"
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
||||||
|
# warn was USED below but never defined — under `set -e` the first warn call ("command not
|
||||||
|
# found") aborted the whole update before the service restarts.
|
||||||
|
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
||||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||||
@@ -30,6 +33,48 @@ if [ "$WEB" = 1 ]; then
|
|||||||
ok "web rebuilt"
|
ok "web rebuilt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Plugin runner (scripting): rebuild the user-scoped runner payload (install.sh §2b) — also
|
||||||
|
# RETROFITS it onto older installs that predate it (the "plugin runner isn't installed" console
|
||||||
|
# state on SteamOS).
|
||||||
|
log "Rebuilding plugin runner (scripting)"
|
||||||
|
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
|
||||||
|
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/sdk' && bun install --frozen-lockfile --ignore-scripts && bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\" && install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\""
|
||||||
|
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|
||||||
|
|| die "runner bundle missing the dynamic plugin import — wrong build"
|
||||||
|
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
|
||||||
|
#!/bin/sh
|
||||||
|
# Generated by scripts/steamdeck/update.sh — user-scoped punktfunk-scripting (see install.sh §2b).
|
||||||
|
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
|
||||||
|
WRAP
|
||||||
|
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
|
||||||
|
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
|
||||||
|
"$SRC/scripts/punktfunk-scripting.service" > "$HOME/.config/systemd/user/punktfunk-scripting.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
ok "plugin runner rebuilt (opt-in service: systemctl --user enable --now punktfunk-scripting)"
|
||||||
|
|
||||||
|
# Retrofit the post-OS-update rebuild check (install.sh §5) onto older installs: probes the host
|
||||||
|
# binary with ldd at session start and re-runs this script when a SteamOS update broke its links.
|
||||||
|
if [ ! -f "$HOME/.config/systemd/user/punktfunk-rebuild-check.service" ]; then
|
||||||
|
cat > "$HOME/.config/systemd/user/punktfunk-rebuild-check.service" <<EOF
|
||||||
|
# Generated by scripts/steamdeck/update.sh — rebuild the host if a SteamOS update broke its libs.
|
||||||
|
[Unit]
|
||||||
|
Description=punktfunk SteamOS post-update rebuild check
|
||||||
|
Before=punktfunk-host.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=$SRC/scripts/steamdeck/rebuild-check.sh
|
||||||
|
TimeoutStartSec=1800
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
chmod +x "$SRC/scripts/steamdeck/rebuild-check.sh" 2>/dev/null || true
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable punktfunk-rebuild-check.service 2>/dev/null || true
|
||||||
|
ok "punktfunk-rebuild-check.service installed (auto-rebuild after SteamOS updates)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
||||||
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
||||||
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
||||||
@@ -77,6 +122,13 @@ if [ "$SUDO_OK" = 1 ]; then
|
|||||||
sudo usermod -aG input "$USER"
|
sudo usermod -aG input "$USER"
|
||||||
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
||||||
fi
|
fi
|
||||||
|
# Register the tuning on Valve's atomic-update preserve list (see install.sh §4): without
|
||||||
|
# this, every SteamOS A/B update strips the three files above again (verified live —
|
||||||
|
# gamepads silently degrade to Xbox 360, UDP buffers back to 208 KB).
|
||||||
|
if [ -f "$SRC/scripts/punktfunk-atomic-keep.conf" ]; then
|
||||||
|
sudo install -Dm644 "$SRC/scripts/punktfunk-atomic-keep.conf" /etc/atomic-update.conf.d/punktfunk.conf
|
||||||
|
ok "system tuning registered to survive SteamOS updates (atomic-update.conf.d)"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
|
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
|
||||||
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
|
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
|
||||||
@@ -94,8 +146,12 @@ if [ ! -s "$GRANT_DST" ] && [ -s "$GRANT_SRC" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Restarting services"
|
log "Restarting services"
|
||||||
systemctl --user restart punktfunk-host.service
|
# --no-block: when this script runs INSIDE punktfunk-rebuild-check.service (ordered
|
||||||
ok "punktfunk-host restarted"
|
# Before=punktfunk-host), a blocking restart would deadlock — the restart job waits for the
|
||||||
if [ "$WEB" = 1 ]; then systemctl --user restart punktfunk-web.service; ok "punktfunk-web restarted"; fi
|
# check unit, which waits for this script, which waits for the restart. Enqueue and move on;
|
||||||
|
# systemd starts the service the moment the ordering allows.
|
||||||
|
systemctl --user restart --no-block punktfunk-host.service
|
||||||
|
ok "punktfunk-host restart queued"
|
||||||
|
if [ "$WEB" = 1 ]; then systemctl --user restart --no-block punktfunk-web.service; ok "punktfunk-web restart queued"; fi
|
||||||
echo
|
echo
|
||||||
log "Updated. Status: systemctl --user status punktfunk-host"
|
log "Updated. Status: systemctl --user status punktfunk-host"
|
||||||
|
|||||||
Reference in New Issue
Block a user