Files
navi/ios/Shared/ProfileStore.swift
T
M4andCursor 7ceb3a5148
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
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>
2026-08-01 22:46:32 +03:00

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