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