Files
punktfunk/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift
enricobuehler 09bac99090 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.
2026-08-10 08:12:50 +02:00

224 lines
8.9 KiB
Swift

// PIN pairing sheet. The host shows the pairing PIN in its web console (port 47992 →
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
// attacker exactly one online guess — for the user a typo just means "try again" (the
// 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
struct PairSheet: View {
@Environment(\.dismiss) private var dismiss
let host: StoredHost
/// Called with the verified host fingerprint after a successful ceremony.
let onPaired: (Data) -> Void
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
@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
var id: String { rawValue }
}
@State private var editing: EditField?
#endif
var body: some View {
#if os(tvOS)
VStack(spacing: 24) {
Text("The PIN is shown in the host's web console "
+ "(https://<host>:47992 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.")
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TVFieldRow(
label: "PIN", value: pin, placeholder: "Shown in the host's web console"
) { editing = .pin }
TVFieldRow(
label: "Device name", value: clientName, placeholder: "Apple TV"
) { editing = .clientName }
if let errorText {
Text(errorText)
.font(.geist(22, relativeTo: .callout))
.foregroundStyle(.red)
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
ceremony.abandon()
dismiss()
}
if busy {
ProgressView()
}
Button("Pair & Connect") { runCeremony() }
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { ceremony.abandon() }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
TVTextEntry(
title: "PIN (shown in the host's web console)", text: pin,
keyboardType: .numberPad
) {
pin = $0.trimmingCharacters(in: .whitespaces)
editing = nil
}
case .clientName:
TVTextEntry(title: "Device name", text: clientName) {
clientName = $0
editing = nil
}
}
}
#else
VStack(spacing: 0) {
Form {
Section {
TextField(
"PIN", text: $pin,
prompt: Text("Shown in the host's web console"))
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
#if os(iOS)
.keyboardType(.numberPad)
#endif
TextField(
"Client name", text: $clientName,
prompt: Text(Self.clientNamePrompt))
#if os(tvOS)
.labelsHidden() // prefilled → tvOS floats the label off-center
#endif
} header: {
Label("Pair with \(host.displayName)", systemImage: "lock.shield")
.foregroundStyle(.tint)
} footer: {
Text("The PIN is shown in the host's web console "
+ "(https://<host>:47992 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint "
+ "comparison needed.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
if let errorText {
Section {
Text(errorText)
.font(.geist(16, relativeTo: .callout))
.foregroundStyle(.red)
}
}
}
#if !os(tvOS)
.formStyle(.grouped)
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
// explicit Geist Mono font.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif
HStack {
Button("Cancel", role: .cancel) {
ceremony.abandon()
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
if busy {
ProgressView()
.controlSize(.small)
.padding(.trailing, 8)
}
Button("Pair & Connect") { runCeremony() }
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
.controlSize(.large)
#endif
.padding(16)
}
#if os(macOS)
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
#endif
#if os(iOS)
// Bottom sheet instead of a full-screen modal (Liquid Glass background on iOS 26).
// .medium rests; .large is included so the sheet grows to keep the Pair/Cancel row
// above the keyboard when the PIN field is focused. Hide the grabber while the ceremony
// is in flight — dismissal is disabled then (interactiveDismissDisabled), so a drag
// would only rubber-band; the always-enabled Cancel button is the exit.
.presentationDetents([.medium, .large])
.presentationDragIndicator(busy ? .hidden : .visible)
#endif
.interactiveDismissDisabled(busy)
.onDisappear { ceremony.abandon() } // any other dismissal path
#endif
}
/// The field prompt names the device you are actually on — it said "this Mac" on every
/// platform, which on an iPhone is simply wrong.
private static var clientNamePrompt: String {
#if os(macOS)
"How the host lists this Mac"
#else
"How the host lists this device"
#endif
}
private func runCeremony() {
ceremony.run(
host: host.address, port: host.port, pin: pin, clientName: clientName
) { fingerprint in
onPaired(fingerprint)
dismiss()
}
}
}
#if DEBUG
extension PairSheet {
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
/// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's
/// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
/// mid-entry, with a live primary button.
///
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
/// the private state.
init(
host: StoredHost, shotPIN: String, shotClientName: String,
onPaired: @escaping (Data) -> Void
) {
self.init(host: host, onPaired: onPaired)
_pin = State(initialValue: shotPIN)
_clientName = State(initialValue: shotClientName)
}
}
#endif