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>
89 lines
4.1 KiB
Swift
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
|
|
}
|
|
}
|