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>
95 lines
4.2 KiB
Swift
95 lines
4.2 KiB
Swift
import Foundation
|
|
|
|
/// One enrolled mesh client as stored on disk (a phone, tablet, or another Mac
|
|
/// that joined via the QR flow). The keypair lives here so re-showing the QR
|
|
/// never mints a new identity.
|
|
public struct MeshClient: Identifiable, Sendable, Equatable {
|
|
public let device: String
|
|
public let privateKey: String
|
|
public let publicKey: String
|
|
public let address: String
|
|
public var id: String { device }
|
|
|
|
/// `pubkey…` shortened for list display.
|
|
public var shortPublicKey: String { String(publicKey.prefix(12)) + "…" }
|
|
|
|
/// The full wg-quick config text for this client (what the QR encodes).
|
|
public func config(generatedAt: Date = Date()) -> String {
|
|
MeshJoin.clientConfig(device: device, privateKey: privateKey,
|
|
address: address, generatedAt: generatedAt)
|
|
}
|
|
}
|
|
|
|
/// The on-disk client store at `~/.config/wg-mesh/clients/<device>/` —
|
|
/// **shared with `wg-phone-add`** (same layout: `private.key`, `public.key`,
|
|
/// `address`, `<device>.conf`), so devices enrolled from the terminal show up
|
|
/// in the app and vice versa. Private keys: dirs 0700, files 0600. The root is
|
|
/// injectable for tests; everything else scans the filesystem (the directory IS
|
|
/// the source of truth, same pattern as `VPNConfigStore`).
|
|
public struct MeshClientStore: Sendable {
|
|
private let root: URL
|
|
private static let fm = FileManager.default
|
|
|
|
public init(root: URL? = nil) {
|
|
self.root = root ?? FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".config/wg-mesh/clients", isDirectory: true)
|
|
}
|
|
|
|
private func dir(_ device: String) -> URL {
|
|
root.appendingPathComponent(device, isDirectory: true)
|
|
}
|
|
|
|
/// Every stored client whose keypair parses, sorted by name. Clients with a
|
|
/// key but no recorded address are skipped (half-enrolled; re-enroll fixes).
|
|
public func list() -> [MeshClient] {
|
|
guard let dirs = try? Self.fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)
|
|
else { return [] }
|
|
return dirs
|
|
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
|
|
.compactMap { load(device: $0.lastPathComponent) }
|
|
.sorted { $0.device < $1.device }
|
|
}
|
|
|
|
public func load(device: String) -> MeshClient? {
|
|
let d = dir(device)
|
|
guard let priv = read(d.appendingPathComponent("private.key")),
|
|
let keypair = WireGuardKeypair(privateKeyBase64: priv),
|
|
let address = read(d.appendingPathComponent("address"))
|
|
else { return nil }
|
|
return MeshClient(device: device, privateKey: keypair.privateKey,
|
|
publicKey: keypair.publicKey, address: address)
|
|
}
|
|
|
|
/// The stored keypair for a device, when one exists (enrollment reuses it).
|
|
public func keypair(device: String) -> WireGuardKeypair? {
|
|
read(dir(device).appendingPathComponent("private.key"))
|
|
.flatMap(WireGuardKeypair.init(privateKeyBase64:))
|
|
}
|
|
|
|
/// Persist an enrolled client: keys + address + rendered `.conf`, with
|
|
/// key-material permissions (0700 dir, 0600 files).
|
|
public func save(_ client: MeshClient) throws {
|
|
let d = dir(client.device)
|
|
try Self.fm.createDirectory(at: d, withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
try write(client.privateKey, to: d.appendingPathComponent("private.key"))
|
|
try write(client.publicKey, to: d.appendingPathComponent("public.key"))
|
|
try write(client.address, to: d.appendingPathComponent("address"))
|
|
try write(client.config(), to: d.appendingPathComponent("\(client.device).conf"))
|
|
}
|
|
|
|
private func read(_ url: URL) -> String? {
|
|
(try? String(contentsOf: url, encoding: .utf8))?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.nonEmpty
|
|
}
|
|
|
|
private func write(_ text: String, to url: URL) throws {
|
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
|
try Self.fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
var nonEmpty: String? { isEmpty ? nil : self }
|
|
}
|