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
@@ -87,6 +87,10 @@ struct ContentView: View {
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
@@ -268,6 +272,7 @@ struct ContentView: View {
ConnectOverlay(
connectingHostName: connectingOverlayName,
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
}
}
@@ -584,7 +589,8 @@ struct ContentView: View {
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
// when that dial FAILS do we fall into the visible "Waking" wait a cold box takes far
// longer to boot than a connect will sit and redial once it's back on mDNS.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
@@ -641,7 +647,9 @@ struct ContentView: View {
private func prepareWake(for host: StoredHost) {
if let live = discovery.hosts.first(where: { host.matches($0) }) {
store.updateMacs(host.id, macs: live.macAddresses) // learn on every platform
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
// dial times out. With auto-wake off, connects go straight through (no packet).
let macs = host.wakeMacs
let ip = host.address
DispatchQueue.global(qos: .userInitiated).async {
@@ -1,7 +1,7 @@
// The unified full-screen "getting you connected" takeover one look for BOTH phases of reaching a
// host, so the user gets feedback the instant they pick one and it flows seamlessly into a wake if
// the host turns out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the
// shared console UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
// The unified "getting you connected" overlay one look for BOTH phases of reaching a host, so the
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
//
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
// so a host that takes a beat to answer no longer looks like nothing happened.
@@ -9,10 +9,15 @@
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
// timeout.
//
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover the
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
// looked out of place there.
//
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
// connect in the same MainActor step), so the overlay never blinks between them. Presented over BOTH
// the touch and gamepad home; it swallows input to the screen behind it, and on iOS/macOS the pad
// drives it (B cancels, A retries once timed out) while the buttons work for a pointer / tvOS focus.
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
import PunktfunkKit
import SwiftUI
@@ -22,6 +27,9 @@ struct ConnectOverlay: View {
/// passes nil here). Drives the "Connecting" phase.
let connectingHostName: String?
@ObservedObject var waker: HostWaker
/// The console launcher is up full-screen aurora takeover; otherwise the default UI's Liquid
/// Glass modal.
var gamepadUI: Bool
/// Cancel a dial in flight tears down the (uncancelable) connect and returns the UI; the late
/// result is discarded by SessionModel's connect guard.
var onCancelConnect: () -> Void
@@ -42,13 +50,25 @@ struct ConnectOverlay: View {
var body: some View {
if let phase {
ZStack {
// Opaque aurora the same living backdrop the console home wears, so the takeover
// reads as a deliberate full-screen moment rather than a card popping up.
Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
// Swallow taps so the home behind can't be touched through the takeover.
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
if gamepadUI {
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
} else {
// Default UI: a Liquid Glass modal over a dim scrim.
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
.contentShape(Rectangle()).onTapGesture {}
content(phase)
.padding(28)
.frame(maxWidth: 380)
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.padding(40)
}
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
@@ -59,24 +79,27 @@ struct ConnectOverlay: View {
}
@ViewBuilder private func content(_ phase: Phase) -> some View {
VStack(spacing: 16) {
// The takeover carries larger type than the compact modal.
let titleSize: CGFloat = gamepadUI ? 24 : 19
let bodySize: CGFloat = gamepadUI ? 14 : 13
VStack(spacing: gamepadUI ? 16 : 14) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(.white)
Text("Connecting to \(name)")
.font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
case .waking(let w) where w.timedOut:
Image(systemName: "moon.zzz.fill")
.font(.system(size: 40)).foregroundStyle(.white.opacity(0.9))
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
Text("\(w.hostName) didn't wake")
.font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65))
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
@@ -86,10 +109,10 @@ struct ConnectOverlay: View {
case .waking(let w):
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(14)).foregroundStyle(.white.opacity(0.65)).monospacedDigit()
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered).padding(.top, 6)
@@ -65,6 +65,9 @@ struct GamepadHomeView: View {
/// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
/// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// Auto-wake on connect (default ON) when off, activating an offline host just dials (no wake),
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
#if os(iOS)
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@@ -259,7 +262,7 @@ struct GamepadHomeView: View {
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
&& !host.wakeMacs.isEmpty,
activate: { connect(host) })
@@ -58,6 +58,16 @@ enum ShotScenes {
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut))
},
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
},
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
},
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
},
]
#endif
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
@@ -143,11 +153,14 @@ private struct ShotGamepadAddHost: View {
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
}
/// The unified full-screen connect takeover (the real `ConnectOverlay`) in each phase instant
/// "Connecting" feedback, the "Waking" wait, and the wake-timed-out prompt over the gamepad home.
/// The unified connect overlay (the real `ConnectOverlay`) in each phase instant "Connecting"
/// feedback, the "Waking" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
/// modal over the touch host grid.
private struct ShotConnect: View {
enum Kind { case connecting, waking, timedOut }
let kind: Kind
var gamepadUI = true
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@@ -155,30 +168,38 @@ private struct ShotConnect: View {
@StateObject private var waker = HostWaker()
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in }
)
.overlay {
ConnectOverlay(
connectingHostName: kind == .connecting ? "Battlestation" : nil,
waker: waker,
onCancelConnect: {})
}
.onAppear {
switch kind {
case .connecting:
break
case .waking:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14))
case .timedOut:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
backdrop
.overlay {
ConnectOverlay(
connectingHostName: kind == .connecting ? "Battlestation" : nil,
waker: waker,
gamepadUI: gamepadUI,
onCancelConnect: {})
}
.onAppear {
switch kind {
case .connecting:
break
case .waking:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14))
case .timedOut:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
}
}
}
@ViewBuilder private var backdrop: some View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in })
} else {
ShotHome()
}
}
}
@@ -36,6 +36,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@ObservedObject private var gamepads = GamepadManager.shared
@@ -258,6 +259,11 @@ struct GamepadSettingsView: View {
+ "available on the host.",
options: SettingsOptions.compositors, current: compositor
) { compositor = $0 },
toggleRow(
id: "autoWake", icon: "power", label: "Auto-wake on connect",
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
+ "streaming. Off connects straight through.",
value: $autoWakeEnabled),
choiceRow(
id: "codec", header: "Video", icon: "film", label: "Video codec",
@@ -42,9 +42,10 @@ extension SettingsView {
} footer: {
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)")
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
+ "\(Self.bitrateFooter)"
: "The host creates a virtual output at exactly this mode — native resolution, but "
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -294,6 +295,24 @@ extension SettingsView {
}
}
/// Auto-wake on connect fire Wake-on-LAN + wait for a sleeping saved host to come back before
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
@ViewBuilder var wakeSection: some View {
Section {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
} header: {
Text("Wake-on-LAN")
} footer: {
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
+ "way.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var windowSection: some View {
#if os(macOS)
Section {
@@ -21,7 +21,10 @@ 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
// Default ON: a windowed session streams at the window's native pixels (1:1, no scaling) so it
// stays pixel-exact instead of the presenter resampling a fixed-mode frame into the window.
// Off falls back to the explicit mode below (fixed output, scaled to non-matching windows).
@AppStorage(DefaultsKey.matchWindow) var matchWindow = true
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@@ -45,6 +48,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
#if DEBUG && !os(tvOS)
@State var showControllerTest = false
#endif
@@ -106,6 +110,7 @@ struct SettingsView: View {
Form {
streamModeSection
compositorSection
wakeSection
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
@@ -235,6 +240,7 @@ struct SettingsView: View {
streamModeSection
pointerSection
compositorSection
wakeSection
}
.formStyle(.grouped)
.navigationTitle("General")
@@ -305,6 +311,10 @@ struct SettingsView: View {
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
}
private var autoWakeEnabledTag: Binding<String> {
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
}
private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds
@@ -344,9 +354,13 @@ struct SettingsView: View {
TVSelectionRow(
title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
TVSelectionRow(
title: "Auto-wake on connect",
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.")
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
+ "sleeping saved host and waits for it before streaming.")
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)