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>
127 lines
5.5 KiB
Swift
127 lines
5.5 KiB
Swift
import SwiftUI
|
|
import TVAnarchyCore
|
|
|
|
/// The locked front door. Until this Mac has joined an install (via Device Mesh)
|
|
/// there's nothing to show, so a fresh install renders ONLY this page: what the
|
|
/// app is, plus the two ways in (scan the join QR an already-trusted device
|
|
/// displays from Settings → Device Mesh, or paste the same config as text).
|
|
/// Joining persists the credentials and flips the shell over to the real UI.
|
|
struct IntroView: View {
|
|
let onJoined: () -> Void
|
|
|
|
private enum Mode { case welcome, scanning, pasting, joined }
|
|
@State private var mode: Mode = .welcome
|
|
@State private var pasted = ""
|
|
@State private var error: String?
|
|
@State private var joinedConfig: WGQuickConfig?
|
|
|
|
private let fleet = FleetState()
|
|
|
|
var body: some View {
|
|
VStack(spacing: 18) {
|
|
Spacer(minLength: 24)
|
|
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
|
|
.font(.system(size: 64)).foregroundStyle(.tint)
|
|
Text("TVAnarchy").font(.largeTitle).bold()
|
|
Text("Your media install's remote control — browse the library, play anywhere, manage downloads. Everything lives on the devices in your install, so the first step is joining this Mac to it.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center).frame(maxWidth: 420)
|
|
|
|
switch mode {
|
|
case .welcome: welcome
|
|
case .scanning: scanner
|
|
case .pasting: paster
|
|
case .joined: done
|
|
}
|
|
|
|
if let error {
|
|
Text(error).font(.caption).foregroundStyle(.red)
|
|
.frame(maxWidth: 420)
|
|
}
|
|
Spacer(minLength: 24)
|
|
}
|
|
.padding(32)
|
|
.frame(minWidth: 560, minHeight: 540)
|
|
}
|
|
|
|
private var welcome: some View {
|
|
VStack(spacing: 10) {
|
|
Text("On a Mac that's already part of the install, open Settings → Device Mesh, enroll this Mac by name, and bring the QR here.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center).frame(maxWidth: 420)
|
|
HStack(spacing: 12) {
|
|
Button { error = nil; mode = .scanning } label: {
|
|
Label("Scan join QR", systemImage: "qrcode.viewfinder")
|
|
}
|
|
.controlSize(.large).buttonStyle(.borderedProminent)
|
|
Button { error = nil; mode = .pasting } label: {
|
|
Label("Paste config", systemImage: "doc.on.clipboard")
|
|
}
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scanner: some View {
|
|
VStack(spacing: 10) {
|
|
QRScannerView { payload in join(payload) }
|
|
.frame(width: 360, height: 270)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary))
|
|
Text("Hold the join QR up to this Mac's camera.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Button("Cancel") { error = nil; mode = .welcome }
|
|
}
|
|
}
|
|
|
|
private var paster: some View {
|
|
VStack(spacing: 10) {
|
|
TextEditor(text: $pasted)
|
|
.font(.caption.monospaced())
|
|
.frame(width: 420, height: 180)
|
|
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary))
|
|
Text("Paste the WireGuard config from the enrolling Mac (Copy config under its QR).")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
Button("Cancel") { error = nil; mode = .welcome }
|
|
Button("Join") { join(pasted) }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(pasted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var done: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "checkmark.seal.fill").font(.system(size: 40)).foregroundStyle(.green)
|
|
Text("Joined as \(joinedConfig?.device ?? "this Mac") · \(joinedConfig?.address ?? "")")
|
|
.font(.headline)
|
|
Text("Credentials are saved. At home the install is reachable over the LAN right away; for anywhere-access, import the same config into the WireGuard app and enable the tunnel.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center).frame(maxWidth: 420)
|
|
HStack(spacing: 12) {
|
|
if let conf = joinedConfig {
|
|
Button("Copy config for WireGuard") { copyToClipboard(conf.text) }
|
|
}
|
|
Button("Enter TVAnarchy") { onJoined() }
|
|
.buttonStyle(.borderedProminent).controlSize(.large)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func join(_ text: String) {
|
|
let host = Host.current().localizedName?.lowercased()
|
|
.map { $0.isLetter || $0.isNumber ? $0 : "-" } ?? []
|
|
let fallback = String(host).split(separator: "-").joined(separator: "-")
|
|
do {
|
|
joinedConfig = try fleet.join(configText: text,
|
|
fallbackDevice: fallback.isEmpty ? "this-mac" : fallback)
|
|
error = nil
|
|
mode = .joined
|
|
} catch {
|
|
// Keep scanning/pasting — a half-read QR or stray clipboard content
|
|
// shouldn't bounce the user back to the start.
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
}
|