Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
10 KiB
Swift
307 lines
10 KiB
Swift
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<Void, Error>) 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, Error>) -> 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<Void, Error>) 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, Error>) -> 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
|
|
}
|
|
}
|
|
}
|