// The phone's locked front door — the iOS twin of the macOS IntroView, with // the roles reversed: the Mac SHOWS the join QRs, the phone SCANS them (the // camera is the phone's native input). Two QRs, two owners: // № 1 (mesh / WireGuard) → belongs to the WireGuard app, which owns tunnels // on iOS; if it lands here we say so instead of failing cryptically. // № 2 (app setup, tvanarchy://bridge?…) → ours: points this app at the // Mac's bridge (LAN host + mesh fallback), probes it, and unlocks the install. import SwiftUI import LilithDesignTokens /// Join install view for iOS (Devices pillar "install pairing" / Device Mesh flow). /// File/class still named JoinFleetView for internal continuity (per v2 plan: internal fleet-engine names kept until migration). /// Product UI uses "install" and "Device Mesh". See v2/pillars/devices.md and glossary. struct JoinFleetView: View { @EnvironmentObject private var settings: BridgeSettings let onJoined: () -> Void private enum Mode { case welcome, scanning, manual } @State private var mode: Mode = .welcome @State private var note: String? @State private var probing = false /// Set when a setup payload was applied but no bridge answered — offers /// "join anyway" (away from home with the tunnel still off is normal). @State private var unreachable = false @State private var manualHost = "" @State private var manualPort = "8787" var body: some View { VStack(spacing: AppSpacing.lg) { Spacer() Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") .font(.system(size: 56)).foregroundStyle(AppColors.primary) Text("TVAnarchy").font(.largeTitle).bold() Text("This app is your install's remote — everything it shows lives on your media server. Join the install to begin.") .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center).padding(.horizontal, AppSpacing.lg) switch mode { case .welcome: welcome case .scanning: scanner case .manual: manual } if let note { Text(note).font(.footnote).foregroundStyle(AppColors.Semantic.error) .multilineTextAlignment(.center).padding(.horizontal, AppSpacing.lg) } if unreachable { Button("Join anyway — I'll turn the tunnel on later") { complete() } .font(.footnote) } Spacer() } .preferredColorScheme(.dark) } private var welcome: some View { VStack(spacing: AppSpacing.md) { Text("On your Mac: TVAnarchy → Settings → Device Mesh → enroll this phone. Scan QR 1 with the WireGuard app, then QR 2 here.") .font(.footnote).foregroundStyle(.secondary) .multilineTextAlignment(.center).padding(.horizontal, AppSpacing.lg) Button { note = nil; mode = .scanning } label: { Label("Scan setup QR", systemImage: "qrcode.viewfinder") .frame(maxWidth: 240) } .buttonStyle(.borderedProminent).controlSize(.large) Button("Enter address manually") { note = nil; mode = .manual } .font(.footnote) } } private var scanner: some View { VStack(spacing: AppSpacing.md) { QRScanView { handle($0) } .frame(height: 320) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal, AppSpacing.lg) if probing { ProgressView() } Button("Cancel") { note = nil; mode = .welcome } } } private var manual: some View { VStack(spacing: AppSpacing.md) { TextField("Bridge host (e.g. 10.9.0.3)", text: $manualHost) .textFieldStyle(.roundedBorder) .textInputAutocapitalization(.never).autocorrectionDisabled() .keyboardType(.URL) TextField("Port", text: $manualPort) .textFieldStyle(.roundedBorder).keyboardType(.numberPad) HStack { Button("Cancel") { note = nil; mode = .welcome } Button("Join") { handle("\(manualHost):\(manualPort)") } .buttonStyle(.borderedProminent) .disabled(manualHost.trimmingCharacters(in: .whitespaces).isEmpty || probing) } if probing { ProgressView() } } .padding(.horizontal, AppSpacing.lg) } private func handle(_ raw: String) { guard !probing else { return } switch JoinPayload.parse(raw) { case .setup(let host, let fallback, let port): apply(host: host, fallback: fallback, port: port) case .wireGuardConfig: note = "That's QR 1 (the mesh config) — scan it with the WireGuard app, then come back and scan QR 2 here." case .invalid: // Keep the camera running; stray codes shouldn't bounce the flow. if mode == .manual { note = "That doesn't look like a bridge address." } } } private func apply(host: String, fallback: String?, port: Int) { probing = true; note = nil; unreachable = false settings.host = host settings.fallbackHost = fallback ?? settings.fallbackHost settings.port = port Task { // Reuses the settings prober: flips activeHost to whichever leg answers. await settings.probeHosts() let reachable = await healthz(host: settings.activeHost, port: port) probing = false if reachable { complete() } else { unreachable = true note = "Saved \(host):\(port), but no bridge answered. At home? Check the Mac is on. Away? Enable the WireGuard tunnel first." } } } private func complete() { FleetGate.latch() onJoined() } private func healthz(host: String, port: Int) async -> Bool { guard let url = URL(string: "http://\(host):\(port)/healthz") else { return false } var request = URLRequest(url: url) request.timeoutInterval = 2 guard let (_, response) = try? await URLSession.shared.data(for: request) else { return false } return (response as? HTTPURLResponse)?.statusCode == 200 } }