tv-anarchy/Sources/TVAnarchyCore/Mesh/MeshEnrollController.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

89 lines
4.1 KiB
Swift

import Foundation
/// App-side enrollment of a new device onto the wg1 mesh (Devices pillar: "install pairing" / Device Mesh surface).
/// The in-app port of `session-tools/bin/wg-phone-add`, sharing its client store so the two stay
/// idempotent against each other. Product model: "install" = the group; "fleet" is internal (see v2/plan.md §1 "not a v1/ split", pillars/devices.md, glossary.md).
/// The flow (all user-initiated from Settings Device Mesh):
///
/// 1. reuse or generate the device's keypair (CryptoKit, no `wg` binary),
/// 2. read the hub config over ssh to pick the lowest free 10.9.0.x slot,
/// 3. append the `[Peer]` block on the hub + `wg syncconf` (skipped when the
/// hub already has this pubkey safe to re-run),
/// 4. persist keys + render the client config locally.
///
/// The UI then shows the config as a QR (lp.live private-area style) for the
/// WireGuard app on the joining device to scan.
@Observable @MainActor public final class MeshEnrollController {
public private(set) var clients: [MeshClient] = []
public private(set) var busy = false
/// Transient status/error line for the UI (cleared by the next action).
public var status: String?
private let store: MeshClientStore
private let ssh: SSHTransport
public init(store: MeshClientStore = MeshClientStore(),
ssh: SSHTransport = SSHTransport(endpoints: MeshDefaults.hubSSHEndpoints)) {
self.store = store
self.ssh = ssh
}
public func reload() { clients = store.list() }
/// Enroll `device` (or re-run for an existing one) and return the ready
/// client. Mutates the hub config only when the pubkey isn't there yet.
@discardableResult
public func enroll(device rawDevice: String) async -> MeshClient? {
let device = rawDevice.trimmingCharacters(in: .whitespaces)
guard MeshJoin.isValidDeviceName(device) else {
status = "Device names: letters, digits, - and _ only."
return nil
}
busy = true; status = nil
defer { busy = false; reload() }
let keypair = store.keypair(device: device) ?? WireGuardKeypair()
let conf = MeshDefaults.hubConfigPath
// Probe + read in one round trip. `||`-tails keep every branch exit-0 so
// SSHTransport can't mistake "pubkey absent" for a dead endpoint.
let probe = await ssh.runRemote(
"grep -qxF 'PublicKey = \(keypair.publicKey)' \(conf) && echo known || echo new; cat \(conf) || true")
guard probe.ok, let verdict = probe.stdout.split(separator: "\n").first else {
status = "Can't reach the hub (\(ssh.detail)) — mesh or ssh key problem."
return nil
}
let hubKnowsKey = verdict == "known"
// Address: keep the stored slot; otherwise lowest free from the hub conf.
let address: String
if let existing = store.load(device: device)?.address {
address = existing
} else if let free = MeshJoin.freeAddress(hubConfigText: probe.stdout) {
address = free
} else {
status = "No free 10.9.0.x slot on the hub."
return nil
}
if !hubKnowsKey {
let block = MeshJoin.hubPeerBlock(device: device, publicKey: keypair.publicKey, address: address)
let append = await ssh.runRemote(
"printf %s \(SSHTransport.shq(block + "\n")) >> \(conf) && wg syncconf wg1 <(wg-quick strip wg1)")
guard append.ok else {
status = "Hub config update failed: \(append.stderr.isEmpty ? "exit \(append.status)" : append.stderr)"
return nil
}
}
let client = MeshClient(device: device, privateKey: keypair.privateKey,
publicKey: keypair.publicKey, address: address)
do { try store.save(client) } catch {
status = "Enrolled on the hub but couldn't save locally: \(error.localizedDescription)"
return nil
}
status = hubKnowsKey ? "\(device) was already enrolled — config refreshed."
: "\(device) enrolled at \(address)."
return client
}
}