tv-anarchy/Sources/TVAnarchyCore/VPN/VPNController.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

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
}
}
}