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
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:
@@ -60,7 +60,8 @@ struct ContentView: View {
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||
@StateObject private var waker = HostWaker()
|
||||
#if os(macOS)
|
||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||
@@ -259,9 +260,25 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||
// `home`, so it covers the whole wake→online→connect sequence).
|
||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||||
// turns out to be asleep.
|
||||
homeBase.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: connectingOverlayName,
|
||||
waker: waker,
|
||||
onCancelConnect: { model.disconnect() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||||
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||||
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||||
private var connectingOverlayName: String? {
|
||||
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||||
else { return nil }
|
||||
return host.displayName
|
||||
}
|
||||
|
||||
@ViewBuilder private var homeBase: some View {
|
||||
|
||||
@@ -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
|
||||
@@ -49,8 +49,14 @@ enum ShotScenes {
|
||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotWaking())
|
||||
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting))
|
||||
},
|
||||
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .waking))
|
||||
},
|
||||
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut))
|
||||
},
|
||||
]
|
||||
#endif
|
||||
@@ -137,7 +143,12 @@ private struct ShotGamepadAddHost: View {
|
||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||
}
|
||||
|
||||
private struct ShotWaking: View {
|
||||
/// 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.
|
||||
private struct ShotConnect: View {
|
||||
enum Kind { case connecting, waking, timedOut }
|
||||
let kind: Kind
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@@ -149,11 +160,25 @@ private struct ShotWaking: View {
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in }
|
||||
)
|
||||
.overlay { WakeOverlay(waker: waker) }
|
||||
.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||
waker: waker,
|
||||
onCancelConnect: {})
|
||||
}
|
||||
.onAppear {
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
||||
var timedOut = false
|
||||
}
|
||||
|
||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||
/// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`.
|
||||
@Published private(set) var waking: Waking?
|
||||
|
||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||
|
||||
Reference in New Issue
Block a user