feat(apple): the connect path's modals are pad-navigable

`.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.
This commit is contained in:
2026-08-10 09:56:25 +02:00
parent 8ab4918923
commit 522ac7bd49
3 changed files with 359 additions and 16 deletions
@@ -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<Bool> {
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<StoredHost?> {
@@ -470,18 +579,22 @@ struct ContentView: View {
}
private var approvalChoicePresented: Binding<Bool> {
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
Binding(
get: { approvalChoice != nil && !consolePromptShowing },
set: { if !$0 { approvalChoice = nil } })
}
private var awaitingApprovalPresented: Binding<Bool> {
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
Binding(
get: { awaitingApproval != nil && !consolePromptShowing },
set: { if !$0 { awaitingApproval = nil } })
}
private var connectionErrorPresented: Binding<Bool> {
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<Bool> {
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
@@ -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
}
@@ -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