From 89ff326ebfaed07b7336806b88ff71801067e30f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 13:36:03 +0200 Subject: [PATCH] feat(resize/apple): Match-window mid-stream resize trigger + settings (C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Settings/SettingsView+Sections.swift | 12 +- .../Settings/SettingsView.swift | 1 + .../PunktfunkKit/Support/DefaultsKeys.swift | 9 ++ .../Video/MatchWindowFollower.swift | 124 ++++++++++++++++++ .../PunktfunkKit/Views/StreamView.swift | 20 ++- .../PunktfunkKit/Views/StreamViewIOS.swift | 22 ++++ .../PunktfunkKitTests/MatchWindowTests.swift | 43 ++++++ 7 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/MatchWindowTests.swift 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..2a2ea418 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift @@ -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() + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 9dede3c3..0f267bc7 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index ee247fb1..bb8eec6b 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -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 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))) + } +}