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>
183 lines
6.5 KiB
Swift
183 lines
6.5 KiB
Swift
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("Отключено")
|
|
}
|
|
}
|