diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 1b0c8c23..849b7b72 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -327,12 +327,25 @@ struct ContentView: View { }() return ZStack { stream(captureEnabled: pendingFingerprint == nil) - .blur(radius: pendingFingerprint != nil ? 32 : 0) + // 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) .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, @@ -410,6 +423,16 @@ 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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift b/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift new file mode 100644 index 00000000..373a4150 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift @@ -0,0 +1,41 @@ +// 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 + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 9e0df31e..4c524714 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -109,6 +109,16 @@ 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 @@ -364,6 +374,8 @@ 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. @@ -374,6 +386,23 @@ 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 @@ -417,6 +446,11 @@ 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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index c9c9e089..a4cf536e 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -13,6 +13,11 @@ 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 @@ -35,8 +40,11 @@ extension SettingsView { } header: { Text("Stream mode") } footer: { - Text("The host creates a virtual output at exactly this mode — " - + "native resolution, no scaling. \(Self.bitrateFooter)") + 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 — " + + "native resolution, no scaling. \(Self.bitrateFooter)") .font(.geist(12, relativeTo: .caption)) .foregroundStyle(.secondary) } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 8e346289..f53e1694 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -21,6 +21,7 @@ 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 diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 428c3bc9..33955d4d 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -11,6 +11,15 @@ 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" diff --git a/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift new file mode 100644 index 00000000..6907af56 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift @@ -0,0 +1,153 @@ +// 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() + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift b/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift new file mode 100644 index 00000000..985ceeae --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift @@ -0,0 +1,63 @@ +// 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 + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 19bd8502..e11db891 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -85,7 +85,8 @@ final class SessionPresenter { displayMeter: LatencyMeter? = nil, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { stop() self.connection = connection @@ -128,12 +129,14 @@ final class SessionPresenter { link.add(to: .main, forMode: .common) stage2Link = link syncFrameRate(hz: connection.currentMode().refreshHz) - pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) + pipeline.start( + connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd, + onDecodedSize: onDecodedSize) } else { let pump = StreamPump() pump.start( connection: connection, layer: baseLayer, - onFrame: onFrame, onSessionEnd: onSessionEnd) + onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize) self.pump = pump } } diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index f21fcc02..68d20084 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -329,7 +329,8 @@ public final class Stage2Pipeline { public func start( connection: PunktfunkConnection, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { offsetNs = connection.clockOffsetNs recovery.bind(connection) // arm host-keyframe recovery for this session @@ -350,6 +351,9 @@ 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. @@ -387,6 +391,11 @@ 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 } diff --git a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index 8d193aa7..c19ebb7e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -21,7 +21,8 @@ final class StreamPump { connection: PunktfunkConnection, layer: AVSampleBufferDisplayLayer, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { let token = token // Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery). @@ -35,6 +36,9 @@ 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 @@ -79,6 +83,11 @@ 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") diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 9dede3c3..2d0f7db1 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -87,6 +87,8 @@ 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? @@ -108,6 +110,8 @@ 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 @@ -118,6 +122,8 @@ 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 @@ -131,6 +137,8 @@ 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 } @@ -142,6 +150,8 @@ 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 { @@ -165,6 +175,9 @@ 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] = [] @@ -201,6 +214,13 @@ 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 { @@ -626,15 +646,32 @@ public final class StreamLayerView: NSView { displayMeter: displayMeter, makeDisplayLink: { displayLink(target: $0, selector: $1) }, onFrame: onFrame, - onSessionEnd: onSessionEnd) + 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 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). + /// 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. 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() { @@ -650,6 +687,7 @@ public final class StreamLayerView: NSView { inputCapture?.stop() inputCapture = nil presenter.stop() + matchFollower = nil connection = nil } diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index ee247fb1..f79d002b 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -53,6 +53,8 @@ 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? @@ -68,6 +70,8 @@ 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 @@ -77,6 +81,8 @@ 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 @@ -89,6 +95,8 @@ 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 } @@ -99,6 +107,8 @@ 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) } @@ -147,6 +157,11 @@ 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 @@ -161,6 +176,13 @@ 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 { @@ -327,6 +349,14 @@ 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 @@ -340,7 +370,8 @@ public final class StreamViewController: StreamViewControllerBase { displayMeter: displayMeter, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, onFrame: onFrame, - onSessionEnd: onSessionEnd) + onSessionEnd: onSessionEnd, + onDecodedSize: onDecodedSize) layoutMetalLayer() #if os(iOS) @@ -411,6 +442,7 @@ 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 @@ -425,6 +457,16 @@ 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 diff --git a/clients/apple/Tests/PunktfunkKitTests/MatchWindowTests.swift b/clients/apple/Tests/PunktfunkKitTests/MatchWindowTests.swift new file mode 100644 index 00000000..dde0e185 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/MatchWindowTests.swift @@ -0,0 +1,43 @@ +// 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))) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift b/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift new file mode 100644 index 00000000..98021e9f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift @@ -0,0 +1,52 @@ +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) + } +} diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index a2d18ba2..0fbe235d 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -264,21 +264,23 @@ pub fn show( let page = adw::PreferencesPage::new(); let stream = adw::PreferencesGroup::builder().title("Stream").build(); - let res_names: Vec = RESOLUTIONS - .iter() - .map(|&(w, h)| { - if w == 0 { - "Native display".to_string() - } else { - format!("{w} × {h}") - } - }) + // 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 = std::iter::once("Native display".to_string()) + .chain(std::iter::once("Match window".to_string())) + .chain( + RESOLUTIONS + .iter() + .skip(1) + .map(|&(w, h)| format!("{w} × {h}")), + ) .collect(); let res_row = ChoiceRow::new( &dialog, inline, "Resolution", - "The host creates a virtual output at exactly this size", + "The host creates a virtual output at exactly this size — Match window follows \ + the stream window, including mid-stream resizes", &res_names.iter().map(String::as_str).collect::>(), ); let hz_names: Vec = REFRESH @@ -470,10 +472,15 @@ pub fn show( // Seed from the current settings. { let s = settings.borrow(); - let res_i = RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(0); + let res_i = if s.match_window { + 1 + } else { + RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .map(|i| if i == 0 { 0 } else { i + 1 }) + .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); @@ -508,8 +515,14 @@ pub fn show( dialog.add(&page); dialog.connect_closed(move |_| { let mut s = settings.borrow_mut(); - let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)]; - (s.width, s.height) = (w, h); + // 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] + }; 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(); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 4118286d..6201bf05 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -145,6 +145,10 @@ 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 = diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 972b8859..e4ba52b5 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -164,6 +164,34 @@ 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> { + 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 + }) + } + /// 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`. @@ -343,6 +371,8 @@ 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 = diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 11e131a7..8cde457d 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -136,29 +136,37 @@ 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 = RESOLUTIONS - .iter() - .map(|&(w, h)| { - if w == 0 { - "Native display".into() - } else { - format!("{w} \u{00D7} {h}") - } - }) + let names: Vec = std::iter::once("Native display".to_string()) + .chain(std::iter::once("Match window".to_string())) + .chain( + RESOLUTIONS + .iter() + .skip(1) + .map(|&(w, h)| format!("{w} \u{00D7} {h}")), + ) .collect(); - let i = RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(0); + let i = if s.match_window { + 1 + } else { + RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .map(|i| if i == 0 { 0 } else { i + 1 }) + .unwrap_or(0) + }; (names, i) }; let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| { - (s.width, s.height) = RESOLUTIONS[i]; + s.match_window = i == 1; + (s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] }; }) .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.", + resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \ + follows the stream window, including mid-stream resizes.", ); let (hz_names, hz_i) = { let names: Vec = REFRESH diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index f25e0993..ef52c3df 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -406,6 +406,19 @@ 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 { @@ -466,6 +479,9 @@ 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, } } } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index ca7b9865..2f60d867 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -176,7 +176,9 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { RowId::Resolution => ( Some("Stream"), "Resolution", - if s.width == 0 { + if s.match_window { + "Match window".into() + } else if s.width == 0 { "Native".into() } else { format!("{} × {}", s.width, s.height) @@ -259,7 +261,8 @@ 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." + "The host creates a virtual display at exactly this size — no scaling. \ + Match window follows this window, including mid-stream resizes." } RowId::Refresh => "Native follows the display this window is on.", RowId::Bitrate => "Automatic uses the host's default (20 Mbps).", @@ -303,11 +306,20 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { let s = &mut *ctx.settings; match id { RowId::Resolution => { - let cur = RESOLUTIONS - .iter() - .position(|(w, h)| (*w, *h) == (s.width, s.height)); - step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| { - (s.width, s.height) = RESOLUTIONS[i]; + // 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 + .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] }; }) } RowId::Refresh => { @@ -401,14 +413,26 @@ mod tests { device_name: "t", t: 0.0, }; - // Resolution starts at Native (index 0): left refuses, right steps. + // 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. 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] diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index d39f741a..d2113ba8 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -52,6 +52,18 @@ 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>, + /// 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>, } pub enum Outcome { @@ -173,6 +185,22 @@ 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, + /// 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, + /// 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, + /// 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, } impl StreamState { @@ -217,6 +245,10 @@ 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, } } @@ -270,7 +302,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .register_custom_event::() .map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?; let mut window = { - let mut b = video.window(&opts.window_title, 1280, 720); + // 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)); match opts.window_pos { Some((x, y)) => b.position(x, y), None => b.position_centered(), @@ -340,12 +375,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let mut stream: Option = match &mut mode { ModeCtl::Single(build) => { let force_software = Arc::new(AtomicBool::new(false)); - let params = build( + let mut 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, @@ -423,6 +461,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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())?; @@ -616,7 +662,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result presenter.vulkan_decode(), ) { ActionOutcome::Handled => {} - ActionOutcome::Start(params) => { + ActionOutcome::Start(mut params) => { + if opts.match_window.is_some() { + apply_match_window(&mut params, &window); + } stream = Some(StreamState::new( *params, force_software, @@ -753,6 +802,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } + // --- 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(); @@ -1013,6 +1069,125 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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, + sent_at: Option, + 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 @@ -1115,6 +1290,105 @@ 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 { diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 48972d45..21a362d3 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -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, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1085,6 +1085,24 @@ 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); @@ -1110,6 +1128,13 @@ async fn serve_session( let (probe_tx, probe_rx) = std::sync::mpsc::channel::(); let (probe_result_tx, mut probe_result_rx) = tokio::sync::mpsc::unbounded_channel::(); + // 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: }` — 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::(); // 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. @@ -1118,23 +1143,46 @@ 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 = 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 ok = req.mode.refresh_hz > 0 + let now = std::time::Instant::now(); + let valid = 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() { @@ -1230,6 +1278,17 @@ 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; + } + } } } }); @@ -1539,6 +1598,7 @@ async fn serve_session( codec, probe_rx, probe_result_tx, + reconfig_result_tx, fec_target: fec_target_dp, conn: conn_stream, timing_conn, @@ -2927,9 +2987,10 @@ 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, - width: u32, - height: u32, - fps: u32, + /// 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, codec: &'static str, client: String, /// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive @@ -2937,6 +2998,69 @@ struct SendStats { bitrate_kbps: Arc, } +/// 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, + 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, @@ -3075,14 +3199,12 @@ 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(|| { - stats.rec.register_session( - "native", - stats.width, - stats.height, - stats.fps, - stats.codec, - &stats.client, - ) + // 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) }); let sample = crate::stats_recorder::StatsSample { t_ms: 0, // stamped by push_sample from the capture's monotonic start @@ -3293,6 +3415,10 @@ struct SessionContext { probe_rx: std::sync::mpsc::Receiver, /// Speed-test results back to the control task. probe_result_tx: tokio::sync::mpsc::UnboundedSender, + /// Mode-switch outcomes back to the control task (H2): a corrective + /// `Reconfigured { accepted: true, mode: }` 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, /// Adaptive-FEC target the control task updates from the client's loss reports. fec_target: Arc, /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). @@ -3351,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { codec: _, probe_rx, probe_result_tx, + reconfig_result_tx, fec_target, conn, timing_conn, @@ -3409,7 +3536,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) = + let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) = 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")] @@ -3457,13 +3584,20 @@ 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(), - width: mode.width, - height: mode.height, - fps: mode.refresh_hz, + mode: live_mode.clone(), codec: plan.codec.label(), client: client_label, bitrate_kbps: live_bitrate.clone(), @@ -3608,7 +3742,10 @@ 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))) => { + Ok(( + new_vd, + (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen), + )) => { // 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; @@ -3616,6 +3753,7 @@ 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(); @@ -3655,9 +3793,37 @@ 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) => { - (capturer, enc, frame, interval, cur_node_id) = 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; 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(); @@ -3668,6 +3834,16 @@ 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), + }); } } } @@ -3682,7 +3858,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 = (1.0 / interval.as_secs_f64()).round() as u32; + let hz = interval_hz(interval); match crate::encode::open_video( plan.codec, frame.format, @@ -3840,7 +4016,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) = loop { + let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = 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() { @@ -3900,6 +4076,7 @@ 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(); @@ -4174,6 +4351,11 @@ 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, ); /// Build the pipeline, retrying *transient* failures with bounded exponential backoff. @@ -4326,6 +4508,12 @@ 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; @@ -4390,13 +4578,82 @@ 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)) + Ok((capturer, enc, frame, interval, node_id, pool_gen)) } #[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}; diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index b4963f40..a8a2aba1 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -79,6 +79,13 @@ pub struct VirtualOutput { /// keep-alive pool is Linux). #[cfg(target_os = "linux")] pub reused_gen: Option, + /// 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, } impl VirtualOutput { @@ -100,6 +107,8 @@ impl VirtualOutput { ownership: DisplayOwnership::Owned, #[cfg(target_os = "linux")] reused_gen: None, + #[cfg(target_os = "linux")] + pool_gen: None, } } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 583972bd..c428e043 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -241,6 +241,7 @@ 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 @@ -366,6 +367,7 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput { keepalive: Box::new(()), ownership: DisplayOwnership::SessionManaged, reused_gen: None, + pool_gen: None, } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs index ed468522..f6d524e8 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs @@ -198,6 +198,7 @@ impl VirtualDisplay for HyprlandDisplay { // `remote_fd.is_some()` — same as wlroots. ownership: DisplayOwnership::Owned, reused_gen: None, + pool_gen: None, }) } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs index 096cff34..a67e5709 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs @@ -135,6 +135,7 @@ 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, }) } } diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 5ce780c9..d2232aa7 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -176,6 +176,21 @@ 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 @@ -386,6 +401,9 @@ 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 } @@ -819,6 +837,20 @@ mod linux { } pub(super) fn force_release(slot: Option) -> 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, why: &'static str) -> usize { let Some(r) = REG.get() else { return 0 }; let (released, restores) = { let mut es = r.entries.lock().unwrap(); @@ -847,10 +879,7 @@ mod linux { restore(); } for e in released { - tracing::info!( - backend = e.backend, - "virtual display released (mgmt /display/release)" - ); + tracing::info!(backend = e.backend, "virtual display {why}"); drop(e); } n diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 8e5ac2a3..b96614c5 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -557,31 +557,68 @@ 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). 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) }; + // 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"); + }; + *refs += 1; tracing::info!( slot, refs = *refs, backend = self.driver.name(), - "virtual monitor reused (concurrent / reconfigure session)" + "virtual monitor reused (concurrent session)" ); warn_if_pick_moved(mon); - 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); + return Ok(self.output_for(slot, mon, quit)); } // Display budget (Stage W3): a display we can't afford is DECLINED at admission @@ -761,6 +798,53 @@ 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 { + 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 @@ -796,53 +880,10 @@ impl VirtualDisplayManager { self.ensure_pinger(); // Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its - // 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; - } - } - } + // 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) }; match &gdi_name { Some(n) => { tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); @@ -974,28 +1015,134 @@ impl VirtualDisplayManager { }) } - /// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. + /// 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. /// /// # Safety - /// Touches the live display topology via the CCD/GDI helpers. - unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) { + /// `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, + ) -> Result { tracing::info!( - 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" + 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)" ); - // 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); + // 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:#}" + ); } - if let Some(n) = &mon.gdi_name { - set_active_mode(n, mode); + // 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 + ), + } + // 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. + } } - mon.mode = mode; } /// Tear down `mon`, which the caller has ALREADY removed from `inner.slots`: on the LAST member