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.8 KiB
Swift
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
|
|
}
|
|
}
|