import Foundation import Network final class ProfileStore: @unchecked Sendable { private let defaults: UserDefaults init(suiteName: String = AppConstants.appGroupID) { defaults = UserDefaults(suiteName: suiteName) ?? .standard } func loadProfiles() -> [Profile] { guard let data = defaults.data(forKey: AppConstants.keyProfiles), let list = try? JSONDecoder().decode([Profile].self, from: data) else { return [] } return list } func saveProfiles(_ list: [Profile]) { if let data = try? JSONEncoder().encode(list) { defaults.set(data, forKey: AppConstants.keyProfiles) } } func getActiveID() -> String? { defaults.string(forKey: AppConstants.keyActiveID) } func setActiveID(_ id: String?) { defaults.set(id, forKey: AppConstants.keyActiveID) } func getSubURL() -> String { defaults.string(forKey: AppConstants.keySubURL) ?? "" } func setSubURL(_ url: String) { defaults.set(url, forKey: AppConstants.keySubURL) } func autoBest() -> Bool { defaults.bool(forKey: AppConstants.keyAutoBest) } func setAutoBest(_ v: Bool) { defaults.set(v, forKey: AppConstants.keyAutoBest) } func writeTunnelConfig(name: String, uri: String) { let cfg = TunnelConfig(name: name, uri: uri, startedAt: Date().timeIntervalSince1970) if let data = try? JSONEncoder().encode(cfg) { defaults.set(data, forKey: AppConstants.keyTunnelConfig) } defaults.set("", forKey: AppConstants.keyTunnelError) defaults.set("starting", forKey: AppConstants.keyTunnelStatus) defaults.synchronize() writeTunnelConfigFile(cfg) } func clearTunnelConfig() { defaults.removeObject(forKey: AppConstants.keyTunnelConfig) defaults.set("stopped", forKey: AppConstants.keyTunnelStatus) defaults.set("", forKey: AppConstants.keyTunnelError) defaults.synchronize() removeTunnelConfigFile() } func readTunnelConfig() -> TunnelConfig? { if let file = readTunnelConfigFile() { return file } guard let data = defaults.data(forKey: AppConstants.keyTunnelConfig) else { return nil } return try? JSONDecoder().decode(TunnelConfig.self, from: data) } func tunnelStatus() -> String { defaults.string(forKey: AppConstants.keyTunnelStatus) ?? "idle" } func tunnelError() -> String? { // Prefer file (extension → app visibility is more reliable than suite UserDefaults alone). if let fileErr = readTunnelErrorFile(), !fileErr.isEmpty { return fileErr } let s = defaults.string(forKey: AppConstants.keyTunnelError) ?? "" return s.isEmpty ? nil : s } func setTunnelStatus(_ status: String, error: String? = nil) { defaults.set(status, forKey: AppConstants.keyTunnelStatus) if let error { defaults.set(error, forKey: AppConstants.keyTunnelError) writeTunnelErrorFile(error) } else if status == "connected" || status == "stopped" || status == "starting" { defaults.set("", forKey: AppConstants.keyTunnelError) writeTunnelErrorFile("") } defaults.synchronize() } // MARK: - App Group files (shared with Packet Tunnel) private var containerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) } private var tunnelConfigFileURL: URL? { containerURL?.appendingPathComponent("tunnel-config.json") } private var tunnelErrorFileURL: URL? { containerURL?.appendingPathComponent("tunnel-error.txt") } private func writeTunnelConfigFile(_ cfg: TunnelConfig) { guard let url = tunnelConfigFileURL, let data = try? JSONEncoder().encode(cfg) else { return } try? data.write(to: url, options: .atomic) } func readTunnelConfigFile() -> TunnelConfig? { guard let url = tunnelConfigFileURL, let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode(TunnelConfig.self, from: data) } private func removeTunnelConfigFile() { if let url = tunnelConfigFileURL { try? FileManager.default.removeItem(at: url) } if let url = tunnelErrorFileURL { try? FileManager.default.removeItem(at: url) } } private func writeTunnelErrorFile(_ error: String) { guard let url = tunnelErrorFileURL else { return } if error.isEmpty { try? FileManager.default.removeItem(at: url) } else { try? error.data(using: .utf8)?.write(to: url, options: .atomic) } } private func readTunnelErrorFile() -> String? { guard let url = tunnelErrorFileURL, let data = try? Data(contentsOf: url), let s = String(data: data, encoding: .utf8) else { return nil } return s } func importSubscription(url: String) async throws -> [Profile] { guard let u = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw StoreError.message("неверный URL подписки") } var req = URLRequest(url: u) req.setValue("ClashMeta/1.18.0", forHTTPHeaderField: "User-Agent") req.setValue("*/*", forHTTPHeaderField: "Accept") let (data, resp) = try await URLSession.shared.data(for: req) if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { throw StoreError.message("подписка HTTP \(http.statusCode)") } let body = String(data: data, encoding: .utf8) ?? "" let items = LinkParser.parseSubscriptionBody(body) if items.isEmpty { throw StoreError.message("в подписке нет серверов") } saveProfiles(items) setSubURL(url.trimmingCharacters(in: .whitespacesAndNewlines)) return items } func importLinks(_ text: String) -> [Profile] { let items = LinkParser.parseSubscriptionBody(text) if !items.isEmpty { saveProfiles(items) } return items } func pingAll(_ profiles: [Profile]) async -> [PingResult] { await withTaskGroup(of: PingResult.self) { group in for p in profiles { group.addTask { await self.pingOne(p) } } var out: [PingResult] = [] for await r in group { out.append(r) } return out.sorted { if $0.ok != $1.ok { return $0.ok && !$1.ok } if $0.ok { return $0.ms < $1.ms } return false } } } func bestOf(_ pings: [PingResult]) -> PingResult? { pings.filter(\.ok).min(by: { $0.ms < $1.ms }) } private func pingOne(_ p: Profile) async -> PingResult { let host = LinkParser.hostOf(p.uri) let port = LinkParser.portOf(p.uri) guard !host.isEmpty else { return PingResult(id: p.id, error: "нет хоста") } let proto = Proto.detect(p.uri) let start = Date() do { if proto == .hy2 || proto == .awg { // Best-effort UDP connectability probe. try await udpProbe(host: host, port: UInt16(port)) } else { try await tcpConnect(host: host, port: UInt16(port), timeout: 3) } let ms = Int64(Date().timeIntervalSince(start) * 1000) return PingResult(id: p.id, ms: ms, ok: true) } catch { return PingResult(id: p.id, ok: false, error: error.localizedDescription) } } private func tcpConnect(host: String, port: UInt16, timeout: TimeInterval) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let queue = DispatchQueue(label: "navis.ping") let conn = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp ) var settled = false let finish: (Result) -> Void = { result in guard !settled else { return } settled = true conn.cancel() cont.resume(with: result) } conn.stateUpdateHandler = { state in switch state { case .ready: finish(.success(())) case .failed(let err): finish(.failure(err)) case .cancelled: break default: break } } conn.start(queue: queue) queue.asyncAfter(deadline: .now() + timeout) { finish(.failure(StoreError.message("timeout"))) } } } private func udpProbe(host: String, port: UInt16) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let queue = DispatchQueue(label: "navis.ping.udp") let conn = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .udp ) var settled = false let finish: (Result) -> Void = { result in guard !settled else { return } settled = true conn.cancel() cont.resume(with: result) } conn.stateUpdateHandler = { state in switch state { case .ready: conn.send(content: Data([0]), completion: .contentProcessed { err in if let err { finish(.failure(err)) } else { finish(.success(())) } }) case .failed(let err): finish(.failure(err)) default: break } } conn.start(queue: queue) queue.asyncAfter(deadline: .now() + 1.2) { finish(.failure(StoreError.message("timeout"))) } } } } enum StoreError: LocalizedError { case message(String) var errorDescription: String? { switch self { case .message(let s): return s } } }