From 522ac7bd49ab4101c97478151bd1d15f41b6f63b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 08:35:28 +0200 Subject: [PATCH] feat(apple): the connect path's modals are pad-navigable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces: a game controller cannot move through their buttons or press one. On iOS/macOS that made every prompt in the connect path a dead end for a pad-only user, and they are not incidental prompts — "Pairing required" is the FIRST thing an unpaired host shows (so pairing was unreachable before it even got to a PIN), "Connection failed" strands the console UI behind a modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a connect that may never complete. GamepadPromptView renders those states as a console card with a focus list of actions; the system alerts stand down while it is up. tvOS keeps them — the focus engine drives them natively there, which is exactly why this gap was invisible from that platform. Two things are deliberate rather than incidental: The gate is "not STREAMING", not `model.connection == nil`. A connection object exists well before a stream does — through the pair-required and approval handshakes, precisely when these fire — so gating on the connection would hand those cases back to the system dialog. Streaming is the one state that must keep the alert: there the pad belongs to GamepadCapture. And the overlay hangs off `driven`, not `home`, for the same reason: `home` renders only while the connection is nil, so a prompt mounted there would be skipped in the very case it was written for. The launcher stands down from the controller poll while a prompt is up (`promptActive`) — without it the host carousel keeps scrolling underneath the modal and one A press reaches both. macOS + tvOS typecheck; console UI verified opening Settings in the iPad simulator with the prompts wired in. --- .../Sources/PunktfunkClient/ContentView.swift | 147 ++++++++++-- .../Home/GamepadHomeView.swift | 9 +- .../PunktfunkClient/Home/GamepadPrompt.swift | 219 ++++++++++++++++++ 3 files changed, 359 insertions(+), 16 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index dea4b3cc..56bb189e 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -200,6 +200,22 @@ struct ContentView: View { private var driven: some View { drivenBase .environment(\.gamepadMetrics, gamepadMetrics) + #if os(iOS) || os(macOS) + // The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which + // renders only while `model.connection == nil`: a connection exists through the + // pair-required and approval handshakes, which is precisely when these prompts fire. + // It sits above the connect takeover too — the delegated-approval wait is raised + // DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that + // state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two + // never poll the pad at once.) + .overlay { + if let prompt = consolePrompt { + GamepadPromptView(prompt: prompt) + .gamepadPaletteInk() + .transition(.opacity) + } + } + #endif } private var drivenBase: some View { @@ -444,9 +460,102 @@ struct ContentView: View { // 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 } }) + Binding( + get: { deepLinkNotice != nil && !consolePromptShowing }, + set: { if !$0 { deepLinkNotice = nil } }) } + /// True while the console prompt owns the modal state (see `consolePrompt`). Always false on + /// tvOS, whose alerts the focus engine drives natively. + private var consolePromptShowing: Bool { + #if os(iOS) || os(macOS) + consolePrompt != nil + #else + false + #endif + } + + #if os(iOS) || os(macOS) + /// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of + /// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a + /// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the + /// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a + /// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a + /// connect that may never complete. One at a time, most-urgent first — a system alert stack + /// would layer these, but a console shows one screen. + /// + /// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well + /// before a stream does, through exactly the handshakes these prompts belong to. Streaming is + /// the one case that must stay with the system alert — there the pad belongs to + /// `GamepadCapture` and is being forwarded to the host. + private var consolePrompt: GamepadPrompt? { + guard gamepadUIActive, model.phase != .streaming else { return nil } + if let req = approvalChoice { + return GamepadPrompt( + id: "pairing-required", + title: "Pairing required", + message: "\(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.", + actions: [ + // The follow-on presentation is deferred a tick exactly as the system dialog + // does it, so this prompt is fully torn down before the next screen mounts — + // two controller pollers overlapping for a frame is how one A press reaches + // both. + GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) { + approvalChoice = nil + DispatchQueue.main.async { requestAccess(req) } + }, + GamepadPromptAction(id: "pin", title: "Pair with PIN…") { + approvalChoice = nil + DispatchQueue.main.async { pairingTarget = req.host } + }, + GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) { + approvalChoice = nil + }, + ]) + } + if let req = awaitingApproval { + return GamepadPrompt( + id: "awaiting-approval", + title: "Waiting for approval", + message: "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.", + actions: [ + GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) { + awaitingApproval = nil + model.disconnect() + }, + ], + busy: true) + } + if connectionErrorReady { + return GamepadPrompt( + id: "connection-failed", + title: "Connection failed", + message: model.errorMessage ?? "", + actions: [ + GamepadPromptAction(id: "ok", title: "OK", isCancel: true) { + model.errorMessage = nil + }, + ]) + } + if let notice = deepLinkNotice { + return GamepadPrompt( + id: "cant-open", + title: "Can't open", + message: notice, + actions: [ + GamepadPromptAction(id: "ok", title: "OK", isCancel: true) { + deepLinkNotice = nil + }, + ]) + } + return nil + } + #endif + /// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents /// the library in place (see the cover's comment). private var touchLibraryTarget: Binding { @@ -470,18 +579,22 @@ struct ContentView: View { } private var approvalChoicePresented: Binding { - Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } }) + Binding( + get: { approvalChoice != nil && !consolePromptShowing }, + set: { if !$0 { approvalChoice = nil } }) } private var awaitingApprovalPresented: Binding { - Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } }) + Binding( + get: { awaitingApproval != nil && !consolePromptShowing }, + set: { if !$0 { awaitingApproval = nil } }) } - private var connectionErrorPresented: Binding { - Binding( - get: { - guard model.errorMessage != nil else { return false } - #if os(macOS) + /// Whether the "Connection failed" state is ready to be shown at all — shared by the system + /// alert and the console prompt so the two can never disagree about the macOS deferral below. + private var connectionErrorReady: Bool { + 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 @@ -490,10 +603,14 @@ struct ContentView: View { // 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 - }, + if fullscreenForSession && isFullscreen { return false } + #endif + return true + } + + private var connectionErrorPresented: Binding { + Binding( + get: { connectionErrorReady && !consolePromptShowing }, set: { if !$0 { model.errorMessage = nil } }) } @@ -628,7 +745,8 @@ struct ContentView: View { libraryTarget: $libraryTarget, pairingTarget: $pairingTarget, onPaired: handlePaired, waker: waker, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, - launchTitle: launchTitle) + launchTitle: launchTitle, + promptActive: consolePromptShowing) } else { HomeView( store: store, model: model, discovery: discovery, @@ -646,7 +764,8 @@ struct ContentView: View { libraryTarget: $libraryTarget, pairingTarget: $pairingTarget, onPaired: handlePaired, waker: waker, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, - launchTitle: launchTitle) + launchTitle: launchTitle, + promptActive: consolePromptShowing) // On tvOS pairing/library normally present from HomeView's navigationDestinations // — which aren't mounted while the gamepad launcher is up. Give the launcher its // own presenters (exactly one of the two homes is mounted at a time, so these can diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 62613083..8b864cbe 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -84,6 +84,11 @@ struct GamepadHomeView: View { /// Launch a library title on a host — the in-place library layer's activate path (iOS; the /// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves). let launchTitle: (StoredHost, String) -> Void + /// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so + /// this screen must stand down for as long as it is. Same handoff contract as the connect + /// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the + /// modal and a single A press reaches both. + var promptActive = false /// The profile catalog — pinned host+profile combos render as their own tiles here, which is /// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4). @@ -311,14 +316,14 @@ struct GamepadHomeView: View { /// transition's input drop, during which NOBODY polls. private var homeOwnsController: Bool { #if os(iOS) - topScreen == nil && !transitioning + topScreen == nil && !transitioning && !promptActive && waker.waking == nil && model.phase != .connecting #else // `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and // either way the launcher underneath must stop consuming the pad — the pair screen's own // list is polling the same controller. libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost - && waker.waking == nil && model.phase != .connecting + && !promptActive && waker.waking == nil && model.phase != .connecting #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift new file mode 100644 index 00000000..b54435b8 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift @@ -0,0 +1,219 @@ +// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS). +// +// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move +// through their buttons or press one — so on iOS/macOS every prompt in the connect path was a dead +// end for a pad-only user, and they are not incidental prompts: +// +// - "Pairing required" (Request Access / Pair with PIN…) is the FIRST thing an unpaired host +// shows. Pairing was unreachable before it even got to the PIN. +// - "Connection failed" strands the console UI behind a modal only a finger can dismiss. +// - "Waiting for approval" owns the only Cancel for a connect that may never complete. +// +// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole +// reason this gap was tvOS-invisible. +// +// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of +// unknown length, wrong for two buttons in a card, where it would need an invented height and +// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) + +/// One choice in a console prompt. +struct GamepadPromptAction: Identifiable { + let id: String + let title: String + /// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one + /// action should carry it — `GamepadPrompt` falls back to the LAST action when none does, + /// which matches how a system alert treats its cancel role. + var isCancel = false + /// Drawn as the primary, accent-tinted row. At most one. + var isPrimary = false + let run: () -> Void +} + +/// A prompt to show over the console UI: what happened, and what can be done about it. +struct GamepadPrompt: Identifiable { + let id: String + let title: String + let message: String + let actions: [GamepadPromptAction] + /// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title — + /// the prompt is the UI for something still in flight, not a report that it finished. + var busy = false +} + +/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of +/// actions, and the same legend every other gamepad screen carries. +struct GamepadPromptView: View { + @Environment(\.gamepadInk) private var ink + @Environment(\.gamepadMetrics) private var metrics + let prompt: GamepadPrompt + + @State private var cursor = 0 + @State private var input = GamepadMenuInput(manager: .shared) + @State private var haptics = MenuHaptics(manager: .shared) + /// `.sensoryFeedback` counters — device ticks for confirm and for a refused move at an end. + @State private var activateTick = 0 + @State private var boundaryTick = 0 + + #if os(iOS) + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false + #endif + + var body: some View { + ZStack { + // Swallows touch to the launcher behind it, which is also gated out of the controller + // poll for as long as this is up (ContentView's `promptActive`). + Rectangle() + .fill(.black.opacity(0.55)) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture {} + card + } + .sensoryFeedback(.selection, trigger: cursor) + .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) + .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + .onAppear { + cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0) + wire() + input.start() + } + // The prompt's identity is stable across a message change (same `id`), so re-wire rather + // than rely on a remount: the stored closures captured the OLD actions array. + .onChange(of: prompt.actions.map(\.id)) { _, _ in + cursor = min(cursor, max(prompt.actions.count - 1, 0)) + wire() + } + .onDisappear { + input.stop() + haptics.stop() + } + } + + private var card: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + if prompt.busy { + ProgressView().controlSize(.small).tint(ink.fg(0.8)) + } + Text(prompt.title) + .font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3)) + .foregroundStyle(ink.fg) + } + Text(prompt.message) + .font(.geist(metrics.detailFont, relativeTo: .callout)) + .foregroundStyle(ink.fg(0.62)) + .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 6) { + ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in + actionRow(action, focused: idx == cursor) + .contentShape(Rectangle()) + .onTapGesture { tap(idx) } + } + } + .padding(.top, 2) + GamepadHintBar(hints: hints) + } + .padding(compact ? 20 : 26) + .frame(maxWidth: 460) + .consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(ink.fg(0.12), lineWidth: 1) + } + .padding(24) + } + + private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View { + let m = metrics + return Text(action.title) + .font(.geist(m.labelFont, .semibold, relativeTo: .body)) + .foregroundStyle(action.isPrimary ? ink.accent : ink.fg) + .frame(maxWidth: .infinity) + .padding(.horizontal, m.rowHPad) + .padding(.vertical, m.rowVPad) + .consoleGlass( + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), + tint: focused ? ink.accent(0.30) : nil, + interactive: focused) + .overlay { + RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) + .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) + } + .scaleEffect(focused ? 1.0 : 0.98) + .animation(.smooth(duration: 0.18), value: focused) + } + + private var hints: [GamepadHint] { + var hints: [GamepadHint] = [.init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select", + action: { activate() })] + // Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that + // action, and B does it too — naming it twice would just be noise. + if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) { + hints.append(.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title, + action: { back() })) + } + return hints + } + + // MARK: - Input + + private func wire() { + input.onMove = { direction in + switch direction { + case .up: step(by: -1) + case .down: step(by: 1) + // A prompt's actions are a vertical list; left/right have nothing to mean here, and + // silently treating them as up/down would make a nudged stick pick a different button. + case .left, .right: break + } + } + input.onConfirm = { activate() } + input.onBack = { back() } + } + + private func step(by delta: Int) { + let target = cursor + delta + guard target >= 0, target < prompt.actions.count else { + boundaryTick &+= 1 + haptics.boundary() + return + } + cursor = target + haptics.move() + } + + private func activate() { + guard cursor >= 0, cursor < prompt.actions.count else { return } + activateTick &+= 1 + haptics.confirm() + prompt.actions[cursor].run() + } + + /// B: the cancel action, else the last one — the same fallback a system alert applies when + /// nothing carries the cancel role, so B always has a way out rather than doing nothing. + private func back() { + guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last + else { return } + activateTick &+= 1 + haptics.confirm() + action.run() + } + + /// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates. + private func tap(_ idx: Int) { + guard idx >= 0, idx < prompt.actions.count else { return } + cursor = idx + activate() + } +} +#endif