feat(clients): unified full-screen connect/wake takeover + iOS/tvOS Wake-on-LAN
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
android / android (push) Successful in 12m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m4s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 5m57s
deb / build-publish (push) Successful in 13m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m50s
apple / swift (push) Successful in 4m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 21m27s
ci / rust (push) Failing after 6m47s
apple / screenshots (push) Successful in 20m18s

Give instant feedback the moment a host is picked, and make the wake wait a
full-screen takeover instead of a modal card — unified into one ConnectOverlay
across every client:

- android: new ConnectOverlay (aurora backdrop; Connecting / Waking / timed-out
  phases) replaces the tiny inline "Connecting…" row and the WakeOverlay card.
  The dial phase is now cancelable and hands off to the wake wait in one frame.
- console (pf-console-ui): the connect/wake overlays become a full-screen aurora
  takeover (draw_takeover) instead of a centered card over a dim scrim; the
  Waking → Connecting handoff no longer blinks.
- apple: new ConnectOverlay mirrors it (macOS / iOS / tvOS), replacing the
  per-tile connecting spinner + the WakeOverlay card; instant "Connecting…" from
  model.phase, and the carousel is gated inactive during the dial.

Also enable Wake-on-LAN on iOS/tvOS now that the multicast entitlement is
approved: enable com.apple.developer.networking.multicast and flip
wakeOnLANAvailable to true on every platform (MACs were already learned from
mDNS, so wake works immediately).

Verified: Android compileDebugKotlin + screenshot renders; console clippy +
36 tests + rendered phases on Linux; Apple swift build + 121 tests + rendered
phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:28:27 +02:00
parent 2271f67202
commit dd02e1f402
15 changed files with 710 additions and 417 deletions
@@ -0,0 +1,122 @@
// 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`.
//
// - 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.
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
// timeout.
//
// 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.
import PunktfunkKit
import SwiftUI
struct ConnectOverlay: View {
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
/// passes nil here). Drives the "Connecting" phase.
let connectingHostName: String?
@ObservedObject var waker: HostWaker
/// 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
private enum Phase {
case connecting(name: String)
case waking(HostWaker.Waking)
}
/// Waking takes precedence it only ever exists after a dial has already failed, so a stray
/// overlap can't strand the "Connecting" phase over a wake in progress.
private var phase: Phase? {
if let w = waker.waking { return .waking(w) }
if let name = connectingHostName { return .connecting(name: name) }
return nil
}
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)
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
#endif
}
}
@ViewBuilder private func content(_ phase: Phase) -> some View {
VStack(spacing: 16) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(.white)
Text("Connecting to \(name)")
.font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65))
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))
Text("\(w.hostName) didn't wake")
.font(.geist(24, .bold, relativeTo: .title2)).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))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
}
.padding(.top, 6)
case .waking(let w):
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(14)).foregroundStyle(.white.opacity(0.65)).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)
}
}
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
/// correct across the Connecting Waking handoff without the view re-mounting. A zero-size backing
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
/// overlay is up, so nothing else is consuming the pad).
private struct ConnectControllerInput: View {
@ObservedObject var waker: HostWaker
var onCancelConnect: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.onAppear {
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
@@ -192,9 +192,12 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
// Stop consuming the controller while another screen (or the wake overlay) is on top
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
isActive: libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
@@ -1,84 +0,0 @@
// The "Waking <host>" modal shown while HostWaker brings a sleeping host back a spinner + a
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
import PunktfunkKit
import SwiftUI
struct WakeOverlay: View {
@ObservedObject var waker: HostWaker
var body: some View {
if let w = waker.waking {
ZStack {
// Dim + swallow input to the home behind it.
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card(w)
.frame(maxWidth: 380)
.padding(28)
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.padding(40)
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { WakeControllerInput(waker: waker) }
#endif
}
}
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
VStack(spacing: 14) {
if w.timedOut {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
Text("\(w.hostName) didn't wake")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
}
.padding(.top, 6)
} else {
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
.monospacedDigit()
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered)
.padding(.top, 6)
}
}
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
/// gated inactive while a wake is up, so nothing else is consuming the pad).
private struct WakeControllerInput: View {
@ObservedObject var waker: HostWaker
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.onAppear {
input.onBack = { waker.cancel() }
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
input.start()
}
.onDisappear { input.stop() }
}
}
#endif