Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
6.4 KiB
Swift
148 lines
6.4 KiB
Swift
// 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
|
|
}
|
|
}
|