Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64b9d11ee6 |
@@ -327,25 +327,12 @@ struct ContentView: View {
|
||||
}()
|
||||
return ZStack {
|
||||
stream(captureEnabled: pendingFingerprint == nil)
|
||||
// Blur the live stream during the trust prompt (heavy) and during a resize (lighter
|
||||
// — the deliberate "hold on" while the host rebuilds its pipeline and the decoder
|
||||
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
|
||||
// as before (its own overlay handles the transition).
|
||||
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
|
||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||
.blur(radius: pendingFingerprint != nil ? 32 : 0)
|
||||
.overlay {
|
||||
if pendingFingerprint != nil {
|
||||
Color.black.opacity(0.45)
|
||||
}
|
||||
}
|
||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||
// steering and the next click still reaches the stream.
|
||||
.overlay {
|
||||
if pendingFingerprint == nil {
|
||||
ResizeIndicatorView(active: model.resizing)
|
||||
}
|
||||
}
|
||||
if let fp = pendingFingerprint {
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
@@ -423,16 +410,6 @@ struct ContentView: View {
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
},
|
||||
// Resize overlay START — the follower is main-actor, so this drives the blur
|
||||
// + spinner synchronously the instant the window differs from the live mode.
|
||||
onResizeTarget: { [weak model] w, h in
|
||||
model?.resizeTargeted(width: w, height: h)
|
||||
},
|
||||
// Resize overlay END — the coded dims of each new-mode IDR, reported from the
|
||||
// decode pump thread; hop to the main actor to clear the overlay.
|
||||
onDecodedSize: { [weak model] w, h in
|
||||
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
|
||||
},
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// The resize overlay (design/midstream-resolution-resize.md — client resize UX). A Match-window
|
||||
// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox
|
||||
// decoder on the first new-mode IDR — an unavoidable sub-second gap where the last frame lingers,
|
||||
// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter,
|
||||
// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption
|
||||
// acknowledges the transition. It clears the instant a frame at the requested size decodes (the
|
||||
// `onDecodedSize` END signal) or on the follower's safety timeout — see `SessionModel.resizing`.
|
||||
//
|
||||
// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a
|
||||
// resize the user triggers by dragging the window never swallows their next click.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct ResizeIndicatorView: View {
|
||||
/// Mirrors `SessionModel.resizing`; the fade in/out is driven off this.
|
||||
let active: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if active {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Resizing…")
|
||||
.font(.geist(15, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.vertical, 24)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark) // the spinner + glass read over any frame
|
||||
.animation(.easeInOut(duration: 0.22), value: active)
|
||||
.allowsHitTesting(false) // the stream keeps receiving input the whole time
|
||||
}
|
||||
}
|
||||
@@ -109,16 +109,6 @@ final class SessionModel: ObservableObject {
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
|
||||
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
|
||||
@Published private(set) var resizing = false
|
||||
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
|
||||
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
|
||||
/// differently-sized frame. Ticked from the 1 Hz stats timer.
|
||||
private var resizeIndicator = ResizeIndicator()
|
||||
|
||||
let meter = FrameMeter()
|
||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||||
@@ -374,8 +364,6 @@ final class SessionModel: ObservableObject {
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
resizing = false
|
||||
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
|
||||
}
|
||||
|
||||
/// Called (via the main actor) when the pump hits end-of-session.
|
||||
@@ -386,23 +374,6 @@ final class SessionModel: ObservableObject {
|
||||
errorMessage = "Session ended by \(name)."
|
||||
}
|
||||
|
||||
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||||
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
|
||||
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
|
||||
func resizeTargeted(width: UInt32, height: UInt32) {
|
||||
resizeIndicator.steering(
|
||||
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
|
||||
resizing = resizeIndicator.active
|
||||
}
|
||||
|
||||
/// Resize overlay END (main actor — hopped from the decode pump's `onDecodedSize`): a new-mode
|
||||
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
|
||||
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
|
||||
func resizeDecoded(width: Int, height: Int) {
|
||||
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
|
||||
resizing = resizeIndicator.active
|
||||
}
|
||||
|
||||
private func beginStreaming() {
|
||||
guard let conn = connection else { return }
|
||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||
@@ -446,11 +417,6 @@ final class SessionModel: ObservableObject {
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
|
||||
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
|
||||
// success; this only fires after the timeout.
|
||||
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||||
self.resizing = self.resizeIndicator.active
|
||||
let (frames, bytes, total) = self.meter.drain()
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
|
||||
@@ -13,11 +13,6 @@ extension SettingsView {
|
||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS) || os(macOS)
|
||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||
Toggle("Match window", isOn: $matchWindow)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
iosResolutionWheel
|
||||
iosRefreshRows
|
||||
@@ -40,10 +35,7 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text(matchWindow
|
||||
? "The stream follows this window — the host resizes its virtual output to match "
|
||||
+ "as you resize, no scaling. \(Self.bitrateFooter)"
|
||||
: "The host creates a virtual output at exactly this mode — "
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -21,7 +21,6 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
|
||||
@@ -11,15 +11,6 @@ public enum DefaultsKey {
|
||||
public static let streamWidth = "punktfunk.width"
|
||||
public static let streamHeight = "punktfunk.height"
|
||||
public static let streamHz = "punktfunk.hz"
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): when on, the
|
||||
/// stream mode FOLLOWS the session view — the connect asks for the view's pixel size and a
|
||||
/// mid-session resize (a windowed macOS window, an iPad Stage Manager / Split View scene)
|
||||
/// renegotiates the host's virtual display + encoder (`PunktfunkConnection.requestMode`), so a
|
||||
/// windowed session streams native-resolution pixels instead of scaling. Off (default): the
|
||||
/// explicit `streamWidth`/`streamHeight` are used and never auto-resized (a fullscreen session
|
||||
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||
/// stream views' `MatchWindowFollower`.
|
||||
public static let matchWindow = "punktfunk.matchWindow"
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// Match-window resize follower (design/midstream-resolution-resize.md D1/D2, client C3).
|
||||
//
|
||||
// The presenting view feeds this its PHYSICAL-PIXEL size on every layout; it debounces to
|
||||
// resize-end, spaces requests ≥ 1 s apart, and asks the connection to switch the host's virtual
|
||||
// display + encoder to match (`PunktfunkConnection.requestMode`) — so a windowed macOS session or
|
||||
// an iPad Stage Manager / Split View scene streams native-resolution pixels instead of scaling.
|
||||
// The decode/present side needs nothing: VideoToolbox recreates its session on the keyframe-derived
|
||||
// format-description change (the first new-mode AU is an IDR with fresh parameter sets).
|
||||
//
|
||||
// The trigger discipline is the shared cross-client one (mirrors the session binary's
|
||||
// `resize_decision`): physical pixels rounded DOWN to even (the host rejects odd dimensions) and
|
||||
// clamped ≥ 320×200; debounce to resize-end; ≥ 1 s between requests; skip a size equal to the live
|
||||
// mode; and request each distinct size at most once — which both stops re-asking a rejected size
|
||||
// and keeps a host-side rollback (accepted, rebuild failed, corrective ack restored the old mode)
|
||||
// from looping request → rollback → request.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||
public enum MatchWindow {
|
||||
/// Even-floor + clamp a physical-pixel size to a host-valid mode dimension: the host's
|
||||
/// `validate_dimensions` rejects odd sizes, and we never ask below 320×200.
|
||||
public static func normalize(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||
let evenClamp: (Int, UInt32) -> UInt32 = { px, minimum in
|
||||
let even = UInt32(max(px, 0)) / 2 * 2
|
||||
return max(even, minimum)
|
||||
}
|
||||
return (evenClamp(widthPx, 320), evenClamp(heightPx, 200))
|
||||
}
|
||||
|
||||
/// Whether to request `target` now (the debounce has already settled; spacing is the caller's
|
||||
/// timer): `nil` to skip — equal to the live mode, or already requested once (a rejected size /
|
||||
/// a host rollback must not loop). `target` is expected already-[normalize]d.
|
||||
public static func request(
|
||||
target: (width: UInt32, height: UInt32),
|
||||
current: (width: UInt32, height: UInt32),
|
||||
lastRequested: (width: UInt32, height: UInt32)?
|
||||
) -> (width: UInt32, height: UInt32)? {
|
||||
if target.width == current.width, target.height == current.height { return nil }
|
||||
if let lr = lastRequested, lr.width == target.width, lr.height == target.height { return nil }
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the debounce timer + serialization state and drives `PunktfunkConnection.requestMode` from
|
||||
/// the stream view's layout callbacks. Main-actor: the views feed it on the main thread and it reads
|
||||
/// the connection's live mode there. Enabled per session from the `matchWindow` setting.
|
||||
@MainActor
|
||||
public final class MatchWindowFollower {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private let debounce: TimeInterval
|
||||
private let minSpacing: TimeInterval
|
||||
private var enabled: Bool
|
||||
|
||||
private var work: DispatchWorkItem?
|
||||
private var pendingSize: (width: Int, height: Int)?
|
||||
private var lastRequested: (width: UInt32, height: UInt32)?
|
||||
private var lastRequestAt: Date?
|
||||
/// The last size we reported via [`onResizeTarget`] — dedups the per-layout stream of a drag so
|
||||
/// the UI is notified once per distinct target, and reset to `nil` when the window is back in
|
||||
/// sync with the live mode (so a later resize re-reports).
|
||||
private var lastSteered: (width: UInt32, height: UInt32)?
|
||||
|
||||
/// Fired (on the main actor) the instant the window starts differing from the live mode — i.e.
|
||||
/// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the
|
||||
/// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the
|
||||
/// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target.
|
||||
public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)?
|
||||
|
||||
/// `debounce` = quiet time after the last size event before requesting (Win32 gets
|
||||
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
|
||||
/// (a full host pipeline rebuild each). Defaults match the other clients.
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
enabled: Bool,
|
||||
debounce: TimeInterval = 0.4,
|
||||
minSpacing: TimeInterval = 1.0
|
||||
) {
|
||||
self.connection = connection
|
||||
self.enabled = enabled
|
||||
self.debounce = debounce
|
||||
self.minSpacing = minSpacing
|
||||
}
|
||||
|
||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||
public func setEnabled(_ on: Bool) {
|
||||
enabled = on
|
||||
if !on {
|
||||
work?.cancel()
|
||||
work = nil
|
||||
pendingSize = nil
|
||||
lastSteered = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the presenting view's current PHYSICAL-PIXEL size (its `bounds` × the backing/display
|
||||
/// scale). Called from every layout pass; coalesced by the debounce so a drag-resize sends one
|
||||
/// request at its end, never one per frame.
|
||||
public func noteSize(widthPx: Int, heightPx: Int) {
|
||||
guard enabled else { return }
|
||||
pendingSize = (widthPx, heightPx)
|
||||
schedule()
|
||||
reportSteering(widthPx: widthPx, heightPx: heightPx)
|
||||
}
|
||||
|
||||
/// Report the resize overlay's START signal (deduped): the moment the normalized window size
|
||||
/// differs from the live mode we're steering toward a new size. No connection / no negotiated
|
||||
/// mode yet → nothing to compare against, skip.
|
||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||
guard let connection else { return }
|
||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0 else { return }
|
||||
if target.width == mode.width, target.height == mode.height {
|
||||
lastSteered = nil // back in sync — a later change re-reports
|
||||
return
|
||||
}
|
||||
if lastSteered?.width == target.width, lastSteered?.height == target.height { return }
|
||||
lastSteered = target
|
||||
onResizeTarget?(target.width, target.height)
|
||||
}
|
||||
|
||||
private func schedule() {
|
||||
work?.cancel()
|
||||
let item = DispatchWorkItem { [weak self] in self?.fire() }
|
||||
work = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item)
|
||||
}
|
||||
|
||||
private func fire() {
|
||||
guard enabled, let connection, let size = pendingSize else { return }
|
||||
// ≥ 1 s spacing: a request went out recently → re-arm the debounce and retry later rather
|
||||
// than fire early (keeps at most ~one request outstanding — the accept ack round-trips in
|
||||
// milliseconds, ahead of the host's rebuild).
|
||||
if let last = lastRequestAt, Date().timeIntervalSince(last) < minSpacing {
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
||||
let mode = connection.currentMode()
|
||||
pendingSize = nil
|
||||
guard let req = MatchWindow.request(
|
||||
target: target,
|
||||
current: (mode.width, mode.height),
|
||||
lastRequested: lastRequested
|
||||
) else { return }
|
||||
// Keep the current refresh — Match-window follows SIZE, not rate.
|
||||
connection.requestMode(width: req.width, height: req.height, refreshHz: mode.refreshHz)
|
||||
lastRequested = req
|
||||
lastRequestAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Resize-in-progress indicator state (design/midstream-resolution-resize.md — client UX).
|
||||
//
|
||||
// A mid-stream resize takes the host 0.3–2 s to rebuild its virtual display + encoder, and the
|
||||
// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale
|
||||
// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a
|
||||
// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp
|
||||
// new-resolution frame is on screen — so the wait reads as intentional, not as lag.
|
||||
//
|
||||
// This is driven ENTIRELY by signals the client already has (no new protocol):
|
||||
// * START — the Match-window follower reports the size it is steering toward (instant, on the
|
||||
// first resize layout, before the debounced request even leaves).
|
||||
// * END — the decode pipeline reports each new-mode IDR's dimensions; when they reach the target
|
||||
// the new picture is here.
|
||||
// * TIMEOUT — the safety net for a switch that never delivers the exact target: the host rejected
|
||||
// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size.
|
||||
//
|
||||
// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI
|
||||
// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes
|
||||
/// `ProcessInfo.processInfo.systemUptime` or a test clock).
|
||||
public struct ResizeIndicator {
|
||||
/// Whether the blur + spinner should be shown.
|
||||
public private(set) var active = false
|
||||
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
|
||||
private var target: (width: UInt32, height: UInt32)?
|
||||
/// When the current `active` span began — the timeout is measured from here.
|
||||
private var since: TimeInterval?
|
||||
/// How long to keep the overlay up if the target frame never arrives (rejected / capped switch).
|
||||
public var timeout: TimeInterval
|
||||
|
||||
public init(timeout: TimeInterval = 2.5) { self.timeout = timeout }
|
||||
|
||||
/// The follower is steering toward `width`×`height` — a resize is under way. Show the overlay now
|
||||
/// (instant feedback). Called only for a genuine change (the follower skips a target equal to the
|
||||
/// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the
|
||||
/// target actually changes so a slow drag never trips it mid-gesture.
|
||||
public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) {
|
||||
if !active || target?.width != width || target?.height != height {
|
||||
since = now
|
||||
}
|
||||
target = (width, height)
|
||||
active = true
|
||||
}
|
||||
|
||||
/// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it
|
||||
/// matches the steered target — the sharp new-resolution picture is on glass.
|
||||
public mutating func decoded(width: UInt32, height: UInt32) {
|
||||
guard active, let t = target, t.width == width, t.height == height else { return }
|
||||
active = false
|
||||
since = nil
|
||||
}
|
||||
|
||||
/// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame
|
||||
/// (a rejected or host-capped switch never delivers the exact target).
|
||||
public mutating func tick(now: TimeInterval) {
|
||||
guard active, let s = since, now - s >= timeout else { return }
|
||||
active = false
|
||||
since = nil
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,7 @@ final class SessionPresenter {
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
stop()
|
||||
self.connection = connection
|
||||
@@ -129,14 +128,12 @@ final class SessionPresenter {
|
||||
link.add(to: .main, forMode: .common)
|
||||
stage2Link = link
|
||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||
pipeline.start(
|
||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
} else {
|
||||
let pump = StreamPump()
|
||||
pump.start(
|
||||
connection: connection, layer: baseLayer,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
self.pump = pump
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +329,7 @@ public final class Stage2Pipeline {
|
||||
public func start(
|
||||
connection: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
@@ -351,9 +350,6 @@ public final class Stage2Pipeline {
|
||||
let thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||
// loss-recovery IDR at the same size (see StreamPump).
|
||||
var lastDecodedDims: CMVideoDimensions?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||
@@ -391,11 +387,6 @@ public final class Stage2Pipeline {
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||
lastDecodedDims = dims
|
||||
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
|
||||
@@ -21,8 +21,7 @@ final class StreamPump {
|
||||
connection: PunktfunkConnection,
|
||||
layer: AVSampleBufferDisplayLayer,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
let token = token
|
||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||
@@ -36,9 +35,6 @@ final class StreamPump {
|
||||
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR),
|
||||
// not on every loss-recovery IDR at the same size — so it fires once per real switch.
|
||||
var lastDecodedDims: CMVideoDimensions?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||
@@ -83,11 +79,6 @@ final class StreamPump {
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||
lastDecodedDims = dims
|
||||
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||
}
|
||||
if awaitingIDR {
|
||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||
|
||||
@@ -87,8 +87,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
private let onDisconnectRequest: (() -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -110,8 +108,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
onDisconnectRequest: (() -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
@@ -122,8 +118,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
self.onDisconnectRequest = onDisconnectRequest
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.onResizeTarget = onResizeTarget
|
||||
self.onDecodedSize = onDecodedSize
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
@@ -137,8 +131,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return view
|
||||
}
|
||||
@@ -150,8 +142,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||
// connection identity actually changed.
|
||||
if view.connection !== connection {
|
||||
@@ -175,9 +165,6 @@ public final class StreamLayerView: NSView {
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size on every relayout.
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
private let cursorCapture = CursorCapture()
|
||||
private var inputCapture: InputCapture?
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
@@ -214,13 +201,6 @@ public final class StreamLayerView: NSView {
|
||||
/// view can't do that itself (the connection's owner disconnects).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
/// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget`
|
||||
/// (main thread, via the follower) fires the instant the window starts steering toward a new
|
||||
/// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives
|
||||
/// the blur+spinner from these — set before `start()`.
|
||||
public var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
public var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
|
||||
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
||||
/// stream); flipping to true auto-engages once.
|
||||
public var captureEnabled = true {
|
||||
@@ -646,32 +626,15 @@ public final class StreamLayerView: NSView {
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
|
||||
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); the first real `layout()` feeds the initial
|
||||
// size, so the stream converges to the window even if the connect used the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
onSessionEnd: onSessionEnd)
|
||||
layoutPresenter()
|
||||
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
||||
}
|
||||
|
||||
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
|
||||
/// retina↔non-retina move (see SessionPresenter.layout). Also feeds the Match-window follower
|
||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||
/// retina↔non-retina move (see SessionPresenter.layout).
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||
let px = convertToBacking(bounds).size
|
||||
matchFollower?.noteSize(
|
||||
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
@@ -687,7 +650,6 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
private let onCaptureChange: ((Bool) -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -70,8 +68,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onDisconnectRequest: (() -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
@@ -81,8 +77,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
self.onCaptureChange = onCaptureChange
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.onResizeTarget = onResizeTarget
|
||||
self.onDecodedSize = onDecodedSize
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
@@ -95,8 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return controller
|
||||
}
|
||||
@@ -107,8 +99,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
if controller.connection !== connection {
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
}
|
||||
@@ -157,11 +147,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// Capture state at the last resign, restored on the next foreground — otherwise the
|
||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||
private var wasCapturedOnResign = false
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad
|
||||
/// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone
|
||||
/// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -176,13 +161,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
|
||||
var onCaptureChange: ((Bool) -> Void)?
|
||||
/// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the
|
||||
/// blur+spinner the instant the window differs from the live mode (iOS only — tvOS has no
|
||||
/// follower). See `MatchWindowFollower.onResizeTarget`.
|
||||
var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||
/// overlay clears when a frame at the requested size actually decodes.
|
||||
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
@@ -349,14 +327,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): follow the scene's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it — covers
|
||||
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
@@ -370,8 +340,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
onSessionEnd: onSessionEnd)
|
||||
layoutMetalLayer()
|
||||
|
||||
#if os(iOS)
|
||||
@@ -442,7 +411,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
streamView.onPointerButton = nil
|
||||
streamView.onScroll = nil
|
||||
streamView.currentHostMode = nil
|
||||
matchFollower = nil
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// Return the TV to the user's preferred mode — the home screen must not stay in the
|
||||
@@ -457,16 +425,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
layoutMetalLayer()
|
||||
#if os(iOS)
|
||||
// Match-window (C3): feed the follower the view's physical-pixel size (points × scale).
|
||||
let b = streamView.bounds
|
||||
if b.width > 0, b.height > 0 {
|
||||
let scale = renderScale
|
||||
matchFollower?.noteSize(
|
||||
widthPx: Int((b.width * scale).rounded()),
|
||||
heightPx: Int((b.height * scale).rounded()))
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
applyDisplayCriteriaIfNeeded()
|
||||
#endif
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// The Match-window trigger discipline (design/midstream-resolution-resize.md D2), as pure
|
||||
// functions — the same rules the session binary's `resize_decision` unit-tests: physical pixels
|
||||
// even-floored and clamped ≥ 320×200, skip a size equal to the live mode, and request each
|
||||
// distinct size at most once (so a rejected size / a host rollback can't loop).
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class MatchWindowTests: XCTestCase {
|
||||
func testNormalizeEvenFloorsAndClamps() {
|
||||
// Odd pixels floor to even (the host rejects odd dimensions).
|
||||
let a = MatchWindow.normalize(widthPx: 1001, heightPx: 601)
|
||||
XCTAssertEqual(a.width, 1000)
|
||||
XCTAssertEqual(a.height, 600)
|
||||
// Already-even sizes pass through.
|
||||
let b = MatchWindow.normalize(widthPx: 2560, heightPx: 1440)
|
||||
XCTAssertEqual(b.width, 2560)
|
||||
XCTAssertEqual(b.height, 1440)
|
||||
// Tiny / zero clamp to the host floor.
|
||||
let c = MatchWindow.normalize(widthPx: 100, heightPx: 80)
|
||||
XCTAssertEqual(c.width, 320)
|
||||
XCTAssertEqual(c.height, 200)
|
||||
let z = MatchWindow.normalize(widthPx: 0, heightPx: -4)
|
||||
XCTAssertEqual(z.width, 320)
|
||||
XCTAssertEqual(z.height, 200)
|
||||
}
|
||||
|
||||
func testRequestSkipsEqualAndAlreadyRequested() {
|
||||
// A new size (different from the live mode, not yet requested) → request it.
|
||||
let r = MatchWindow.request(
|
||||
target: (1000, 600), current: (1280, 720), lastRequested: (800, 500))
|
||||
XCTAssertEqual(r?.width, 1000)
|
||||
XCTAssertEqual(r?.height, 600)
|
||||
// Equal to the live mode → nothing to do.
|
||||
XCTAssertNil(MatchWindow.request(
|
||||
target: (1280, 720), current: (1280, 720), lastRequested: nil))
|
||||
// Already requested once → don't re-ask (covers a rejected size AND a host rollback:
|
||||
// accepted → rebuild failed → corrective ack restored the old mode must not loop).
|
||||
XCTAssertNil(MatchWindow.request(
|
||||
target: (1000, 600), current: (1280, 720), lastRequested: (1000, 600)))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class ResizeIndicatorTests: XCTestCase {
|
||||
func testInactiveUntilSteered() {
|
||||
var r = ResizeIndicator()
|
||||
XCTAssertFalse(r.active)
|
||||
// A decoded frame with nothing pending is a no-op (session start / steady state).
|
||||
r.decoded(width: 1920, height: 1080)
|
||||
XCTAssertFalse(r.active)
|
||||
}
|
||||
|
||||
func testSteeringActivatesAndDecodedTargetClears() {
|
||||
var r = ResizeIndicator()
|
||||
r.steering(width: 2560, height: 1440, now: 0)
|
||||
XCTAssertTrue(r.active)
|
||||
// A frame at a DIFFERENT size (the old mode still draining) doesn't clear it.
|
||||
r.decoded(width: 1920, height: 1080)
|
||||
XCTAssertTrue(r.active)
|
||||
// The target frame lands → clear.
|
||||
r.decoded(width: 2560, height: 1440)
|
||||
XCTAssertFalse(r.active)
|
||||
}
|
||||
|
||||
func testTimeoutClearsWhenTargetNeverArrives() {
|
||||
var r = ResizeIndicator(timeout: 2.5)
|
||||
r.steering(width: 2560, height: 1440, now: 10)
|
||||
r.tick(now: 12) // 2 s < timeout — still up
|
||||
XCTAssertTrue(r.active)
|
||||
r.tick(now: 12.6) // 2.6 s ≥ timeout — a rejected/capped switch clears
|
||||
XCTAssertFalse(r.active)
|
||||
}
|
||||
|
||||
func testDragReArmsTimeoutOnEachNewTarget() {
|
||||
var r = ResizeIndicator(timeout: 2.5)
|
||||
r.steering(width: 2000, height: 1200, now: 0)
|
||||
r.steering(width: 2200, height: 1200, now: 2) // target changed → since re-armed to 2
|
||||
r.tick(now: 4) // only 2 s since the last change — still up (drag isn't a timeout)
|
||||
XCTAssertTrue(r.active)
|
||||
r.tick(now: 4.6) // 2.6 s since the last change → clears
|
||||
XCTAssertFalse(r.active)
|
||||
}
|
||||
|
||||
func testSteadyDragDoesNotResetTimeout() {
|
||||
var r = ResizeIndicator(timeout: 2.5)
|
||||
r.steering(width: 2560, height: 1440, now: 0)
|
||||
r.steering(width: 2560, height: 1440, now: 1) // SAME target → since stays 0
|
||||
r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer → clears (not reset by the repeat)
|
||||
XCTAssertFalse(r.active)
|
||||
}
|
||||
}
|
||||
@@ -264,23 +264,21 @@ pub fn show(
|
||||
let page = adw::PreferencesPage::new();
|
||||
|
||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
// `match_window` flag), then the explicit sizes.
|
||||
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||
.chain(std::iter::once("Match window".to_string()))
|
||||
.chain(
|
||||
RESOLUTIONS
|
||||
let res_names: Vec<String> = RESOLUTIONS
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|&(w, h)| format!("{w} × {h}")),
|
||||
)
|
||||
.map(|&(w, h)| {
|
||||
if w == 0 {
|
||||
"Native display".to_string()
|
||||
} else {
|
||||
format!("{w} × {h}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let res_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Resolution",
|
||||
"The host creates a virtual output at exactly this size — Match window follows \
|
||||
the stream window, including mid-stream resizes",
|
||||
"The host creates a virtual output at exactly this size",
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let hz_names: Vec<String> = REFRESH
|
||||
@@ -472,15 +470,10 @@ pub fn show(
|
||||
// Seed from the current settings.
|
||||
{
|
||||
let s = settings.borrow();
|
||||
let res_i = if s.match_window {
|
||||
1
|
||||
} else {
|
||||
RESOLUTIONS
|
||||
let res_i = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|&(w, h)| w == s.width && h == s.height)
|
||||
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||
.unwrap_or(0)
|
||||
};
|
||||
.unwrap_or(0);
|
||||
res_row.set_selected(res_i as u32);
|
||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||
hz_row.set_selected(hz_i as u32);
|
||||
@@ -515,14 +508,8 @@ pub fn show(
|
||||
dialog.add(&page);
|
||||
dialog.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
|
||||
s.match_window = res_i == 1;
|
||||
(s.width, s.height) = if res_i <= 1 {
|
||||
(0, 0)
|
||||
} else {
|
||||
RESOLUTIONS[res_i - 1]
|
||||
};
|
||||
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
||||
(s.width, s.height) = (w, h);
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
|
||||
@@ -145,10 +145,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
window_size: crate::session_main::window_size(&settings_at_start),
|
||||
// Latched at console start (like the stats tier above): toggling Match window in
|
||||
// the console's settings screen applies from the next console launch.
|
||||
match_window: crate::session_main::match_window(&settings_at_start),
|
||||
};
|
||||
|
||||
let result =
|
||||
|
||||
@@ -164,34 +164,6 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's starting size under Match-window: the persisted last size, so the
|
||||
/// first connect's mode already matches the glass; `None` (policy off / never
|
||||
/// stored) = the 1280×720 default.
|
||||
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
|
||||
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
|
||||
.then_some((settings.last_window_w, settings.last_window_h))
|
||||
}
|
||||
|
||||
/// The Match-window policy hook for the presenter loop
|
||||
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
|
||||
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||
/// next launch opens at it.
|
||||
pub(crate) fn match_window(
|
||||
settings: &trust::Settings,
|
||||
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||
settings.match_window.then(|| {
|
||||
Box::new(|w: u32, h: u32| {
|
||||
let mut s = trust::Settings::load();
|
||||
if (s.last_window_w, s.last_window_h) != (w, h) {
|
||||
s.last_window_w = w;
|
||||
s.last_window_h = h;
|
||||
s.save();
|
||||
}
|
||||
}) as Box<dyn FnMut(u32, u32)>
|
||||
})
|
||||
}
|
||||
|
||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||
/// failure through the same contract when spawned with `--json-status`.
|
||||
@@ -371,8 +343,6 @@ mod session_main {
|
||||
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||
#[cfg(not(feature = "ui"))]
|
||||
overlay: None,
|
||||
window_size: window_size(&settings),
|
||||
match_window: match_window(&settings),
|
||||
};
|
||||
|
||||
let outcome =
|
||||
|
||||
@@ -136,37 +136,29 @@ pub(crate) fn settings_page(
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
|
||||
// --- Display ---------------------------------------------------------------------------
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
// `match_window` flag), then the explicit sizes.
|
||||
let (res_names, res_i) = {
|
||||
let names: Vec<String> = std::iter::once("Native display".to_string())
|
||||
.chain(std::iter::once("Match window".to_string()))
|
||||
.chain(
|
||||
RESOLUTIONS
|
||||
let names: Vec<String> = RESOLUTIONS
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|&(w, h)| format!("{w} \u{00D7} {h}")),
|
||||
)
|
||||
.collect();
|
||||
let i = if s.match_window {
|
||||
1
|
||||
.map(|&(w, h)| {
|
||||
if w == 0 {
|
||||
"Native display".into()
|
||||
} else {
|
||||
RESOLUTIONS
|
||||
format!("{w} \u{00D7} {h}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|&(w, h)| w == s.width && h == s.height)
|
||||
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||
.unwrap_or(0)
|
||||
};
|
||||
.unwrap_or(0);
|
||||
(names, i)
|
||||
};
|
||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
(s.width, s.height) = RESOLUTIONS[i];
|
||||
})
|
||||
.tooltip(
|
||||
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
||||
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
|
||||
follows the stream window, including mid-stream resizes.",
|
||||
resolves to the monitor this window is on at connect.",
|
||||
);
|
||||
let (hz_names, hz_i) = {
|
||||
let names: Vec<String> = REFRESH
|
||||
|
||||
@@ -406,19 +406,6 @@ pub struct Settings {
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
/// (`Reconfigure`), so windowed sessions stream native-resolution pixels instead of
|
||||
/// scaling. Overrides `width`/`height` while on; on fullscreen it degenerates to the
|
||||
/// display's native mode. Default off (Auto-native stays the shipped default until
|
||||
/// the per-backend validation matrix is green).
|
||||
pub match_window: bool,
|
||||
/// The session window's last logical size under `match_window`: the next launch
|
||||
/// opens its window at this size, so the first connect's mode already matches what
|
||||
/// the user will be looking at. `0` = never stored → the 1280×720 default.
|
||||
pub last_window_w: u32,
|
||||
pub last_window_h: u32,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -479,9 +466,6 @@ impl Default for Settings {
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,9 +176,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
"Resolution",
|
||||
if s.match_window {
|
||||
"Match window".into()
|
||||
} else if s.width == 0 {
|
||||
if s.width == 0 {
|
||||
"Native".into()
|
||||
} else {
|
||||
format!("{} × {}", s.width, s.height)
|
||||
@@ -261,8 +259,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
fn detail(id: RowId) -> &'static str {
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling. \
|
||||
Match window follows this window, including mid-stream resizes."
|
||||
"The host creates a virtual display at exactly this size — no scaling."
|
||||
}
|
||||
RowId::Refresh => "Native follows the display this window is on.",
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
@@ -306,20 +303,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let s = &mut *ctx.settings;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
// The D1 tri-state as one picker: Native, Match window, then the explicit
|
||||
// sizes (RESOLUTIONS keeps its (0,0) = Native head; Match window is the
|
||||
// virtual index 1, stored as the `match_window` flag with w/h cleared).
|
||||
let cur = if s.match_window {
|
||||
Some(1)
|
||||
} else {
|
||||
RESOLUTIONS
|
||||
let cur = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|(w, h)| (*w, *h) == (s.width, s.height))
|
||||
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||
};
|
||||
step_option(cur, RESOLUTIONS.len() + 1, delta, wrap).map(|i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
.position(|(w, h)| (*w, *h) == (s.width, s.height));
|
||||
step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| {
|
||||
(s.width, s.height) = RESOLUTIONS[i];
|
||||
})
|
||||
}
|
||||
RowId::Refresh => {
|
||||
@@ -413,26 +401,14 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Resolution starts at Native (index 0): left refuses, right steps — first onto
|
||||
// Match window (the D1 tri-state's middle option), then the explicit sizes.
|
||||
// Resolution starts at Native (index 0): left refuses, right steps.
|
||||
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||
assert!(ctx.settings.match_window, "Native → Match window");
|
||||
assert_eq!((ctx.settings.width, ctx.settings.height), (0, 0));
|
||||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||
assert!(!ctx.settings.match_window, "explicit size clears the policy");
|
||||
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
||||
// Stepping back from an explicit size returns to Match window, then Native.
|
||||
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||
assert!(ctx.settings.match_window);
|
||||
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||
assert!(!ctx.settings.match_window);
|
||||
assert_eq!(ctx.settings.width, 0, "back to Native");
|
||||
// Cycle from the last option wraps to the first.
|
||||
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
||||
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
||||
assert!(!ctx.settings.match_window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,18 +52,6 @@ pub struct SessionOpts {
|
||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||
/// warning rather than killing the session. Browse mode requires one.
|
||||
pub overlay: Option<Box<dyn Overlay>>,
|
||||
/// The window's starting logical size; `None` = the 1280×720 default. The binary
|
||||
/// passes the persisted last-window size under the Match-window policy so the first
|
||||
/// connect's mode already matches what the user will be looking at.
|
||||
pub window_size: Option<(u32, u32)>,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2):
|
||||
/// `Some` = the stream mode follows the window. At session start the params' mode
|
||||
/// w/h are replaced by the window's physical pixel size; a mid-session resize sends
|
||||
/// a debounced `Reconfigure` so the host's virtual display + encoder follow. The
|
||||
/// callback receives the window's logical size at each resize-end — the binary
|
||||
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
|
||||
/// Explicit keep today's behavior).
|
||||
pub match_window: Option<Box<dyn FnMut(u32, u32)>>,
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
@@ -185,22 +173,6 @@ struct StreamState {
|
||||
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
||||
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||
last_stats: Option<Stats>,
|
||||
/// Match-window (D2) debounce state: the last resize event's stamp. `Some` = a
|
||||
/// resize is pending; the tick fires the request once ~400 ms pass with no further
|
||||
/// size events (never per drag-frame — each accepted switch is a full host rebuild).
|
||||
resize_pending: Option<Instant>,
|
||||
/// When the last `Reconfigure` was sent — the ≥ 1 s spacing between requests (D2).
|
||||
/// The accept ack round-trips in milliseconds (it precedes the host's rebuild), so
|
||||
/// this spacing also serializes: at most ~one request is ever outstanding.
|
||||
resize_sent_at: Option<Instant>,
|
||||
/// The last size actually requested. Each distinct size is requested at most once:
|
||||
/// this both implements "don't re-request a rejected size until it changes" (D2) and
|
||||
/// keeps a host-side rollback (accept ack, rebuild failed, corrective ack restored
|
||||
/// the old mode) from looping request → rollback → request forever.
|
||||
resize_requested: Option<(u32, u32)>,
|
||||
/// The connector mode last shown in the HUD/title — a change (an accepted switch's
|
||||
/// ack, or a corrective rollback) refreshes both.
|
||||
shown_mode: Option<Mode>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -245,10 +217,6 @@ impl StreamState {
|
||||
hw_fails: 0,
|
||||
osd_text: String::new(),
|
||||
last_stats: None,
|
||||
resize_pending: None,
|
||||
resize_sent_at: None,
|
||||
resize_requested: None,
|
||||
shown_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,10 +270,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.register_custom_event::<FrameWake>()
|
||||
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
||||
let mut window = {
|
||||
// Match-window (D1): open at the persisted last size, so the first connect's
|
||||
// mode already matches the glass. 1280×720 stays the fallback/default.
|
||||
let (ww, wh) = opts.window_size.unwrap_or((1280, 720));
|
||||
let mut b = video.window(&opts.window_title, ww.max(320), wh.max(200));
|
||||
let mut b = video.window(&opts.window_title, 1280, 720);
|
||||
match opts.window_pos {
|
||||
Some((x, y)) => b.position(x, y),
|
||||
None => b.position_centered(),
|
||||
@@ -375,15 +340,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let mut stream: Option<StreamState> = match &mut mode {
|
||||
ModeCtl::Single(build) => {
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
let mut params = build(
|
||||
let params = build(
|
||||
&gamepad,
|
||||
native,
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
);
|
||||
if opts.match_window.is_some() {
|
||||
apply_match_window(&mut params, &window);
|
||||
}
|
||||
Some(StreamState::new(
|
||||
params,
|
||||
force_software,
|
||||
@@ -461,14 +423,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
||||
presenter.recreate_swapchain(&window)?;
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
// Match-window (D2): (re)stamp the debounce — the request fires
|
||||
// once ~400 ms pass with no further size events, never per
|
||||
// drag-frame (each accepted switch is a full host rebuild).
|
||||
if opts.match_window.is_some() {
|
||||
if let Some(st) = stream.as_mut() {
|
||||
st.resize_pending = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::Exposed => {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
@@ -662,10 +616,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
presenter.vulkan_decode(),
|
||||
) {
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(mut params) => {
|
||||
if opts.match_window.is_some() {
|
||||
apply_match_window(&mut params, &window);
|
||||
}
|
||||
ActionOutcome::Start(params) => {
|
||||
stream = Some(StreamState::new(
|
||||
*params,
|
||||
force_software,
|
||||
@@ -802,13 +753,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
|
||||
// --- Match-window (D2): debounced mode-follow + HUD/title refresh on a switch ----
|
||||
if let Some(persist) = opts.match_window.as_mut() {
|
||||
if let Some(st) = stream.as_mut() {
|
||||
resize_tick(st, &mut window, &opts.window_title, persist.as_mut());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
@@ -1069,125 +1013,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Match-window (D1): replace the params' requested w/h with the window's physical pixel
|
||||
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
|
||||
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||
/// display, so this degenerates to the display's native mode.
|
||||
fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window) {
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
params.mode.width = (pw & !1).max(320);
|
||||
params.mode.height = (ph & !1).max(200);
|
||||
tracing::info!(
|
||||
w = params.mode.width,
|
||||
h = params.mode.height,
|
||||
"match-window: requesting the window's pixel size"
|
||||
);
|
||||
}
|
||||
|
||||
/// Match-window (D2) per-iteration tick: refresh the HUD line + window title when the
|
||||
/// live mode moves (an accepted switch's ack, or a corrective rollback), then fire the
|
||||
/// debounced `Reconfigure` once ~400 ms pass with no further resize events. The shared
|
||||
/// trigger discipline:
|
||||
/// * physical pixels, even-floored, clamped ≥ 320×200; the current refresh is kept;
|
||||
/// * ≥ 1 s between requests (the accept ack round-trips in milliseconds — it precedes
|
||||
/// the host's rebuild — so the spacing also keeps at most ~one request outstanding);
|
||||
/// * each distinct size is requested at most ONCE (`resize_requested`): a rejected
|
||||
/// size isn't re-asked until the window changes, and a host-side rollback (accepted,
|
||||
/// rebuild failed, corrective ack restored the old mode) can't loop.
|
||||
fn resize_tick(
|
||||
st: &mut StreamState,
|
||||
window: &mut sdl3::video::Window,
|
||||
title_base: &str,
|
||||
persist: &mut dyn FnMut(u32, u32),
|
||||
) {
|
||||
let Some(c) = &st.connector else {
|
||||
return; // not connected yet — the pending stamp survives until we are
|
||||
};
|
||||
// HUD/title follow the live mode slot (updated by any accepted ack).
|
||||
let m = c.mode();
|
||||
if st.shown_mode.is_some_and(|prev| prev != m) {
|
||||
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||
tracing::info!(mode = %st.mode_line, "stream mode switched");
|
||||
let _ = window.set_title(&format!("{title_base} · {}", st.mode_line));
|
||||
}
|
||||
st.shown_mode = Some(m);
|
||||
|
||||
match resize_decision(
|
||||
Instant::now(),
|
||||
&mut st.resize_pending,
|
||||
st.resize_sent_at,
|
||||
st.resize_requested,
|
||||
(m.width, m.height),
|
||||
window.size_in_pixels(),
|
||||
) {
|
||||
ResizeAction::Wait => {}
|
||||
ResizeAction::Settled(target) => {
|
||||
// The debounce settled: persist the window's LOGICAL size for the next
|
||||
// launch (its window is created in logical units) even when no request goes
|
||||
// out (e.g. resized back to the streamed size).
|
||||
let (lw, lh) = window.size();
|
||||
persist(lw, lh);
|
||||
let Some((w, h)) = target else { return };
|
||||
tracing::info!(w, h, "window resized — requesting mode switch");
|
||||
if c
|
||||
.request_mode(Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh_hz: m.refresh_hz,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("mode-switch request dropped — control channel closed");
|
||||
}
|
||||
st.resize_requested = Some((w, h));
|
||||
st.resize_sent_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one [`resize_decision`] tick decided.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ResizeAction {
|
||||
/// Nothing to do yet (no resize pending, still debouncing, or spacing defers — the
|
||||
/// pending stamp is kept so a later tick retries).
|
||||
Wait,
|
||||
/// The debounce settled (pending cleared, the caller persists the window size), with
|
||||
/// the mode to request — `None` when the size needs no switch (equal to the streamed
|
||||
/// mode, or this exact size was already requested once).
|
||||
Settled(Option<(u32, u32)>),
|
||||
}
|
||||
|
||||
/// The D2 trigger discipline as a pure decision (unit-tested — CI can't open windows):
|
||||
/// debounce to resize-end, ≥ 1 s between requests, physical pixels even-floored and
|
||||
/// clamped ≥ 320×200, skip when equal to the streamed mode, and each distinct size
|
||||
/// requested at most once (covers rejected sizes AND host-side rollbacks).
|
||||
fn resize_decision(
|
||||
now: Instant,
|
||||
pending: &mut Option<Instant>,
|
||||
sent_at: Option<Instant>,
|
||||
requested: Option<(u32, u32)>,
|
||||
current: (u32, u32),
|
||||
pixel_size: (u32, u32),
|
||||
) -> ResizeAction {
|
||||
const DEBOUNCE: Duration = Duration::from_millis(400);
|
||||
const SPACING: Duration = Duration::from_secs(1);
|
||||
let Some(since) = *pending else {
|
||||
return ResizeAction::Wait;
|
||||
};
|
||||
if now.duration_since(since) < DEBOUNCE {
|
||||
return ResizeAction::Wait;
|
||||
}
|
||||
if sent_at.is_some_and(|at| now.duration_since(at) < SPACING) {
|
||||
return ResizeAction::Wait; // keep the pending stamp — a later tick retries
|
||||
}
|
||||
*pending = None;
|
||||
let target = ((pixel_size.0 & !1).max(320), (pixel_size.1 & !1).max(200));
|
||||
if current == target || requested == Some(target) {
|
||||
return ResizeAction::Settled(None);
|
||||
}
|
||||
ResizeAction::Settled(Some(target))
|
||||
}
|
||||
|
||||
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
|
||||
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
|
||||
/// the host while captured instead of the local shell. SDL implements the grab there
|
||||
@@ -1290,105 +1115,6 @@ fn stats_text(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resize_decision_follows_the_d2_discipline() {
|
||||
let t0 = Instant::now();
|
||||
let ms = Duration::from_millis;
|
||||
|
||||
// No resize pending → nothing to do.
|
||||
let mut pending = None;
|
||||
assert_eq!(
|
||||
resize_decision(t0, &mut pending, None, None, (1280, 720), (1000, 600)),
|
||||
ResizeAction::Wait
|
||||
);
|
||||
|
||||
// Still debouncing (a drag in progress) → wait, pending kept.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(
|
||||
t0 + ms(399),
|
||||
&mut pending,
|
||||
None,
|
||||
None,
|
||||
(1280, 720),
|
||||
(1000, 600)
|
||||
),
|
||||
ResizeAction::Wait
|
||||
);
|
||||
assert!(pending.is_some(), "pending survives the wait");
|
||||
|
||||
// Debounce settled → request the even-floored, clamped pixel size.
|
||||
assert_eq!(
|
||||
resize_decision(
|
||||
t0 + ms(400),
|
||||
&mut pending,
|
||||
None,
|
||||
None,
|
||||
(1280, 720),
|
||||
(1001, 601)
|
||||
),
|
||||
ResizeAction::Settled(Some((1000, 600))),
|
||||
"odd pixels floor to even"
|
||||
);
|
||||
assert!(pending.is_none(), "pending consumed");
|
||||
|
||||
// Spacing: a request went out < 1 s ago → wait WITHOUT dropping the pending
|
||||
// stamp, so a later tick retries.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(
|
||||
t0 + ms(900),
|
||||
&mut pending,
|
||||
Some(t0),
|
||||
Some((1000, 600)),
|
||||
(1280, 720),
|
||||
(800, 500)
|
||||
),
|
||||
ResizeAction::Wait
|
||||
);
|
||||
assert!(pending.is_some());
|
||||
assert_eq!(
|
||||
resize_decision(
|
||||
t0 + ms(1000),
|
||||
&mut pending,
|
||||
Some(t0),
|
||||
Some((1000, 600)),
|
||||
(1280, 720),
|
||||
(800, 500)
|
||||
),
|
||||
ResizeAction::Settled(Some((800, 500)))
|
||||
);
|
||||
|
||||
// Equal to the streamed mode → settle (persist) but no request.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (1280, 720)),
|
||||
ResizeAction::Settled(None)
|
||||
);
|
||||
|
||||
// A size already requested once (rejected, or rolled back host-side) is never
|
||||
// re-asked — no request → rollback → request loop.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(
|
||||
t0 + ms(400),
|
||||
&mut pending,
|
||||
None,
|
||||
Some((1000, 600)),
|
||||
(1280, 720),
|
||||
(1000, 600)
|
||||
),
|
||||
ResizeAction::Settled(None)
|
||||
);
|
||||
|
||||
// Tiny windows clamp to the host's floor.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (100, 80)),
|
||||
ResizeAction::Settled(Some((320, 200)))
|
||||
);
|
||||
}
|
||||
|
||||
fn sample() -> (Stats, PresentedWindow) {
|
||||
(
|
||||
Stats {
|
||||
|
||||
@@ -39,7 +39,7 @@ use punktfunk_core::quic::{
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::Session;
|
||||
use rand::RngCore;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -1085,24 +1085,6 @@ async fn serve_session(
|
||||
.await
|
||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||
let (mut ctrl_send, mut ctrl_recv) = (send, recv);
|
||||
// Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for:
|
||||
// * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's
|
||||
// game-mode session, attach doesn't own the display — a resize must never relaunch the title
|
||||
// (design/midstream-resolution-resize.md H1/D3). The client keeps scaling client-side.
|
||||
// * an `identity: per-client-mode` policy: the mode is part of the display-identity slot key,
|
||||
// so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the
|
||||
// in-place reconfigure, on KWin a differently-named output — defeating the policy's
|
||||
// per-resolution identity. Honest downgrade: reject, client scales (H5).
|
||||
// The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip
|
||||
// is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip
|
||||
// test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it.
|
||||
// Captured once at session setup; the control task answers `accepted: false` when gated.
|
||||
let live_reconfig_ok = {
|
||||
let per_client_mode_identity = crate::vdisplay::policy::prefs()
|
||||
.configured_effective()
|
||||
.is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode);
|
||||
reconfig_allowed(compositor, per_client_mode_identity)
|
||||
};
|
||||
// Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's
|
||||
// `async move` captures a copy and it stays usable for the data-plane SessionContext below.
|
||||
let codec = crate::encode::Codec::from_wire(welcome.codec);
|
||||
@@ -1128,13 +1110,6 @@ async fn serve_session(
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, mut probe_result_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
// ack is written BEFORE the rebuild, so a failed rebuild (host stays at the old mode) or a
|
||||
// backend that honored a different refresh must CORRECT the client's mode slot with a second
|
||||
// `Reconfigured { accepted: true, mode: <actually live> }` — the client handler treats any
|
||||
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
|
||||
let (reconfig_result_tx, mut reconfig_result_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
|
||||
// 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
|
||||
// PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report.
|
||||
@@ -1143,46 +1118,23 @@ async fn serve_session(
|
||||
let fec_target_ctl = fec_target.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut active = hello.mode;
|
||||
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
|
||||
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
let mut last_accepted_switch: Option<std::time::Instant> = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = io::read_msg(&mut ctrl_recv) => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(req) = Reconfigure::decode(&msg) {
|
||||
let now = std::time::Instant::now();
|
||||
let valid = req.mode.refresh_hz > 0
|
||||
let ok = req.mode.refresh_hz > 0
|
||||
&& crate::encode::validate_dimensions(
|
||||
codec,
|
||||
req.mode.width,
|
||||
req.mode.height,
|
||||
)
|
||||
.is_ok();
|
||||
let too_soon = last_accepted_switch
|
||||
.is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL);
|
||||
let ok = if !live_reconfig_ok {
|
||||
// Backend can't live-reconfigure (gamescope / synthetic /
|
||||
// per-client-mode identity — see the gate above): honest downgrade,
|
||||
// the client keeps scaling client-side.
|
||||
tracing::info!(mode = ?req.mode,
|
||||
"mode switch rejected (backend cannot live-reconfigure)");
|
||||
false
|
||||
} else if !valid {
|
||||
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
||||
false
|
||||
} else if too_soon {
|
||||
tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)");
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if ok {
|
||||
active = req.mode;
|
||||
last_accepted_switch = Some(now);
|
||||
tracing::info!(mode = ?req.mode, "mode switch accepted");
|
||||
} else {
|
||||
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
||||
}
|
||||
let ack = Reconfigured { accepted: ok, mode: active };
|
||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||
@@ -1278,17 +1230,6 @@ async fn serve_session(
|
||||
break;
|
||||
}
|
||||
}
|
||||
correction = reconfig_result_rx.recv() => {
|
||||
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||
// honored at a different refresh. Track it so a later rejection's
|
||||
// `mode: active` echo is truthful too.
|
||||
let Some(ack) = correction else { break }; // data plane gone
|
||||
active = ack.mode;
|
||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1598,7 +1539,6 @@ async fn serve_session(
|
||||
codec,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
fec_target: fec_target_dp,
|
||||
conn: conn_stream,
|
||||
timing_conn,
|
||||
@@ -2987,10 +2927,9 @@ pub(crate) fn boost_thread_priority(critical: bool) {
|
||||
/// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration.
|
||||
struct SendStats {
|
||||
rec: Arc<StatsRecorder>,
|
||||
/// Live session mode, packed w:16|h:16|hz:16 ([`pack_mode`]) — the capture thread updates it
|
||||
/// on an accepted mid-stream mode switch (mirroring `bitrate_kbps` below), so a stats capture
|
||||
/// registers the mode the stream is ACTUALLY running at, not the session-start latch (H3).
|
||||
mode: Arc<AtomicU64>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
codec: &'static str,
|
||||
client: String,
|
||||
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
|
||||
@@ -2998,69 +2937,6 @@ struct SendStats {
|
||||
bitrate_kbps: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// Pack a `(width, height, refresh_hz)` mode into one atomic word (w:16|h:16|hz:16) for the live
|
||||
/// stats-mode slot — one store/load instead of three racy ones. Every dimension fits: the codec
|
||||
/// max dimension caps w/h well under 2^16 (`validate_dimensions`), refresh likewise.
|
||||
fn pack_mode(width: u32, height: u32, refresh_hz: u32) -> u64 {
|
||||
((width as u64 & 0xffff) << 32)
|
||||
| ((height as u64 & 0xffff) << 16)
|
||||
| (refresh_hz as u64 & 0xffff)
|
||||
}
|
||||
|
||||
/// Unpack a [`pack_mode`] word back into `(width, height, refresh_hz)`.
|
||||
fn unpack_mode(packed: u64) -> (u32, u32, u32) {
|
||||
(
|
||||
((packed >> 32) & 0xffff) as u32,
|
||||
((packed >> 16) & 0xffff) as u32,
|
||||
(packed & 0xffff) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Recover the integer refresh rate a pipeline was actually built at from its frame interval
|
||||
/// (`interval` is constructed as `1/effective_hz` in `build_pipeline`, so the round-trip is exact).
|
||||
/// This is the backend-honored rate — it differs from the requested mode when e.g. KWin caps a
|
||||
/// virtual output at 60 Hz.
|
||||
fn interval_hz(interval: std::time::Duration) -> u32 {
|
||||
(1.0 / interval.as_secs_f64()).round() as u32
|
||||
}
|
||||
|
||||
/// The mode a pipeline is ACTUALLY delivering, for the H2/H3 corrective ack: the captured frame's
|
||||
/// real dimensions (`build_pipeline` opens the encoder at `frame.{width,height}`, so this is exactly
|
||||
/// what the client decodes) paced at the rate the pipeline achieved ([`interval_hz`]). It diverges
|
||||
/// from the requested mode when a backend can't honor it: KWin caps a virtual output's refresh, or —
|
||||
/// the case this exists for — Windows pf-vdisplay rejects an in-place `SetMode` to a resolution not
|
||||
/// in the running monitor's advertised EDID list and the host falls back to the actual display mode
|
||||
/// (`capture::idd_push`: "sizing the ring to the display's actual mode"). Comparing this against the
|
||||
/// already-acked request decides whether a corrective `Reconfigured` ack is owed so the client
|
||||
/// doesn't believe it got a resolution it never received.
|
||||
fn delivered_mode(
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
interval: std::time::Duration,
|
||||
) -> punktfunk_core::Mode {
|
||||
punktfunk_core::Mode {
|
||||
width: frame_width,
|
||||
height: frame_height,
|
||||
refresh_hz: interval_hz(interval),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode`
|
||||
/// identity policy may LIVE-reconfigure — accept a mid-stream `Reconfigure`
|
||||
/// (design/midstream-resolution-resize.md H1/H5). Gated OFF for:
|
||||
/// * **gamescope** (every sub-mode): a resize would respawn the nested game / restart the box's
|
||||
/// game-mode session — it must never relaunch the title, so the client keeps scaling client-side.
|
||||
/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a
|
||||
/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output),
|
||||
/// defeating the policy — honest downgrade is to reject and let the client scale.
|
||||
/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts.
|
||||
fn reconfig_allowed(
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
per_client_mode: bool,
|
||||
) -> bool {
|
||||
compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn send_loop(
|
||||
mut session: Session,
|
||||
@@ -3199,12 +3075,14 @@ fn send_loop(
|
||||
// window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot.
|
||||
if stats.rec.is_armed() {
|
||||
let session_id = *sid.get_or_insert_with(|| {
|
||||
// Read the LIVE mode at registration time (H3): a capture armed after a
|
||||
// mid-stream mode switch gets the mode the stream actually runs at.
|
||||
let (w, h, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed));
|
||||
stats
|
||||
.rec
|
||||
.register_session("native", w, h, hz, stats.codec, &stats.client)
|
||||
stats.rec.register_session(
|
||||
"native",
|
||||
stats.width,
|
||||
stats.height,
|
||||
stats.fps,
|
||||
stats.codec,
|
||||
&stats.client,
|
||||
)
|
||||
});
|
||||
let sample = crate::stats_recorder::StatsSample {
|
||||
t_ms: 0, // stamped by push_sample from the capture's monotonic start
|
||||
@@ -3415,10 +3293,6 @@ struct SessionContext {
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
/// Speed-test results back to the control task.
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
/// Mode-switch outcomes back to the control task (H2): a corrective
|
||||
/// `Reconfigured { accepted: true, mode: <actually live> }` when a rebuild failed (stayed at
|
||||
/// the old mode) or the backend honored a different refresh than requested.
|
||||
reconfig_result_tx: tokio::sync::mpsc::UnboundedSender<Reconfigured>,
|
||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||
fec_target: Arc<AtomicU8>,
|
||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||
@@ -3477,7 +3351,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
codec: _,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
timing_conn,
|
||||
@@ -3536,7 +3409,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
);
|
||||
crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone())
|
||||
});
|
||||
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) =
|
||||
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?;
|
||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -3584,20 +3457,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
||||
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
||||
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
||||
// output at 60 Hz.
|
||||
let live_mode = Arc::new(AtomicU64::new(pack_mode(
|
||||
mode.width,
|
||||
mode.height,
|
||||
interval_hz(interval),
|
||||
)));
|
||||
// The send thread emits the web-console stats sample (it owns `session.stats()`); clone the
|
||||
// recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate.
|
||||
let send_stats = SendStats {
|
||||
rec: stats.clone(),
|
||||
mode: live_mode.clone(),
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
fps: mode.refresh_hz,
|
||||
codec: plan.codec.label(),
|
||||
client: client_label,
|
||||
bitrate_kbps: live_bitrate.clone(),
|
||||
@@ -3742,10 +3608,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
Ok((new_vd, pipe))
|
||||
})();
|
||||
match rebuilt {
|
||||
Ok((
|
||||
new_vd,
|
||||
(new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen),
|
||||
)) => {
|
||||
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => {
|
||||
// Replace the pipeline first (drops the old capturer → old PipeWire stream +
|
||||
// virtual output), then the factory (drops e.g. the old KWin connection).
|
||||
capturer = new_cap;
|
||||
@@ -3753,7 +3616,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
frame = new_frame;
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_gen;
|
||||
vd = new_vd;
|
||||
compositor = sw.compositor;
|
||||
next = std::time::Instant::now();
|
||||
@@ -3793,37 +3655,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// healthy session — keep streaming the current mode and log instead.
|
||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
|
||||
Ok(next_pipe) => {
|
||||
let old_display_gen = cur_display_gen;
|
||||
// The destructuring assignment drops the OLD capturer (→ its display lease) as
|
||||
// each binding is replaced — the new pipeline is already up (create-before-drop).
|
||||
(capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe;
|
||||
(capturer, enc, frame, interval, cur_node_id) = next_pipe;
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
// H4: the old display's lease drop above is indistinguishable from a disconnect
|
||||
// to the keep-alive machinery — under linger/forever policies every resize would
|
||||
// ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a
|
||||
// no-op when it was already torn down under `immediate`, or off Linux).
|
||||
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
|
||||
crate::vdisplay::registry::retire(g);
|
||||
}
|
||||
// H2/H3: the backend may have honored a different mode than requested — KWin
|
||||
// caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place
|
||||
// SetMode to a resolution its running monitor doesn't advertise and the host
|
||||
// falls back to the actual display mode. `frame` is the NEW pipeline's first
|
||||
// frame (just rebound above), so its dims are what the client actually decodes.
|
||||
// Publish that ACTUAL mode to the live stats slot, and correct the client's mode
|
||||
// slot when it differs from the accept ack it already got.
|
||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||
live_mode.store(
|
||||
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if actual != new_mode {
|
||||
let _ = reconfig_result_tx.send(Reconfigured {
|
||||
accepted: true,
|
||||
mode: actual,
|
||||
});
|
||||
}
|
||||
// The owed AUs died with the old encoder — drop their in-flight records
|
||||
// and restart the encode-stall clock for the fresh one.
|
||||
inflight.clear();
|
||||
@@ -3834,16 +3668,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
||||
"mode-switch rebuild failed — staying on the current mode");
|
||||
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
||||
// client's mode slot already flipped to `new_mode`. A second accepted ack
|
||||
// carrying the still-live mode corrects it (any accepted ack means "the active
|
||||
// mode is now X" client-side; old clients just log it). `frame` is untouched
|
||||
// here (the destructure only runs on the Ok arm), so it's still the OLD
|
||||
// pipeline's frame — its real dims + interval are exactly what's still on glass.
|
||||
let _ = reconfig_result_tx.send(Reconfigured {
|
||||
accepted: true,
|
||||
mode: delivered_mode(frame.width, frame.height, interval),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3858,7 +3682,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
}
|
||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer rate.
|
||||
let hz = interval_hz(interval);
|
||||
let hz = (1.0 / interval.as_secs_f64()).round() as u32;
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
@@ -4016,7 +3840,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// appears — no reconnect.
|
||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop {
|
||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||
if crate::config::config().compositor.is_none() {
|
||||
@@ -4076,7 +3900,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
frame = new_frame;
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_display_gen;
|
||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||
last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild
|
||||
next = std::time::Instant::now();
|
||||
@@ -4351,11 +4174,6 @@ type Pipeline = (
|
||||
// session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node
|
||||
// (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway.
|
||||
u32,
|
||||
// The display's registry pool generation (Linux keep-alive pool only; `None` on Windows — the
|
||||
// manager leases in place — and for non-poolable outputs). A mode-switch rebuild uses it to
|
||||
// `registry::retire` the superseded old display, so linger/forever keep-alive policies don't
|
||||
// accumulate kept monitors at stale modes (design/midstream-resolution-resize.md H4).
|
||||
Option<u64>,
|
||||
);
|
||||
|
||||
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
||||
@@ -4508,12 +4326,6 @@ fn build_pipeline(
|
||||
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
|
||||
#[cfg(target_os = "linux")]
|
||||
let reused_gen = vout.reused_gen;
|
||||
// The display's pool generation (fresh AND reused), threaded out so a mode-switch rebuild can
|
||||
// `registry::retire` the display this pipeline supersedes (H4). `None` off Linux / non-poolable.
|
||||
#[cfg(target_os = "linux")]
|
||||
let pool_gen = vout.pool_gen;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let pool_gen = None;
|
||||
// The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to
|
||||
// this session's own node). Read before `capture_virtual_output` consumes `vout`.
|
||||
let node_id = vout.node_id;
|
||||
@@ -4578,82 +4390,13 @@ fn build_pipeline(
|
||||
);
|
||||
}
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||
Ok((capturer, enc, frame, interval, node_id, pool_gen))
|
||||
Ok((capturer, enc, frame, interval, node_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn live_mode_pack_roundtrips_and_interval_recovers_hz() {
|
||||
// The live-stats mode slot (H3): pack → unpack is exact for real modes.
|
||||
for (w, h, hz) in [(1280u32, 720u32, 60u32), (3840, 2160, 144), (320, 200, 24)] {
|
||||
assert_eq!(unpack_mode(pack_mode(w, h, hz)), (w, h, hz));
|
||||
}
|
||||
// `interval` is built as 1/effective_hz — the round-trip recovers the integer rate.
|
||||
for hz in [24u32, 30, 60, 75, 90, 120, 144, 165, 240] {
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / hz as f64);
|
||||
assert_eq!(interval_hz(interval), hz);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_mode_reports_captured_dims_and_triggers_corrective_ack() {
|
||||
let hz60 = std::time::Duration::from_secs_f64(1.0 / 60.0);
|
||||
let requested = punktfunk_core::Mode {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
refresh_hz: 60,
|
||||
};
|
||||
|
||||
// Honored: the captured frame matches the request → no corrective ack owed (`== requested`).
|
||||
let honored = delivered_mode(2560, 1440, hz60);
|
||||
assert_eq!(honored, requested);
|
||||
|
||||
// Resolution fallback (Windows pf-vdisplay rejected the out-of-list SetMode, host stayed at
|
||||
// the actual display mode): the frame's real dims flow through, so the delivered mode differs
|
||||
// from the acked request and a corrective ack IS owed — the exact gap this fixes.
|
||||
let fell_back = delivered_mode(1920, 1080, hz60);
|
||||
assert_ne!(fell_back, requested);
|
||||
assert_eq!(
|
||||
fell_back,
|
||||
punktfunk_core::Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60
|
||||
}
|
||||
);
|
||||
|
||||
// Refresh cap (KWin) is still caught: same dims, achieved rate recovered from the interval.
|
||||
let capped = delivered_mode(2560, 1440, std::time::Duration::from_secs_f64(1.0 / 30.0));
|
||||
assert_ne!(capped, requested);
|
||||
assert_eq!(capped.refresh_hz, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfig_allowed_gates_gamescope_and_per_client_mode() {
|
||||
use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots};
|
||||
// gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of
|
||||
// the identity policy.
|
||||
assert!(!reconfig_allowed(Some(Gamescope), false));
|
||||
assert!(!reconfig_allowed(Some(Gamescope), true));
|
||||
// A per-client-mode identity policy rejects on every backend — the resize resolves a
|
||||
// different display-identity slot (H5).
|
||||
assert!(!reconfig_allowed(Some(Kwin), true));
|
||||
assert!(!reconfig_allowed(Some(Mutter), true));
|
||||
assert!(!reconfig_allowed(None, true));
|
||||
// Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place).
|
||||
for c in [Kwin, Mutter, Wlroots, Hyprland] {
|
||||
assert!(
|
||||
reconfig_allowed(Some(c), false),
|
||||
"{c:?} should allow live reconfigure"
|
||||
);
|
||||
}
|
||||
// The synthetic source (no compositor) is the protocol-test path — always reconfigurable.
|
||||
assert!(reconfig_allowed(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
|
||||
@@ -79,13 +79,6 @@ pub struct VirtualOutput {
|
||||
/// keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub reused_gen: Option<u64>,
|
||||
/// The registry pool generation of this display (fresh AND reused — unlike `reused_gen`), so a
|
||||
/// mid-stream mode-switch rebuild can [`registry::retire`](crate::vdisplay::registry::retire) the
|
||||
/// display it supersedes instead of leaving it to accumulate under a linger/forever keep-alive
|
||||
/// policy (`design/midstream-resolution-resize.md` H4). `None` for non-poolable outputs.
|
||||
/// Linux-only (the keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub pool_gen: Option<u64>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
@@ -107,8 +100,6 @@ impl VirtualOutput {
|
||||
ownership: DisplayOwnership::Owned,
|
||||
#[cfg(target_os = "linux")]
|
||||
reused_gen: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
pool_gen: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
keepalive: Box::new(()),
|
||||
ownership: DisplayOwnership::External,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
@@ -367,7 +366,6 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
|
||||
keepalive: Box::new(()),
|
||||
ownership: DisplayOwnership::SessionManaged,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,6 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
// `remote_fd.is_some()` — same as wlroots.
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,6 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,21 +176,6 @@ pub fn mark_failed(gen: u64) {
|
||||
let _ = gen;
|
||||
}
|
||||
|
||||
/// Force-release a **superseded** kept display by its generation stamp
|
||||
/// (`design/midstream-resolution-resize.md` H4): after a mid-stream mode-switch rebuild, the old
|
||||
/// display's lease drop is indistinguishable from a disconnect, so under a `linger`/`forever`
|
||||
/// keep-alive policy every resize would accumulate kept monitors at stale modes. The mode-switch
|
||||
/// arm calls this once the new pipeline is up and the old capturer is dropped. Only a KEPT
|
||||
/// (lingering/pinned) entry is released — an Active one is refused, like `/display/release` — and
|
||||
/// a gen that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
|
||||
/// reconfigures the same monitor in place — nothing is superseded).
|
||||
pub fn retire(gen: u64) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux::retire(gen);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = gen;
|
||||
}
|
||||
|
||||
/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch
|
||||
/// tore it down), so `/display/state` must stop listing it and its keepalive must be reaped
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` A4). Called from the session-switch watcher / a
|
||||
@@ -401,9 +386,6 @@ mod linux {
|
||||
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
|
||||
// `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(gen);
|
||||
// H4: every pooled display carries its gen, so a mode-switch rebuild can `retire` the entry
|
||||
// this output's successor supersedes.
|
||||
out.pool_gen = Some(gen);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -837,20 +819,6 @@ mod linux {
|
||||
}
|
||||
|
||||
pub(super) fn force_release(slot: Option<u64>) -> usize {
|
||||
release_kept(slot, "released (mgmt /display/release)")
|
||||
}
|
||||
|
||||
/// H4 — force-release a display superseded by a mid-stream mode switch. Same machinery as
|
||||
/// [`force_release`] (kept entries only — an Active entry is refused, and a gen already torn
|
||||
/// down under `immediate` is a no-op), distinct log line.
|
||||
pub(super) fn retire(gen: u64) {
|
||||
release_kept(Some(gen), "retired (superseded by a mode switch)");
|
||||
}
|
||||
|
||||
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by gen — running /
|
||||
/// handing off group topology restores, with keepalive drops outside the lock. The shared core
|
||||
/// of [`force_release`] (mgmt) and [`retire`] (mode-switch supersede).
|
||||
fn release_kept(slot: Option<u64>, why: &'static str) -> usize {
|
||||
let Some(r) = REG.get() else { return 0 };
|
||||
let (released, restores) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
@@ -879,7 +847,10 @@ mod linux {
|
||||
restore();
|
||||
}
|
||||
for e in released {
|
||||
tracing::info!(backend = e.backend, "virtual display {why}");
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display released (mgmt /display/release)"
|
||||
);
|
||||
drop(e);
|
||||
}
|
||||
n
|
||||
|
||||
@@ -557,68 +557,31 @@ impl VirtualDisplayManager {
|
||||
|
||||
// This slot already has a live monitor — join it (refcount++). Covers same-client concurrent
|
||||
// sessions AND the build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken
|
||||
// while the old is still held).
|
||||
if matches!(inner.slots.get(&slot), Some(SlotState::Active { .. })) {
|
||||
// A DIFFERENT mode is a mid-stream resize (Reconfigure). The pf-vdisplay driver freezes its
|
||||
// advertised mode list at ADD time, so we can't reach an arbitrary new mode in place — RE-
|
||||
// ARRIVE the monitor at the exact mode instead (Fix 1). Own the slot for the swap: `re_add`
|
||||
// needs `&mut inner` for the topology re-isolate, which the borrowed `mon` would block.
|
||||
let cur_mode = match inner.slots.get(&slot) {
|
||||
Some(SlotState::Active { mon, .. }) => mon.mode,
|
||||
_ => unreachable!("just matched Active"),
|
||||
};
|
||||
if cur_mode != mode {
|
||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
|
||||
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
|
||||
let new_mon =
|
||||
match unsafe { self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr) } {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// The re-arrival failed — put the OLD monitor back so the session keeps
|
||||
// streaming its current mode (the control task already acked the switch; the
|
||||
// rebuild reuses the old target and Fix 2's corrective ack tells the client the
|
||||
// resolution didn't change). Its `gen`/`refs` are intact, so leases stay valid.
|
||||
inner.slots.insert(slot, SlotState::Active { mon, refs });
|
||||
return Err(e).context("mid-stream resize re-arrival");
|
||||
}
|
||||
};
|
||||
// `re_add` preserved `gen`, so both the old session's lease and this new one match on
|
||||
// release. +1 ref for the new (build-then-drop overlap) lease.
|
||||
let out = self.output_for(slot, &new_mon, quit);
|
||||
inner.slots.insert(
|
||||
slot,
|
||||
SlotState::Active {
|
||||
mon: new_mon,
|
||||
refs: refs + 1,
|
||||
},
|
||||
);
|
||||
// The width changed — re-arrange the group so auto-row siblings don't overlap the
|
||||
// resized display (no-op for a single member).
|
||||
self.apply_group_layout(&mut inner);
|
||||
tracing::info!(
|
||||
slot,
|
||||
refs = refs + 1,
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor re-arrived for a mid-stream resize"
|
||||
);
|
||||
return Ok(out);
|
||||
}
|
||||
// Same mode — a plain concurrent-session JOIN (refcount++), no re-arrival.
|
||||
let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// while the old is still held). Reconfigure the shared monitor if the requested mode differs.
|
||||
if let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) {
|
||||
*refs += 1;
|
||||
let reconfigured = mon.mode != mode;
|
||||
if reconfigured {
|
||||
// SAFETY: `reconfigure` only manipulates the live display topology via the CCD/GDI
|
||||
// helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into this slot's
|
||||
// `Active` state, held under the `state` lock, so nothing else reconfigures it
|
||||
// concurrently.
|
||||
unsafe { self.reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(
|
||||
slot,
|
||||
refs = *refs,
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (concurrent session)"
|
||||
"virtual monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
warn_if_pick_moved(mon);
|
||||
return Ok(self.output_for(slot, mon, quit));
|
||||
let out = self.output_for(slot, mon, quit);
|
||||
if reconfigured {
|
||||
// A mode change alters this member's width — re-arrange the group so auto-row
|
||||
// siblings don't overlap the resized display (no-op for a single member).
|
||||
self.apply_group_layout(&mut inner);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
// Display budget (Stage W3): a display we can't afford is DECLINED at admission
|
||||
@@ -798,53 +761,6 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
|
||||
/// Create a fresh monitor at `mode` for `slot` (the client's stable identity slot, `0` = auto):
|
||||
/// Wait for Windows to auto-activate a freshly-ADDed IDD target into its OWN display path and
|
||||
/// return its GDI name — the capture target. Shared by the fresh CREATE and the mid-stream
|
||||
/// re-arrival ([`re_add`](Self::re_add)).
|
||||
///
|
||||
/// The IDD comes up EXTENDED alongside any existing/basic display; the caller then promotes it to
|
||||
/// primary / isolates it. Returns `None` on a GPU-less box (target added but not WDDM-activated) —
|
||||
/// the capture backend re-resolves once a GPU is present.
|
||||
///
|
||||
/// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED
|
||||
/// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed
|
||||
/// the headless path — the IDD then never gets its own path → "not an active display path" → black).
|
||||
/// force-EXTEND is only the FALLBACK, for an integrated-screen box (e.g. a laptop panel) where a
|
||||
/// fresh IDD is CLONED onto the existing display, sharing its source, so it never gets its own
|
||||
/// committed path (observed on an Intel-iGPU + NVIDIA-Optimus laptop, commit 8e87e61):
|
||||
/// `resolve_gdi_name` stays None → the `is_none()` fallback force-EXTENDs to de-clone and the
|
||||
/// second resolve finds the now-committed path. Headless/extended boxes resolve on the first loop
|
||||
/// and skip it — which is the point, since force-EXTEND is ACCESS_DENIED from our service context
|
||||
/// there.
|
||||
///
|
||||
/// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone
|
||||
/// appear as a *shared-source ACTIVE* path (resolve → Some), which the `is_none()` gate would NOT
|
||||
/// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
|
||||
/// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock.
|
||||
unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes a plain `Copy` `u32`
|
||||
// target id by value and returns an owned `String`, so no caller memory is borrowed.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no borrowed memory.
|
||||
unsafe { force_extend_topology() };
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: as the resolve loop above.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// ADD via the driver (pinning the discrete render GPU under the usual conditions), ensure the
|
||||
/// device-level watchdog pinger, resolve the GDI name, force the mode + apply the GROUP topology
|
||||
/// (first member isolates and captures the restore; a later member re-issues the isolate with
|
||||
@@ -880,10 +796,53 @@ impl VirtualDisplayManager {
|
||||
self.ensure_pinger();
|
||||
|
||||
// Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its
|
||||
// OWN display path, with the integrated-screen clone fallback (shared by the re-arrival path).
|
||||
// SAFETY: `resolve_target_gdi` runs the CCD FFI (a `Copy` `u32` target by value, owned return),
|
||||
// under the `state` lock.
|
||||
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
|
||||
// OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode`
|
||||
// below then promotes it to primary and `isolate_displays_ccd` makes it the sole composited
|
||||
// desktop — the proven flow). May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// the capture backend re-resolves once a GPU is present.
|
||||
//
|
||||
// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED
|
||||
// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed
|
||||
// the headless path — the IDD then never gets its own path → "not an active display path" → black).
|
||||
// force-EXTEND is only the FALLBACK below, for an integrated-screen box where a fresh IDD is CLONED
|
||||
// onto the panel (shares its source) instead of getting its own path.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD (QueryDisplayConfig) FFI; it takes a
|
||||
// plain `Copy` `u32` target id by value and returns an owned `String`, so no caller memory
|
||||
// is borrowed across the call.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for an integrated-screen box (e.g. a laptop panel): Windows CLONES a freshly-added
|
||||
// IDD onto the existing display, sharing its source, so it never gets its own committed path. On
|
||||
// the IddCx clone behaviour observed live (commit 8e87e61, an Intel-iGPU + NVIDIA-Optimus laptop)
|
||||
// `resolve_gdi_name` then stays None — so this `is_none()` fallback fires, force-EXTENDs to
|
||||
// de-clone, and the second resolve finds the now-committed path. Headless/extended boxes already
|
||||
// resolved above (the IDD auto-activates with its OWN source) and skip this — which is the whole
|
||||
// point, since force-EXTEND's bare preset is ACCESS_DENIED from our service context there.
|
||||
//
|
||||
// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone
|
||||
// appear as a *shared-source ACTIVE* path (resolve → Some), which this `is_none()` gate would NOT
|
||||
// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
|
||||
// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
|
||||
if gdi_name.is_none() {
|
||||
// SAFETY: as above — `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no
|
||||
// borrowed caller memory, under the `state` lock.
|
||||
unsafe { force_extend_topology() };
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: as the resolve loop above.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||
@@ -1015,134 +974,28 @@ impl VirtualDisplayManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1).
|
||||
///
|
||||
/// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the
|
||||
/// requested mode + `default_modes()`), so a plain `ChangeDisplaySettingsExW` can only reach a
|
||||
/// mode the monitor advertised on arrival — an out-of-list target (e.g. a session that arrived at
|
||||
/// 1080p resizing to 1440p) returns `DISP_CHANGE_BADMODE`. IddCx exposes no live "update modes"
|
||||
/// DDI, so to follow the client to an ARBITRARY new mode we REMOVE the driver monitor and ADD a
|
||||
/// fresh one at the new mode, reusing the slot's stable per-client id (EDID serial / ConnectorIndex
|
||||
/// / ContainerId) so the OS keeps the monitor's identity + saved per-monitor DPI. The visible cost
|
||||
/// is one monitor hotplug per switch (the design's accepted "re-arrival for everything").
|
||||
///
|
||||
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `gen`, so the outstanding
|
||||
/// session lease(s) still match on release — the linger/refcount machine is untouched. The group
|
||||
/// restore snapshot (`group.ccd_saved` / DDC / PnP) is likewise PRESERVED (a mid-session swap, not
|
||||
/// a first-member create): [`reisolate_after_swap`](Self::reisolate_after_swap) re-isolates the new
|
||||
/// target without recapturing it. Caller owns the slot's `Monitor` + `refs` across this call.
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle; touches the live display topology via CCD/GDI.
|
||||
unsafe fn re_add(
|
||||
&'static self,
|
||||
dev: HANDLE,
|
||||
inner: &mut MgrInner,
|
||||
slot: u32,
|
||||
old: &Monitor,
|
||||
mode: Mode,
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
) -> Result<Monitor> {
|
||||
/// Touches the live display topology via the CCD/GDI helpers.
|
||||
unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
slot,
|
||||
old = %format!("{}x{}@{}", old.mode.width, old.mode.height, old.mode.refresh_hz),
|
||||
new = %format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
old_target = old.target_id,
|
||||
"virtual-display: re-arriving monitor for a mid-stream resize (exact mode)"
|
||||
);
|
||||
// 1. Depart the OLD driver monitor — a bare REMOVE IOCTL (no topology restore, pinger stays
|
||||
// up): the surviving/grown-set re-isolate happens after the new ADD. Frees the preferred id
|
||||
// so the ADD below can reuse the same stable identity. Best-effort — a REMOVE failure still
|
||||
// lets the ADD proceed (the driver reaps a stale same-id monitor on the next create anyway).
|
||||
// SAFETY: `dev` is the live control handle (this fn's contract); `&old.key` borrows the
|
||||
// still-owned `MonitorKey`, alive across the synchronous IOCTL.
|
||||
if let Err(e) = unsafe { self.driver.remove_monitor(dev, &old.key) } {
|
||||
tracing::warn!(
|
||||
old_target = old.target_id,
|
||||
"re-arrival REMOVE failed (continuing to ADD): {e:#}"
|
||||
);
|
||||
}
|
||||
// Let the OS finish the ASYNC monitor departure before the ADD — a back-to-back REMOVE→ADD
|
||||
// races the teardown and the ADD is rejected under churn (same 400 ms settle as the reconnect
|
||||
// preempt path).
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
// 2. ADD a fresh monitor at the NEW mode, reusing the slot as the preferred (stable) id.
|
||||
let render_pin = resolve_render_pin();
|
||||
// SAFETY: `dev` is the live control handle; `render_pin`/`client_hdr` are owned `Copy`/`Option`
|
||||
// values passed by value — no borrow crosses the call.
|
||||
let added = unsafe {
|
||||
self.driver
|
||||
.add_monitor(dev, mode, render_pin, slot, client_hdr)
|
||||
.context("re-arrival ADD at the new mode")?
|
||||
};
|
||||
self.ensure_pinger();
|
||||
// 3. Resolve the NEW target's GDI name (target_id changes across a re-arrival).
|
||||
// SAFETY: CCD FFI over a `Copy` target id, under the `state` lock.
|
||||
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "re-arrival target {} -> {n}", added.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI/IDD captures the new size.
|
||||
set_active_mode(n, mode);
|
||||
// 4. Re-isolate the composited set with the NEW target replacing the old — preserving
|
||||
// the group's first-member restore snapshot.
|
||||
// SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock.
|
||||
unsafe { self.reisolate_after_swap(inner, added.target_id) };
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture reopens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"re-arrival target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
added.target_id
|
||||
old = format!(
|
||||
"{}x{}@{}",
|
||||
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||
),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"virtual-display: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes the `Copy` `u32`
|
||||
// `mon.target_id` by value and returns an owned `String`, so nothing borrowed crosses the call.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `gen` (lease/refcount continuity) and
|
||||
// the group-layout `position`. A fresh `gen` would strand the old session's lease release.
|
||||
Ok(Monitor {
|
||||
key: added.key,
|
||||
target_id: added.target_id,
|
||||
luid: added.luid,
|
||||
render_pin,
|
||||
wudf_pid: added.wudf_pid,
|
||||
gdi_name,
|
||||
mode,
|
||||
resolved_monitor_id: added.resolved_monitor_id,
|
||||
position: old.position,
|
||||
gen: old.gen,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-isolate the composited display set after a mid-stream monitor re-arrival ([`re_add`]) put a
|
||||
/// NEW target in place of the old one — WITHOUT recapturing the group restore snapshot (the first
|
||||
/// member captured it at session start; teardown restores that, not the mid-session state). The
|
||||
/// old slot has already been removed from the map by the caller, so `inner.target_ids()` is the
|
||||
/// surviving siblings; the new target joins them.
|
||||
///
|
||||
/// # Safety
|
||||
/// Drives the CCD topology FFI; call under the `state` lock.
|
||||
unsafe fn reisolate_after_swap(&self, inner: &mut MgrInner, new_target: u32) {
|
||||
use crate::vdisplay::policy::Topology;
|
||||
match topology_action() {
|
||||
Topology::Exclusive => {
|
||||
// Grown-set semantics: isolate to the surviving siblings + the new target. The returned
|
||||
// snapshot is DISCARDED — the group keeps the first member's (design §6.1).
|
||||
let mut keep = inner.target_ids();
|
||||
keep.push(new_target);
|
||||
// SAFETY: borrowed slice of Copy target ids, owned return, under the `state` lock.
|
||||
let _ = unsafe { isolate_displays_ccd(&keep) };
|
||||
}
|
||||
Topology::Primary => {
|
||||
// Make the new target primary again (its predecessor held primary), preserving the
|
||||
// original restore snapshot: `set_virtual_primary_ccd` recaptures one, so save + restore
|
||||
// the group's around the call.
|
||||
let keep_saved = inner.group.ccd_saved.take();
|
||||
// SAFETY: `Copy` target id by value, owned return, under the `state` lock.
|
||||
let _ = unsafe { set_virtual_primary_ccd(new_target) };
|
||||
inner.group.ccd_saved = keep_saved;
|
||||
}
|
||||
Topology::Extend | Topology::Auto => {
|
||||
// The re-ADDed target auto-activates extended — nothing to isolate/promote.
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Tear down `mon`, which the caller has ALREADY removed from `inner.slots`: on the LAST member
|
||||
|
||||
@@ -16,6 +16,34 @@ trap {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- reclaim disk before building -----------------------------------------------------------------
|
||||
# The windows-amd64 runner's system volume is intentionally small (100 GB) and a full Windows CI pass
|
||||
# writes ~50 GB of cargo target output into C:\t (x64) / C:\t-a64 (arm64). Left to accumulate across
|
||||
# runs that overflows the disk and the build dies with "no space on device" (os error 112) - exactly
|
||||
# what took the Windows host build down. The runner bakes in a reclaimer + a scheduled task that keeps
|
||||
# an idle box lean (unom/infra's setup-gitea-runner-base.ps1 ->
|
||||
# C:\Users\Public\act-runner\clean-runner-disk.ps1); call it here too so THIS job starts with headroom
|
||||
# regardless of when that task last ran. Threshold mode (no -Force): it only prunes when actually low,
|
||||
# so incremental-compile caches survive when there's room. Best-effort - a cleanup hiccup must never
|
||||
# fail the build.
|
||||
$reclaimer = 'C:\Users\Public\act-runner\clean-runner-disk.ps1'
|
||||
try {
|
||||
if (Test-Path $reclaimer) {
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $reclaimer
|
||||
}
|
||||
else {
|
||||
# Fallback for a runner not yet re-baked with the infra reclaimer: prune the big target dirs when low.
|
||||
$freeGb = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
|
||||
Write-Host "[ensure-toolchain] clean-runner-disk.ps1 absent; C: free ${freeGb} GB"
|
||||
if ($freeGb -lt 35) {
|
||||
foreach ($d in 'C:\t', 'C:\t-a64') {
|
||||
if (Test-Path $d) { Write-Host " reclaiming $d"; Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { Write-Warning "disk reclaim step failed (non-fatal): $_" }
|
||||
|
||||
$ciDir = $PSScriptRoot
|
||||
& "$ciDir\provision-windows-wdk.ps1"
|
||||
& "$ciDir\provision-windows-punktfunk-extras.ps1"
|
||||
|
||||
Reference in New Issue
Block a user