diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index cbc45e56..dea4b3cc 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 { + #if os(macOS) + Binding(get: { pairingTarget }, set: { pairingTarget = $0 }) + #else + Binding( + get: { gamepadUIActive ? nil : pairingTarget }, + set: { pairingTarget = $0 }) + #endif + } + private var approvalChoicePresented: Binding { 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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 7fcd1ecc..1ac89896 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift index e9d025ec..daa2e6dc 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift @@ -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 } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 4017f335..1691ef31 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -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() diff --git a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift new file mode 100644 index 00000000..5fbe843f --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift @@ -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 { + 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 diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift new file mode 100644 index 00000000..89293318 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairCeremony.swift @@ -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 + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 41932d8a..fd6ff538 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -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() } } }