diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index a37c3f2e..175a282f 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -116,7 +116,66 @@ struct ContentView: View { gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) } + // The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and + // the prompt chain below. Not a style choice: as ONE expression this blew Swift's + // type-checker budget on the iOS slice ("unable to type-check in reasonable time"), which + // macOS builds never reveal. Keep new modifiers on whichever half is shorter. var body: some View { + driven + // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an + // alert) so it never collides with the wait alert below. "Request Access" is the + // no-PIN delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The + // follow-on presentation is deferred a tick so this dialog is fully dismissed first. + .confirmationDialog( + "Pairing required", + isPresented: approvalChoicePresented, + titleVisibility: .visible, + presenting: approvalChoice + ) { req in + Button("Request Access") { + DispatchQueue.main.async { requestAccess(req) } + } + Button("Pair with PIN…") { + DispatchQueue.main.async { pairingTarget = req.host } + } + Button("Cancel", role: .cancel) {} + } message: { req in + Text("\(req.host.displayName) requires pairing. Request access and approve this " + + "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or " + + "pair with the 4-digit PIN it can display.") + } + // One "Connection failed" surface for every home screen (touch grid, gamepad launcher) + // and platform — SessionModel funnels all connect/session errors into `errorMessage`. + .alert("Connection failed", isPresented: connectionErrorPresented) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } + // The delegated-approval wait: the host holds the connection open until the operator + // approves it. Cancel returns the UI at once; the in-flight connect is left to time out + // and its late result is discarded by SessionModel's connect guard (disconnect resets + // the phase/host it checks). + .alert( + "Waiting for approval", + isPresented: awaitingApprovalPresented, + presenting: awaitingApproval + ) { _ in + Button("Cancel", role: .cancel) { model.disconnect() } + } message: { req in + Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web " + + "console (port 47992 → Pairing). This device connects automatically once you " + + "approve it — no need to reconnect.") + } + // Informational deep-link outcome (unknown host, a refused profile, already + // streaming). Not an error. + .alert("Can't open", isPresented: deepLinkNoticePresented) { + Button("OK", role: .cancel) {} + } message: { + Text(deepLinkNotice ?? "") + } + } + + private var driven: some View { Group { // The stream view's structural identity MUST be stable across the // awaiting-trust → streaming transition: recreating it restarts the pump, @@ -300,88 +359,44 @@ struct ContentView: View { } #endif #endif - // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an - // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN - // delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on - // presentation is deferred a tick so this dialog is fully dismissed first. - .confirmationDialog( - "Pairing required", - isPresented: Binding( - get: { approvalChoice != nil }, - set: { if !$0 { approvalChoice = nil } }), - titleVisibility: .visible, - presenting: approvalChoice - ) { req in - Button("Request Access") { - DispatchQueue.main.async { requestAccess(req) } - } - Button("Pair with PIN…") { - DispatchQueue.main.async { pairingTarget = req.host } - } - Button("Cancel", role: .cancel) {} - } message: { req in - Text("\(req.host.displayName) requires pairing. Request access and approve this " - + "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or " - + "pair with the 4-digit PIN it can display.") - } - // One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and - // platform — SessionModel funnels all connect/session errors into `errorMessage`. - .alert( - "Connection failed", - isPresented: Binding( - get: { - guard model.errorMessage != nil else { return false } - #if os(macOS) - // Defer the alert while a forced-fullscreen exit is still pending: a sheet - // attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so - // presenting it now strands the window fullscreen on the home screen after a - // session error (a deliberate disconnect sets no `errorMessage`, which is why - // it never stuck). Tearing the session down already flipped `active`→false; - // once the window leaves fullscreen and `isFullscreen` flips, the alert shows - // over the windowed home UI. Not gated when fullscreen is the user's own manual - // choice (opt-out setting) — nothing is auto-exiting there to conflict with. - if fullscreenForSession && isFullscreen { return false } - #endif - return true - }, - set: { if !$0 { model.errorMessage = nil } }) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(model.errorMessage ?? "") - } - // The delegated-approval wait: the host holds the connection open until the operator - // approves it. Cancel returns the UI at once; the in-flight connect is left to time out - // and its late result is discarded by SessionModel's connect guard (disconnect resets the - // phase/host it checks). - .alert( - "Waiting for approval", - isPresented: Binding( - get: { awaitingApproval != nil }, - set: { if !$0 { awaitingApproval = nil } }), - presenting: awaitingApproval - ) { _ in - Button("Cancel", role: .cancel) { model.disconnect() } - } message: { req in - Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web " - + "console (port 47992 → Pairing). This device connects automatically once you " - + "approve it — no need to reconnect.") - } - // Informational deep-link outcome (unknown host / already streaming). Not an error. - .alert("Can't open", isPresented: deepLinkNoticePresented) { - Button("OK", role: .cancel) {} - } message: { - Text(deepLinkNotice ?? "") - } } - /// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so - /// the manual get/set Binding type-checks on its own instead of inflating the body chain's - /// budget (adding it inline tips SwiftUI's per-expression limit — see the split sections idiom). + // Presentation flags for the prompt chain, extracted from their `.alert`/`.confirmationDialog` + // calls so each manual get/set Binding type-checks on its own instead of inflating the body's + // budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom). + private var deepLinkNoticePresented: Binding { Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } }) } + private var approvalChoicePresented: Binding { + Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } }) + } + + private var awaitingApprovalPresented: Binding { + Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } }) + } + + private var connectionErrorPresented: Binding { + Binding( + get: { + guard model.errorMessage != nil else { return false } + #if os(macOS) + // Defer the alert while a forced-fullscreen exit is still pending: a sheet + // attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so + // presenting it now strands the window fullscreen on the home screen after a + // session error (a deliberate disconnect sets no `errorMessage`, which is why + // it never stuck). Tearing the session down already flipped `active`→false; + // once the window leaves fullscreen and `isFullscreen` flips, the alert shows + // over the windowed home UI. Not gated when fullscreen is the user's own manual + // choice (opt-out setting) — nothing is auto-exiting there to conflict with. + if fullscreenForSession && isFullscreen { return false } + #endif + return true + }, + set: { if !$0 { model.errorMessage = nil } }) + } + #if os(iOS) /// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection. private func currentModeLine() -> String { diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index ef7f817c..46499024 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -5,7 +5,12 @@ import PunktfunkKit import SwiftUI -#if os(tvOS) +// The tvOS slide transition ships with the Xcode PROJECT only — its manifest breaks SwiftPM's +// whole-graph validation, so `swift build` never sees it. `canImport` rather than `os(tvOS)` so +// the tvOS sources still TYPECHECK from the command line (the hand recipe in the Apple client's +// README), where the module is genuinely absent; in the app it is present and the transition +// applies exactly as before. +#if os(tvOS) && canImport(SwiftUINavigationTransitions) import SwiftUINavigationTransitions #endif @@ -151,7 +156,7 @@ struct HomeView: View { #if os(macOS) .frame(minWidth: 480, minHeight: 360) #endif - #if os(tvOS) + #if os(tvOS) && canImport(SwiftUINavigationTransitions) // The Settings-app slide for every push in this stack (top-level routes AND // the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade. // Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index b96c0018..b35ba9a5 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -229,17 +229,13 @@ struct HostCardView: View { Button { menu.connectWith(.defaults) } label: { - Label( - "Default settings", - systemImage: menu.boundID == nil ? "checkmark" : "") + checkable("Default settings", on: menu.boundID == nil) } ForEach(menu.profiles) { profile in Button { menu.connectWith(.profile(profile.id)) } label: { - Label( - profile.name, - systemImage: menu.boundID == profile.id ? "checkmark" : "") + checkable(profile.name, on: menu.boundID == profile.id) } } Divider() @@ -262,15 +258,24 @@ struct HostCardView: View { Button { menu.togglePin(profile.id) } label: { - Label( - profile.name, - systemImage: menu.pinnedIDs.contains(profile.id) ? "checkmark" : "") + checkable(profile.name, on: menu.pinnedIDs.contains(profile.id)) } } } } } + /// A menu row that carries a checkmark when it is the current choice. Built as two shapes + /// rather than one `Label` with an empty symbol name — `Image(systemName: "")` is not a + /// blank image, it is an invalid one. + @ViewBuilder private func checkable(_ title: String, on: Bool) -> some View { + if on { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + /// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a /// certificate is pinned (the lock state, spelled out). @ViewBuilder private func statusRow(_ m: CardMetrics) -> some View { diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8971f0e3..3490a3ee 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -361,6 +361,9 @@ final class SessionModel: ObservableObject { if case .success(let conn) = result { Task.detached { conn.close() } // joins Rust threads — off-main } + // A LATER connect has already latched its own settings; only release the + // latch when nothing took over, or this would blank the live session's. + if self.phase == .idle { SessionSettings.end() } return } switch result { @@ -383,12 +386,14 @@ final class SessionModel: ObservableObject { Task.detached { conn.close() } // joins Rust threads — off-main self.phase = .idle self.activeHost = nil + SessionSettings.end() // no session, so nothing may keep its settings latched self.errorMessage = "\(host.displayName) is not paired yet. " + "Pair with its PIN before streaming." } case .failure(let error): self.phase = .idle self.activeHost = nil + SessionSettings.end() // the dial failed — back to the plain globals if case PunktfunkClientError.rejected(let rejection) = error { // The host answered and stated its reason (declined / approval timed // out / busy / versions differ) — show that, and never wake-retry a diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index 1aeb001d..687ca5e0 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -272,8 +272,13 @@ extension SettingsView { /// "Editing: Default settings ▾" plus the edited profile's own management actions. One /// control, at the top of the surface, so the layer being edited is never ambiguous. + /// + /// Absent on tvOS: controller-first surfaces honor profiles and render pinned cards, but don't + /// EDIT them in v1 (design §5.4) — a name prompt and a nested management menu are not what a + /// remote does well, and the pattern should prove itself on the primary surfaces first. @ViewBuilder var scopeSwitcher: some View { + #if !os(tvOS) VStack(alignment: .leading, spacing: 4) { Menu { Picker("Editing", selection: scopeSelection) { @@ -344,6 +349,7 @@ extension SettingsView { } message: { profile in Text(Self.deleteWarning(for: profile, in: profiles)) } + #endif } /// Switching scope is a plain state change — the rows are built by one code path and simply