tv-anarchy/Sources/TVAnarchy/MeshJoinView.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.8 KiB
Swift

import SwiftUI
import CoreImage.CIFilterBuiltins
import TVAnarchyCore
/// Settings Device Mesh (Devices pillar): join a new device to the install's wg1 mesh with a scannable QR.
/// (Product term: "install" replaces old "fleet" in UI per v2/plan; internal names like fleet.json and governor fleet/* remain for now.)
/// The lp.live private-area pattern (an already-trusted device renders the QR
/// on demand; the joining device scans it and is in). Here the payload is the
/// full wg-quick config: scan it with the WireGuard app on the phone/tablet,
/// enable the tunnel, and the device is on the mesh (black, apricot, mesh DNS).
struct MeshJoinView: View {
@State private var mesh = MeshEnrollController()
@State private var newDevice = ""
/// Device whose QR is currently revealed one at a time, hidden by default
/// (the QR contains the device's private key).
@State private var revealed: String?
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Device Mesh").font(.title3).bold()
if mesh.busy { ProgressView().controlSize(.small) }
}
Text("Join a phone, tablet, or another Mac to your device mesh — the private WireGuard network that reaches your media server from anywhere. Enroll a device name, then scan the QR with the WireGuard app on the new device and switch the tunnel on. Re-enrolling an existing device just shows its QR again. (Separate from the download VPN above.)")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 10) {
TextField("New device name (e.g. phone-rachel)", text: $newDevice)
.textFieldStyle(.roundedBorder).frame(maxWidth: 240)
.help("Stable device id — re-enrolling the same name shows the same QR again")
.onSubmit { enroll(newDevice) }
Button { enroll(newDevice) } label: { Label("Enroll", systemImage: "qrcode") }
.disabled(mesh.busy || newDevice.trimmingCharacters(in: .whitespaces).isEmpty)
.help("Generate a WireGuard join config and QR for this device name")
if let status = mesh.status {
Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.padding(.top, 2)
if mesh.clients.isEmpty {
Text("No devices enrolled from this Mac yet.")
.font(.caption).foregroundStyle(.tertiary).padding(.top, 4)
} else {
GroupBox {
VStack(alignment: .leading, spacing: 6) {
ForEach(mesh.clients) { client in
clientRow(client)
if revealed == client.device {
qrPanel(client)
}
}
}
}
}
}
.task { mesh.reload() }
}
private func enroll(_ name: String) {
Task {
if let client = await mesh.enroll(device: name) {
revealed = client.device
newDevice = ""
}
}
}
private func clientRow(_ client: MeshClient) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "iphone.radiowaves.left.and.right")
.foregroundStyle(.secondary).font(.caption)
VStack(alignment: .leading, spacing: 1) {
Text(client.device).font(.caption).bold()
Text("\(client.address) · \(client.shortPublicKey)")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if revealed == client.device {
Button("Hide") { revealed = nil }.controlSize(.small)
} else {
// Reveal is purely local (lp.live style no server involvement):
// the keypair, address, and config are all in the client store.
Button { revealed = client.device } label: { Label("Show QR", systemImage: "qrcode") }
.controlSize(.small)
.help("Show this device's join QR (re-enroll by name if the hub was rebuilt)")
}
}
.padding(.vertical, 1)
}
/// The revealed join panel: QR + copy, hidden by default like lp.live's
/// "Show login QR code" (the payload includes the device's private key, so
/// it only ever renders on demand).
private func qrPanel(_ client: MeshClient) -> some View {
let config = client.config()
return VStack(alignment: .leading, spacing: 6) {
if let qr = Self.qrImage(config) {
Image(nsImage: qr)
.interpolation(.none)
.resizable()
.frame(width: 220, height: 220)
.padding(8)
.background(Color.white, in: RoundedRectangle(cornerRadius: 8))
}
Text("1 · Scan with the WireGuard app on \(client.device), then enable the tunnel.")
.font(.caption2).foregroundStyle(.secondary)
if let setup = MeshJoin.phoneSetupURL(), let setupQR = Self.qrImage(setup.absoluteString) {
Image(nsImage: setupQR)
.interpolation(.none)
.resizable()
.frame(width: 120, height: 120)
.padding(6)
.background(Color.white, in: RoundedRectangle(cornerRadius: 8))
Text("2 · Scan with the TVAnarchy app — points it at this Mac's bridge.")
.font(.caption2).foregroundStyle(.secondary)
}
Button {
copyToClipboard(config)
copied = true
} label: {
Text(copied ? "Copied ✓" : "Copy config (for Macs — paste into the WireGuard app)")
.font(.caption2)
}
.controlSize(.small)
.task(id: copied) {
guard copied else { return }
try? await Task.sleep(for: .seconds(1.6))
copied = false
}
}
.padding(.leading, 22).padding(.bottom, 4)
}
/// Render text as a QR at pixel scale (nearest-neighbor upscaled by the
/// Image view). Correction level M comfortably fits a wg-quick config.
private static func qrImage(_ text: String) -> NSImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let ci = filter.outputImage else { return nil }
let rep = NSCIImageRep(ciImage: ci)
let image = NSImage(size: rep.size)
image.addRepresentation(rep)
return image
}
}