Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

161 lines
6.1 KiB
Swift

import JCodeKit
import SwiftUI
/// First-run pairing: scan QR or type host/port/code.
struct PairingView: View {
@Environment(AppModel.self) private var model
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var host = ""
@State private var port = String(Gateway.defaultPort)
@State private var code = ""
@State private var isPairing = false
@State private var errorMessage: String?
@State private var showScanner = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage {
ErrorBanner(message: errorMessage) {
self.errorMessage = nil
}
.padding(.horizontal, -16)
}
Card {
VStack(alignment: .leading, spacing: 16) {
field("Host", text: $host, placeholder: "devbox.tailnet.ts.net")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
field("Port", text: $port, placeholder: "7643")
.keyboardType(.numberPad)
field("Pairing code", text: $code, placeholder: "123456")
.keyboardType(.numberPad)
}
}
Button(action: pair) {
HStack {
if isPairing && !reduceMotion {
ProgressView().tint(.black)
}
Text(isPairing ? "Pairing..." : "Pair")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(canPair ? Theme.mint : Theme.surfaceElevated)
.foregroundStyle(canPair ? .black : Theme.textTertiary)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canPair || isPairing)
.accessibilityLabel("Pair")
.accessibilityHint("Connects using the host, port, and code above")
Button {
showScanner = true
} label: {
Label("Scan QR from `jcode pair`", systemImage: "qrcode.viewfinder")
.font(.subheadline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Theme.surface)
.foregroundStyle(Theme.textPrimary)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.border, lineWidth: 1)
)
}
.accessibilityLabel("Scan QR code")
.accessibilityHint("Opens the camera to scan a pairing code")
Text("Run `jcode pair` on your machine, then scan the QR code or enter the code manually. Traffic stays on your tailnet.")
.font(.footnote)
.foregroundStyle(Theme.textTertiary)
}
.padding(16)
}
.scrollDismissesKeyboard(.interactively)
.dynamicTypeSize(.large ... .accessibility3)
.sheet(isPresented: $showScanner) {
QRScannerView { scanned in
showScanner = false
if let payload = PairURI.parse(scanned) {
host = payload.gateway.host
port = String(payload.gateway.port)
code = payload.code
pair()
} else {
errorMessage = "Not a jcode pairing QR code"
}
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Text("jcode")
.font(Theme.mono(34, weight: .bold))
.foregroundStyle(Theme.textPrimary)
Text("Pair with a server on your tailnet")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
}
.padding(.top, 32)
}
private var canPair: Bool {
!host.trimmingCharacters(in: .whitespaces).isEmpty && !code.isEmpty
&& UInt16(port) != nil
}
private func field(_ label: String, text: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
TextField(placeholder, text: text)
.font(Theme.mono(16))
.foregroundStyle(Theme.textPrimary)
.padding(12)
.background(Theme.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityLabel(label)
}
}
private func pair() {
guard let portValue = UInt16(port) else { return }
let gateway = Gateway(host: host.trimmingCharacters(in: .whitespaces), port: portValue)
let pairCode = code
isPairing = true
errorMessage = nil
Task {
defer { isPairing = false }
do {
try await model.pair(
gateway: gateway,
code: pairCode,
deviceName: UIDevice.current.name
)
} catch let error as PairingClient.PairingError {
switch error {
case .invalidCode(let message):
errorMessage = message
case .serverError(_, let message):
errorMessage = message
case .invalidResponse:
errorMessage = "Unexpected response from server"
}
} catch {
errorMessage = "Could not reach \(gateway.host):\(gateway.port)"
}
}
}
}