feat(apple): pairing you can actually do with a controller
PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but on iOS/macOS a controller cannot reach a text field, type into it, or press the button underneath — so for anyone in the console UI, pairing (the ONE thing between a fresh install and a first stream) ended at "now touch the screen". GamepadPairView is the same ceremony in the gamepad UI's own vocabulary: the vertical focus list the settings and add-host screens use, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer. It mirrors GamepadAddHostView field for field, because it is the same interaction and someone who has added a host should recognise it immediately. The ceremony itself moved to a shared `PairCeremony` used by both presentations, so they can never disagree about what a wrong PIN means, what a host rejection says, or when a late result must be discarded. On iOS it is a shell layer like settings and add-host, and it LEADS the shell's screen order: it blocks a connect the user already asked for and can be raised from on top of the library (launching a title on an unpaired host), so it has to win; backing out reveals whatever it interrupted. macOS has no shell, so its sheet switches content by mode instead. tvOS is untouched. macOS + tvOS typecheck; console UI verified opening Settings in the iPad simulator with the pair screen wired into the shell.
This commit is contained in:
@@ -390,8 +390,21 @@ struct ContentView: View {
|
||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
#if !os(tvOS)
|
||||
.sheet(item: $pairingTarget) { host in
|
||||
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
|
||||
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
|
||||
// instead — there the pair screen is one of the shell's in-place layers, exactly like
|
||||
// settings and add-host (see `touchPairingTarget`).
|
||||
.sheet(item: touchPairingTarget) { host in
|
||||
#if os(macOS)
|
||||
if gamepadUIActive {
|
||||
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
|
||||
.frame(width: 660, height: 620)
|
||||
} else {
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
}
|
||||
#else
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
#endif
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
@@ -442,6 +455,20 @@ struct ContentView: View {
|
||||
set: { libraryTarget = $0 })
|
||||
}
|
||||
|
||||
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
|
||||
/// in place — the same proxy the library uses, and for the same reason: every writer keeps
|
||||
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
|
||||
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
|
||||
private var touchPairingTarget: Binding<StoredHost?> {
|
||||
#if os(macOS)
|
||||
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
|
||||
#else
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : pairingTarget },
|
||||
set: { pairingTarget = $0 })
|
||||
#endif
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
@@ -598,7 +625,8 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
} else {
|
||||
@@ -615,7 +643,8 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
|
||||
@@ -69,6 +69,13 @@ struct GamepadHomeView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
|
||||
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen —
|
||||
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
|
||||
/// one thing a console-UI user simply could not do. See GamepadPairView.
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
/// Pin the verified fingerprint and connect — ContentView's `handlePaired`.
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||
@ObservedObject var waker: HostWaker
|
||||
@@ -234,6 +241,10 @@ struct GamepadHomeView: View {
|
||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||
private var topScreen: GamepadScreen? {
|
||||
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let host = libraryTarget { return .library(host) }
|
||||
@@ -256,6 +267,12 @@ struct GamepadHomeView: View {
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
onPaired: { onPaired(host, $0) },
|
||||
close: { if !transitioning { pairingTarget = nil } },
|
||||
controllerActive: active)
|
||||
case .library(let host):
|
||||
GamepadLibraryScreen(
|
||||
store: store, host: host,
|
||||
@@ -305,7 +322,10 @@ struct GamepadHomeView: View {
|
||||
topScreen == nil && !transitioning
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
// `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
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ import SwiftUI
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case pair(StoredHost)
|
||||
case library(StoredHost)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
case .library(let host): return "library-\(host.id.uuidString)"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,7 @@ enum GamepadScreen: Identifiable {
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost: return true
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,8 @@ private struct ShotGamepadHome: View {
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
}
|
||||
}
|
||||
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||
// PairSheet, and the reason a console-UI user can pair at all.
|
||||
//
|
||||
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
|
||||
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
|
||||
// underneath — so for anyone in the console UI, pairing (the ONE thing standing between a fresh
|
||||
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
|
||||
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
|
||||
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
|
||||
//
|
||||
// Structure deliberately mirrors GamepadAddHostView field for field — the two screens are the same
|
||||
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
|
||||
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
|
||||
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadPairView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
let host: StoredHost
|
||||
/// Called with the verified host fingerprint after a successful ceremony — the caller pins it
|
||||
/// and connects (ContentView's `handlePaired`).
|
||||
let onPaired: (Data) -> Void
|
||||
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
|
||||
/// environment dismiss.
|
||||
var close: (() -> Void)?
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadAddHostView's twin).
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
|
||||
#endif
|
||||
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { performClose() },
|
||||
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
|
||||
// background thread and its result rewrites this screen, so letting B peel a layer
|
||||
// or A fire a second ceremony underneath it would race the completion.
|
||||
isActive: controllerActive && editing == nil && !ceremony.busy
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomTray
|
||||
// Equal distance from the left and bottom edges for the legend pill (see
|
||||
// GamepadHomeView).
|
||||
.padding(.horizontal, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
}
|
||||
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
|
||||
.onChange(of: pin) { _, value in
|
||||
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
|
||||
}
|
||||
// Any dismissal path abandons an in-flight ceremony — a late success must not pin and
|
||||
// connect to a host the user backed out of.
|
||||
.onDisappear { ceremony.abandon() }
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
|
||||
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
|
||||
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
|
||||
private static let maxPINLength = 12
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Pair with \(host.displayName)")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if !compact {
|
||||
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard tray while editing, the status line + controls legend otherwise.
|
||||
@ViewBuilder private var bottomTray: some View {
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field (see GamepadAddHostView) — the tray's input wiring
|
||||
// captured the previous binding on appear.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
statusLine
|
||||
GamepadHintBar(hints: [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
||||
action: { performClose() }),
|
||||
])
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
|
||||
/// its space so the legend never jumps when a failure arrives.
|
||||
@ViewBuilder private var statusLine: some View {
|
||||
Group {
|
||||
if ceremony.busy {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small).tint(ink.fg(0.7))
|
||||
Text("Pairing with \(host.displayName)…").foregroundStyle(ink.fg(0.7))
|
||||
}
|
||||
} else if let error = ceremony.errorText {
|
||||
Text(error).foregroundStyle(.red)
|
||||
} else {
|
||||
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
|
||||
Text(" ").foregroundStyle(.clear)
|
||||
}
|
||||
}
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
|
||||
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
|
||||
}
|
||||
|
||||
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||
/// the environment dismiss under a macOS sheet.
|
||||
private func performClose() {
|
||||
ceremony.abandon()
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
var value = ""
|
||||
var placeholder = ""
|
||||
var isAction = false
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
[
|
||||
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
|
||||
Row(
|
||||
id: "name", label: "Device name", value: clientName,
|
||||
placeholder: "How the host lists this device"),
|
||||
Row(id: "pair", label: "Pair & Connect", isAction: true),
|
||||
]
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Pair & Connect", systemImage: "lock.shield")
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(ink.fg)
|
||||
Spacer(minLength: 12)
|
||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||
.font(.geistFixed(m.valueFont, .medium))
|
||||
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head) // keep the end of a long name visible while typing
|
||||
if editing == row.id {
|
||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||
Rectangle()
|
||||
.fill(ink.accent)
|
||||
.frame(width: 2, height: m.labelFont + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func activate(id: String) {
|
||||
guard !ceremony.busy else { return }
|
||||
switch id {
|
||||
case "pair":
|
||||
guard canPair else {
|
||||
// Not pairable yet — jump straight to what's missing instead of a dead press,
|
||||
// matching the add-host screen's Add row.
|
||||
focusID = "pin"
|
||||
openKeyboard("pin")
|
||||
return
|
||||
}
|
||||
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
|
||||
fingerprint in
|
||||
onPaired(fingerprint)
|
||||
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
|
||||
// success. Closing is all that's left to do.
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
default:
|
||||
openKeyboard(id)
|
||||
}
|
||||
}
|
||||
|
||||
private var canPair: Bool {
|
||||
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
|
||||
}
|
||||
|
||||
private func openKeyboard(_ id: String) {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
|
||||
}
|
||||
|
||||
private func closeKeyboard() {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||
}
|
||||
|
||||
private func editingBinding(_ id: String) -> Binding<String> {
|
||||
id == "pin" ? $pin : $clientName
|
||||
}
|
||||
|
||||
/// The legend's Delete cell — see GamepadAddHostView's twin for why this edits the binding
|
||||
/// rather than reaching into the keyboard.
|
||||
private func backspace(_ id: String) {
|
||||
let binding = editingBinding(id)
|
||||
guard !binding.wrappedValue.isEmpty else { return }
|
||||
binding.wrappedValue.removeLast()
|
||||
}
|
||||
|
||||
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
|
||||
private func allowedCharacters(_ id: String) -> CharacterSet? {
|
||||
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,86 @@
|
||||
// The SPAKE2 PIN ceremony itself, with no opinion about how it's presented. Two screens run it:
|
||||
// `PairSheet` (the touch/desktop Form, and tvOS's focus-engine layout) and `GamepadPairView` (the
|
||||
// controller-driven console screen). The ceremony is the part that must not diverge between them —
|
||||
// it decides what counts as a wrong PIN, what a rejection means, and which failures are worth
|
||||
// telling the user apart — so it lives here once rather than being copied into the second caller.
|
||||
//
|
||||
// Threading: `pair()` and the identity load both BLOCK, so they run off the main actor; every
|
||||
// published mutation lands back on it.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PairCeremony: ObservableObject {
|
||||
/// A ceremony is in flight — callers disable their commit action and show a spinner.
|
||||
@Published private(set) var busy = false
|
||||
/// The last failure, in user-facing terms; cleared when a new attempt starts.
|
||||
@Published var errorText: String?
|
||||
|
||||
/// Dismissing the presenting screen must abandon an in-flight ceremony: the blocking `pair()`
|
||||
/// call can't be interrupted, so its completion checks this token and self-discards — a late
|
||||
/// success must NOT pin and auto-connect to a host the user cancelled out of. A fresh token
|
||||
/// per attempt, so abandoning one attempt can't silence the next.
|
||||
private var token = Token()
|
||||
|
||||
private final class Token: @unchecked Sendable {
|
||||
var cancelled = false
|
||||
}
|
||||
|
||||
/// Run the ceremony. `onPaired` receives the host's now-VERIFIED fingerprint — the caller pins
|
||||
/// it and connects; no manual fingerprint comparison is needed, because the host proved itself
|
||||
/// with the same PIN.
|
||||
func run(
|
||||
host address: String, port: UInt16, pin rawPIN: String, clientName rawName: String,
|
||||
onPaired: @escaping (Data) -> Void
|
||||
) {
|
||||
busy = true
|
||||
errorText = nil
|
||||
let pin = rawPIN.trimmingCharacters(in: .whitespaces)
|
||||
let name = rawName.trimmingCharacters(in: .whitespaces)
|
||||
token = Token()
|
||||
let token = token
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// Identity load + the ceremony both block — keep them off the main actor.
|
||||
// loadForPairing is the strict variant: the host durably trusts this
|
||||
// identity, so it must have made it into the Keychain.
|
||||
let result = Result {
|
||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||
return try PunktfunkKit.pair(
|
||||
host: address, port: port, identity: identity,
|
||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return } // screen dismissed mid-ceremony
|
||||
self.busy = false
|
||||
switch result {
|
||||
case .success(let fingerprint):
|
||||
onPaired(fingerprint)
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
self.errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
// another device) — show that instead of the guessing-game fallback.
|
||||
self.errorText = rejection.userMessage
|
||||
case .failure(is ClientIdentityStore.IdentityError):
|
||||
self.errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
self.errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||
+ "isolation)?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The presenting screen went away — discard whatever is still in flight. Called from every
|
||||
/// dismissal path (an explicit Cancel, a swipe, B on a controller), which is why it is safe to
|
||||
/// call when nothing is running.
|
||||
func abandon() {
|
||||
token.cancelled = true
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,15 @@
|
||||
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
|
||||
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
||||
// client's identity in return.
|
||||
//
|
||||
// This is the TOUCH/desktop presentation (and tvOS's, where the focus engine drives the same
|
||||
// fields). A controller can't reach a `Form`'s text fields on iOS/macOS, so the console UI
|
||||
// presents `GamepadPairView` instead — same ceremony, via the shared `PairCeremony`.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
|
||||
/// can't be interrupted, so its completion checks this flag and self-discards — a late
|
||||
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
|
||||
/// touched on the main actor.
|
||||
private final class CeremonyToken: @unchecked Sendable {
|
||||
var cancelled = false
|
||||
}
|
||||
|
||||
struct PairSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let host: StoredHost
|
||||
@@ -30,9 +26,10 @@ struct PairSheet: View {
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
@State private var busy = false
|
||||
@State private var errorText: String?
|
||||
@State private var token = CeremonyToken()
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
private var errorText: String? { ceremony.errorText }
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case pin, clientName
|
||||
@@ -64,7 +61,7 @@ struct PairSheet: View {
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
if busy {
|
||||
@@ -78,7 +75,7 @@ struct PairSheet: View {
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Pair with \(host.displayName)")
|
||||
.onDisappear { token.cancelled = true }
|
||||
.onDisappear { ceremony.abandon() }
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
switch field {
|
||||
case .pin:
|
||||
@@ -142,7 +139,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
@@ -180,7 +177,7 @@ struct PairSheet: View {
|
||||
.presentationDragIndicator(busy ? .hidden : .visible)
|
||||
#endif
|
||||
.interactiveDismissDisabled(busy)
|
||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||
.onDisappear { ceremony.abandon() } // any other dismissal path
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -195,47 +192,11 @@ struct PairSheet: View {
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
let pin = pin.trimmingCharacters(in: .whitespaces)
|
||||
let name = clientName.trimmingCharacters(in: .whitespaces)
|
||||
let address = host.address
|
||||
let port = host.port
|
||||
let token = token
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// Identity load + the ceremony both block — keep them off the main actor.
|
||||
// loadForPairing is the strict variant: the host durably trusts this
|
||||
// identity, so it must have made it into the Keychain.
|
||||
let result = Result {
|
||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||
return try PunktfunkKit.pair(
|
||||
host: address, port: port, identity: identity,
|
||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
|
||||
busy = false
|
||||
switch result {
|
||||
case .success(let fingerprint):
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
// another device) — show that instead of the guessing-game fallback.
|
||||
errorText = rejection.userMessage
|
||||
case .failure(is ClientIdentityStore.IdentityError):
|
||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||
+ "isolation)?"
|
||||
}
|
||||
}
|
||||
ceremony.run(
|
||||
host: host.address, port: host.port, pin: pin, clientName: clientName
|
||||
) { fingerprint in
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user