feat(resize/apple): Match-window mid-stream resize trigger + settings (C3)
design/midstream-resolution-resize.md Phase 2, Apple client. The stream mode follows the session window/scene: a windowed macOS window resize or an iPad Stage Manager / Split View scene change renegotiates the host's virtual display + encoder via the existing PunktfunkConnection.requestMode, so a windowed session streams native-resolution pixels instead of scaling. Decode/present need nothing — VideoToolbox recreates its session on the keyframe-derived format-description change (§1 table). - MatchWindowFollower (new): the shared D2 trigger discipline — physical pixels even-floored + clamped ≥320×200, debounce to resize-end, ≥1 s between requests, skip a size equal to the live mode, request each distinct size at most once (stops re-asking a rejected size / looping on a host rollback). Pure normalize/request core is unit-tested (MatchWindowTests). - macOS StreamLayerView: fed from layoutPresenter() (bounds → convertToBacking), guarded to once-in-a-window. - iOS StreamViewController: fed from viewDidLayoutSubviews (bounds × render scale); iOS-only (iPhone fullscreen no-ops, tvOS uses AVDisplayManager). - Settings: "Match window" toggle in the Stream mode section (iOS + macOS), DefaultsKey.matchWindow, read per session by the follower. Verified on a Linux Swift 6.1.2 toolchain (the app target needs AppKit/UIKit, unavailable here): the real MatchWindowFollower.swift type-checks in Swift 5 mode against a connection stub, and the pure discipline + the follower's decision path pass a standalone harness (drag-settle + grow → exactly two switches, refresh preserved, no re-request loop). A full build + on-device run (macOS window, iPad Stage Manager) remains for a Mac. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,7 +40,10 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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?
|
||||
|
||||
/// `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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,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] = []
|
||||
@@ -627,14 +630,28 @@ public final class StreamLayerView: NSView {
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd)
|
||||
// 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.
|
||||
matchFollower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
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 +667,7 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,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
|
||||
@@ -327,6 +332,12 @@ 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.
|
||||
matchFollower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
#endif
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
@@ -411,6 +422,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 +437,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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user