Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
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>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user