tv-anarchy/Sources/TVAnarchyiOS/JoinFleetView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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
}
}