feat(apple): default-UI connect/wake modal, auto-wake toggle, pixel-exact windowed streaming
Three client-UX changes that share the connect/present path (and settings files): - Connect/wake overlay is now mode-aware: the console/gamepad UI keeps the full-screen aurora takeover, while the default (touch/desktop) UI shows a Liquid Glass modal over the host grid — the takeover looked out of place there. - Add an auto-wake toggle (DefaultsKey.autoWake, default on) across macOS/iOS/tvOS Settings + the gamepad settings view; gate startSession/prepareWake and the gamepad "Wake & Connect" label on it. MAC-address learning stays always-on. - Windowed sessions now stream at the window's native pixels (Match-window default-on) so the picture is 1:1 pixel-exact instead of the presenter resampling a fixed-mode frame; fullscreen reports full-display px, also 1:1. Also lands the mid-resize aspect-fit tracking (decoded contentSize) that keeps the picture undistorted after a resize. swift build + swift test (121 tests) green; screenshot scenes verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,12 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||
public static let autoWake = "punktfunk.autoWake"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -70,6 +70,15 @@ final class SessionPresenter {
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
|
||||
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
|
||||
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
|
||||
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
|
||||
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
|
||||
/// falls back to `currentMode()`. Main-thread only.
|
||||
private var contentSize: CGSize?
|
||||
|
||||
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
||||
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||
@@ -184,11 +193,18 @@ final class SessionPresenter {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
||||
? AVMakeRect(
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||
let aspect: CGSize? = {
|
||||
if let c = contentSize, c.width > 0, c.height > 0 { return c }
|
||||
if mode.width > 0, mode.height > 0 {
|
||||
return CGSize(width: Int(mode.width), height: Int(mode.height))
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
@@ -209,10 +225,20 @@ final class SessionPresenter {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
|
||||
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
|
||||
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
|
||||
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
|
||||
func setContentSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0, size != contentSize else { return }
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
func stop() {
|
||||
contentSize = nil // a new session re-derives it from its first frame
|
||||
pump?.stop()
|
||||
pump = nil
|
||||
stage2Link?.invalidate()
|
||||
|
||||
@@ -176,8 +176,14 @@ public final class StreamLayerView: NSView {
|
||||
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.
|
||||
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
private let cursorCapture = CursorCapture()
|
||||
private var inputCapture: InputCapture?
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
@@ -638,6 +644,10 @@ public final class StreamLayerView: NSView {
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||
// NSView.displayLink so it tracks the display this view is on.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: displayLayer,
|
||||
@@ -647,13 +657,19 @@ public final class StreamLayerView: NSView {
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
|
||||
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); the first real `layout()` feeds the initial
|
||||
// size, so the stream converges to the window even if the connect used the explicit mode.
|
||||
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
// Match-window (C3): follow the window's pixel size — DEFAULT ON, so a windowed session
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||
// converges to the window even though the connect used the explicit/display mode; entering
|
||||
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
@@ -679,6 +695,18 @@ public final class StreamLayerView: NSView {
|
||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutPresenter()
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||
public func stop() {
|
||||
@@ -688,6 +716,7 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// 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).
|
||||
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
#endif
|
||||
|
||||
@@ -183,6 +184,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// 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)?
|
||||
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
@@ -349,12 +355,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.
|
||||
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -362,6 +370,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: streamView.displayLayer,
|
||||
@@ -371,7 +383,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
onDecodedSize: { [weak self] w, h in
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
layoutMetalLayer()
|
||||
|
||||
#if os(iOS)
|
||||
@@ -451,6 +466,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
sessionDisplayManager = nil
|
||||
#endif
|
||||
presenter.stop()
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -527,6 +543,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
if on {
|
||||
|
||||
Reference in New Issue
Block a user