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:
2026-07-12 01:29:12 +02:00
parent dd02e1f402
commit e87fd42cee
11 changed files with 257 additions and 74 deletions
@@ -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 {