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,182 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
@Published var profiles: [Profile] = []
|
||||
@Published var pings: [String: PingResult] = [:]
|
||||
@Published var activeID: String?
|
||||
@Published var subURL: String = ""
|
||||
@Published var autoBest: Bool = false
|
||||
@Published var busy: Bool = false
|
||||
@Published var meta: String = "Готово"
|
||||
@Published var metaErr: Bool = false
|
||||
@Published var importText: String = ""
|
||||
|
||||
let store = ProfileStore()
|
||||
let vpn = VPNManager()
|
||||
|
||||
func refresh() {
|
||||
profiles = store.loadProfiles()
|
||||
activeID = store.getActiveID()
|
||||
subURL = store.getSubURL()
|
||||
autoBest = store.autoBest()
|
||||
ensureDefaultSelectionIfNeeded()
|
||||
if let err = store.tunnelError() {
|
||||
setMeta(err, err: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only pick a default when nothing valid is selected — never override user pick.
|
||||
func ensureDefaultSelectionIfNeeded() {
|
||||
if let id = activeID, profiles.contains(where: { $0.id == id }) {
|
||||
return
|
||||
}
|
||||
if let first = profiles.first(where: { $0.protocolKind.supportedOnIOS }) {
|
||||
select(first.id)
|
||||
} else if let first = profiles.first {
|
||||
select(first.id)
|
||||
}
|
||||
}
|
||||
|
||||
func setMeta(_ msg: String, err: Bool = false) {
|
||||
meta = msg
|
||||
metaErr = err
|
||||
}
|
||||
|
||||
func select(_ id: String) {
|
||||
activeID = id
|
||||
store.setActiveID(id)
|
||||
defaultsHintSelected(id)
|
||||
}
|
||||
|
||||
private func defaultsHintSelected(_ id: String) {
|
||||
if let p = profiles.first(where: { $0.id == id }), !p.protocolKind.supportedOnIOS {
|
||||
setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол недоступен на iOS", err: true)
|
||||
} else if metaErr {
|
||||
setMeta("Выбран сервер")
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoBest(_ v: Bool) {
|
||||
autoBest = v
|
||||
store.setAutoBest(v)
|
||||
}
|
||||
|
||||
func importSubscription() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let items = try await store.importSubscription(url: subURL)
|
||||
profiles = items
|
||||
// Keep previous selection if it still exists; otherwise default.
|
||||
if let id = activeID, items.contains(where: { $0.id == id }) {
|
||||
// keep
|
||||
} else {
|
||||
activeID = nil
|
||||
ensureDefaultSelectionIfNeeded()
|
||||
}
|
||||
let usable = items.filter { $0.protocolKind.supportedOnIOS }.count
|
||||
if items.isEmpty {
|
||||
setMeta("В подписке нет поддерживаемых ссылок", err: true)
|
||||
} else if usable == 0 {
|
||||
setMeta("Импортировано \(items.count), но на iOS пока только VLESS/VMess/Trojan", err: true)
|
||||
} else {
|
||||
setMeta("Импортировано: \(items.count) (на iOS: \(usable) VLESS/Trojan)")
|
||||
}
|
||||
} catch {
|
||||
setMeta(error.localizedDescription, err: true)
|
||||
}
|
||||
}
|
||||
|
||||
func importPastedLinks() {
|
||||
let items = store.importLinks(importText)
|
||||
if items.isEmpty {
|
||||
setMeta("Не найдено ссылок", err: true)
|
||||
} else {
|
||||
profiles = items
|
||||
importText = ""
|
||||
activeID = nil
|
||||
ensureDefaultSelectionIfNeeded()
|
||||
let usable = items.filter { $0.protocolKind.supportedOnIOS }.count
|
||||
setMeta("Импортировано: \(items.count)" + (usable > 0 ? " (iOS: \(usable))" : ""))
|
||||
}
|
||||
}
|
||||
|
||||
func pingAll() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
setMeta("Пинг…")
|
||||
let results = await store.pingAll(profiles)
|
||||
var map: [String: PingResult] = [:]
|
||||
for r in results { map[r.id] = r }
|
||||
pings = map
|
||||
let ok = results.filter(\.ok).count
|
||||
setMeta("Пинг: \(ok)/\(results.count)")
|
||||
if autoBest {
|
||||
let usable = results.filter { r in
|
||||
profiles.first(where: { $0.id == r.id })?.protocolKind.supportedOnIOS == true
|
||||
}
|
||||
if let best = store.bestOf(usable) {
|
||||
select(best.id)
|
||||
setMeta("Лучший: \(profiles.first(where: { $0.id == best.id })?.name ?? best.id) \(best.ms) ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectSelected() async {
|
||||
guard let id = activeID, let p = profiles.first(where: { $0.id == id }) else {
|
||||
setMeta("Выберите сервер", err: true)
|
||||
return
|
||||
}
|
||||
// Do NOT silently swap to the first VLESS — that looked like «всегда первый».
|
||||
guard p.protocolKind.supportedOnIOS else {
|
||||
setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол не поддержан на iOS", err: true)
|
||||
return
|
||||
}
|
||||
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
setMeta("Подключение \(p.name) (\(p.protocolKind.label))…")
|
||||
try await vpn.connect(name: p.name, uri: p.uri, store: store)
|
||||
|
||||
// Wait for extension: connected, or error in App Group, or timeout.
|
||||
for _ in 0..<40 {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
await vpn.refreshStatus()
|
||||
if vpn.status == .connected {
|
||||
setMeta("Подключено: \(p.name)")
|
||||
return
|
||||
}
|
||||
if let err = store.tunnelError(), !err.isEmpty {
|
||||
setMeta(err, err: true)
|
||||
return
|
||||
}
|
||||
if vpn.status == .disconnected && !vpn.isStarting {
|
||||
// Give App Group a moment to flush error from appex.
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
if let err = store.tunnelError(), !err.isEmpty {
|
||||
setMeta(err, err: true)
|
||||
} else {
|
||||
setMeta("Не удалось подключить VPN", err: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if vpn.status == .connected {
|
||||
setMeta("Подключено: \(p.name)")
|
||||
} else {
|
||||
setMeta(store.tunnelError() ?? "Таймаут подключения — проверьте VPN в Настройках", err: true)
|
||||
}
|
||||
} catch {
|
||||
setMeta(error.localizedDescription, err: true)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
busy = false
|
||||
await vpn.disconnect(store: store)
|
||||
setMeta("Отключено")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user