From 0a32f4eab14b37d9e65bf21047bb1b53d39621ee Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:40:24 +0200 Subject: [PATCH] fix(client/apple): the iOS slice hit the type-checker wall, and tvOS couldn't be checked at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ContentView.body` grew two modifiers and stopped type-checking on the iOS slice — "unable to type-check this expression in reasonable time", a failure macOS builds never show and the one Apple CI never builds. Split into the screen plus its lifecycle drivers, then the prompt chain, with each alert's presentation Binding lifted out the way the deep-link one already was. tvOS couldn't be checked from the command line at all: HomeView imports the slide transition, which ships with the Xcode project only because its manifest breaks SwiftPM's whole-graph validation. `canImport` instead of a bare `os(tvOS)` gate makes the tvOS sources compile without it, and the app still gets the transition because there the module is present. Both slices now typecheck by hand. The scope switcher is tvOS-gated with them: a name prompt and a nested management menu are not what a remote does well, and §5.4 keeps profile EDITING off controller-first surfaces in v1 — they honor bindings and render pinned cards. Also: release the session's settings latch when a connect fails, is refused, or is abandoned mid-handshake. Only `disconnect` cleared it, so a failed dial left the would-be session's resolution latched over the globals. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/PunktfunkClient/ContentView.swift | 167 ++++++++++-------- .../PunktfunkClient/Home/HomeView.swift | 9 +- .../PunktfunkClient/Home/HostCards.swift | 23 ++- .../Session/SessionModel.swift | 5 + .../Settings/SettingsView+Scope.swift | 6 + 5 files changed, 123 insertions(+), 87 deletions(-) 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