Files
navi/ios/Navis/AppModel.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

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