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>
84 lines
3.5 KiB
Swift
84 lines
3.5 KiB
Swift
import Foundation
|
|
|
|
/// App-side controller for VPN config management (the public-swarm exit plane).
|
|
/// Wraps `VPNConfigStore` (files) + `VPNCredentialStore` (Keychain) for the Settings
|
|
/// UI. Import work runs off-main; the profile list is the on-disk scan. Actuation
|
|
/// (bringing OpenVPN up on the always-on node) is NOT here — that's the install anchor / governor's job.
|
|
@Observable @MainActor public final class VPNController {
|
|
public private(set) var profiles: [OVPNProfile] = []
|
|
public private(set) var busy = false
|
|
/// Transient status/error line for the UI (cleared by the next action).
|
|
public var status: String?
|
|
|
|
public init() {}
|
|
|
|
/// Profiles grouped by provider, groups sorted, each group's profiles by name.
|
|
public var byGroup: [(group: String, profiles: [OVPNProfile])] {
|
|
Dictionary(grouping: profiles, by: \.group)
|
|
.map { (group: $0.key, profiles: $0.value.sorted { $0.name < $1.name }) }
|
|
.sorted { $0.group < $1.group }
|
|
}
|
|
|
|
public func reload() { profiles = VPNConfigStore.list() }
|
|
|
|
public func importFiles(_ urls: [URL]) async {
|
|
await run("Imported") { try VPNConfigStore.importFiles(urls) }
|
|
}
|
|
|
|
public func importZip(_ url: URL) async {
|
|
await run("Imported") { try VPNConfigStore.importZip(url) }
|
|
}
|
|
|
|
/// Route a dropped/selected URL to the right importer by extension. Zips of
|
|
/// `.ovpn` and loose `.ovpn` are both first-class (per the feature request).
|
|
public func importAny(_ urls: [URL]) async {
|
|
let zips = urls.filter { $0.pathExtension.lowercased() == "zip" }
|
|
let ovpns = urls.filter { $0.pathExtension.lowercased() == "ovpn" }
|
|
for zip in zips { await importZip(zip) }
|
|
if !ovpns.isEmpty { await importFiles(ovpns) }
|
|
if zips.isEmpty && ovpns.isEmpty { status = "Pick .ovpn files or a provider .zip." }
|
|
}
|
|
|
|
public func delete(_ profile: OVPNProfile) {
|
|
do { try VPNConfigStore.delete(profile); status = "Removed \(profile.name)" }
|
|
catch { status = error.localizedDescription }
|
|
reload()
|
|
}
|
|
|
|
public func deleteGroup(_ group: String) {
|
|
do {
|
|
try VPNConfigStore.deleteGroup(group)
|
|
VPNCredentialStore.delete(group: group)
|
|
status = "Removed \(group)"
|
|
} catch { status = error.localizedDescription }
|
|
reload()
|
|
}
|
|
|
|
// MARK: credentials
|
|
|
|
public func credential(for group: String) -> VPNCredential? { VPNCredentialStore.get(group: group) }
|
|
public func hasCredential(for group: String) -> Bool { VPNCredentialStore.has(group: group) }
|
|
|
|
public func setCredential(group: String, username: String, password: String) {
|
|
let ok = VPNCredentialStore.set(group: group, VPNCredential(username: username, password: password))
|
|
status = ok ? "Saved login for \(group)" : "Couldn't save login to the Keychain"
|
|
}
|
|
|
|
public func clearCredential(group: String) {
|
|
VPNCredentialStore.delete(group: group)
|
|
status = "Cleared login for \(group)"
|
|
}
|
|
|
|
/// Run an importer off-main, then refresh + report. Count comes from the result.
|
|
private func run(_ verb: String, _ work: @escaping () throws -> [OVPNProfile]) async {
|
|
busy = true; status = nil
|
|
defer { busy = false }
|
|
do {
|
|
let imported = try await Task.detached(priority: .userInitiated) { try work() }.value
|
|
reload()
|
|
status = "\(verb) \(imported.count) config\(imported.count == 1 ? "" : "s")"
|
|
} catch {
|
|
status = error.localizedDescription
|
|
}
|
|
}
|
|
}
|