205e5c5a59
apple / swift (push) Successful in 1m20s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
The W7/W8 client refactor (extracting FullscreenController/ApprovalRequest out of ContentView, splitting Settings) landed the DefaultsKeys + scaffolding but dropped the feature WIRING, which was uncommitted WIP shelved in a stash during the 16:28 clipboard/v4 reconcile. Recovered from that stash (+ its untracked-files parent for the whole new ModifierLayout.swift) and re-ported onto the refactored tree: - invertScroll: applied in InputCapture.sendScroll (the one scroll sink) + Settings toggle - modifierLayout (Cmd/Option switch): restored ModifierLayout.swift enum + KeyMaps .applyModifierLayout + InputCapture.emitKey wire-boundary + Settings picker - fullscreen shortcut (Ctrl+Cmd+F): InputCapture ⌃⌘F interception + onToggleFullscreen + StreamView wiring + Stream-menu item (the .punktfunkToggleFullscreen sink + FullscreenController observer already survived on main) - render scale: Settings picker + MatchWindowFollower renderScale/maxDimension application + StreamView/StreamViewIOS plumbing (RenderScale.swift already on main) StreamCommands re-ported by hand (the stash hunk deleted the since-landed clipboard item — kept it, added the fullscreen item alongside). Recovered ModifierLayout + RenderScale tests. swift build green; 15 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
8.6 KiB
Swift
173 lines
8.6 KiB
Swift
// 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
|
||
import PunktfunkShared
|
||
|
||
/// 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
|
||
/// Render-scale multiplier applied to the live window size before it's requested, so the
|
||
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
|
||
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
|
||
private let renderScale: Double
|
||
private let maxDimension: Int
|
||
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,
|
||
renderScale: Double = 1.0,
|
||
maxDimension: Int = 8192,
|
||
debounce: TimeInterval = 0.4,
|
||
minSpacing: TimeInterval = 1.0
|
||
) {
|
||
self.connection = connection
|
||
self.enabled = enabled
|
||
self.renderScale = RenderScale.sanitize(renderScale)
|
||
self.maxDimension = maxDimension
|
||
self.debounce = debounce
|
||
self.minSpacing = minSpacing
|
||
}
|
||
|
||
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
|
||
/// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of
|
||
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
|
||
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||
RenderScale.apply(
|
||
baseWidth: widthPx, baseHeight: heightPx,
|
||
scale: renderScale, maxDimension: maxDimension)
|
||
}
|
||
|
||
/// 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 = targetMode(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 = targetMode(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()
|
||
}
|
||
}
|