import Foundation public enum VPNStoreError: Error, LocalizedError { case extractFailed(String) case noConfigsFound case ioError(String) public var errorDescription: String? { switch self { case .extractFailed(let s): "Couldn't unpack the archive: \(s)" case .noConfigsFound: "No .ovpn configs were found in that file." case .ioError(let s): "File error: \(s)" } } } /// Manages the on-disk store of imported OpenVPN configs at /// `~/.config/tv-anarchy/vpn//.ovpn` (sidecar certs alongside). The /// directory scan IS the source of truth (no separate manifest) — same pattern as /// the library index. All methods touch the filesystem; call off the main thread. public enum VPNConfigStore { private static let fm = FileManager.default public static func rootURL() -> URL { fm.homeDirectoryForCurrentUser.appendingPathComponent(".config/tv-anarchy/vpn", isDirectory: true) } private static func groupURL(_ group: String) -> URL { rootURL().appendingPathComponent(OVPNParser.sanitizeGroup(group), isDirectory: true) } // MARK: read /// Every imported profile, scanned from disk, grouped dir → profiles. public static func list() -> [OVPNProfile] { guard let groups = try? fm.contentsOfDirectory(at: rootURL(), includingPropertiesForKeys: nil) else { return [] } var out: [OVPNProfile] = [] for groupDir in groups where (try? groupDir.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { let group = groupDir.lastPathComponent let files = (try? fm.contentsOfDirectory(at: groupDir, includingPropertiesForKeys: nil)) ?? [] for file in files where file.pathExtension.lowercased() == "ovpn" { out.append(profile(for: file, group: group)) } } return out.sorted { ($0.group, $0.name) < ($1.group, $1.name) } } public static func groups() -> [String] { Array(Set(list().map(\.group))).sorted() } private static func profile(for fileURL: URL, group: String) -> OVPNProfile { let name = fileURL.deletingPathExtension().lastPathComponent let parsed = (try? String(contentsOf: fileURL, encoding: .utf8)).map(OVPNParser.parse) ?? OVPNParser.Parsed() return OVPNProfile(id: "\(group)/\(fileURL.lastPathComponent)", name: name, group: group, fileURL: fileURL, remote: parsed.remote, proto: parsed.proto, requiresAuth: parsed.requiresAuth, inlineCerts: parsed.inlineCerts) } // MARK: import /// Import loose `.ovpn` files (and any sidecar certs they reference) into `group`. @discardableResult public static func importFiles(_ urls: [URL], group: String = "imported") throws -> [OVPNProfile] { let dest = groupURL(group) try fm.createDirectory(at: dest, withIntermediateDirectories: true) var imported: [OVPNProfile] = [] for src in urls where src.pathExtension.lowercased() == "ovpn" { try harvest(ovpn: src, into: dest) imported.append(profile(for: dest.appendingPathComponent(src.lastPathComponent), group: group)) } guard !imported.isEmpty else { throw VPNStoreError.noConfigsFound } return imported } /// Import a provider zip bundle: unpack, find every `.ovpn` (recursively), carry /// each one plus its referenced sidecar certs into a group named after the zip. @discardableResult public static func importZip(_ zipURL: URL) throws -> [OVPNProfile] { let group = OVPNParser.sanitizeGroup(zipURL.deletingPathExtension().lastPathComponent) let tmp = fm.temporaryDirectory.appendingPathComponent("tva-vpn-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tmp) } // `ditto -x -k` extracts a zip natively on macOS (no PATH/unzip dependency). let r = ProcessRunner.run("/usr/bin/ditto", ["-x", "-k", zipURL.path, tmp.path]) guard r.ok else { throw VPNStoreError.extractFailed(r.stderr.isEmpty ? "ditto exit \(r.status)" : r.stderr) } let ovpns = ovpnFiles(under: tmp) guard !ovpns.isEmpty else { throw VPNStoreError.noConfigsFound } let dest = groupURL(group) try fm.createDirectory(at: dest, withIntermediateDirectories: true) var imported: [OVPNProfile] = [] for src in ovpns { try harvest(ovpn: src, into: dest) imported.append(profile(for: dest.appendingPathComponent(src.lastPathComponent), group: group)) } return imported } /// Copy one `.ovpn` and the sidecar files it references (resolved relative to the /// config's own directory) into `dest`, flattening to basenames so the config's /// relative `ca ca.crt` references still resolve next to it. Overwrites on repeat. private static func harvest(ovpn src: URL, into dest: URL) throws { let text = (try? String(contentsOf: src, encoding: .utf8)) ?? "" copy(src, into: dest) let srcDir = src.deletingLastPathComponent() for ref in OVPNParser.parse(text).sidecarRefs { let refURL = srcDir.appendingPathComponent(ref) if fm.fileExists(atPath: refURL.path) { copy(refURL, into: dest) } } } private static func copy(_ src: URL, into dest: URL) { let target = dest.appendingPathComponent(src.lastPathComponent) try? fm.removeItem(at: target) try? fm.copyItem(at: src, to: target) } /// Recursively collect `.ovpn` files under a directory. static func ovpnFiles(under dir: URL) -> [URL] { guard let en = fm.enumerator(at: dir, includingPropertiesForKeys: nil) else { return [] } return en.compactMap { $0 as? URL }.filter { $0.pathExtension.lowercased() == "ovpn" } } // MARK: delete public static func delete(_ profile: OVPNProfile) throws { do { try fm.removeItem(at: profile.fileURL) } catch { throw VPNStoreError.ioError(error.localizedDescription) } // Drop the group dir if it's now empty (sidecars-only leftovers included). let groupDir = profile.fileURL.deletingLastPathComponent() if VPNConfigStore.ovpnFiles(under: groupDir).isEmpty { try? fm.removeItem(at: groupDir) } } public static func deleteGroup(_ group: String) throws { do { try fm.removeItem(at: groupURL(group)) } catch { throw VPNStoreError.ioError(error.localizedDescription) } } }