import Combine import Foundation import NetworkExtension @MainActor final class VPNManager: ObservableObject { @Published private(set) var status: NEVPNStatus = .invalid @Published private(set) var lastError: String? /// True between startVPNTunnel and connected/failed — keeps Отключить visible. @Published private(set) var isStarting = false private var observer: NSObjectProtocol? init() { observer = NotificationCenter.default.addObserver( forName: .NEVPNStatusDidChange, object: nil, queue: .main ) { [weak self] note in Task { @MainActor in guard let self else { return } if let conn = note.object as? NEVPNConnection { self.status = conn.status switch conn.status { case .connected, .disconnected, .invalid, .disconnecting: self.isStarting = false default: break } } } } Task { await refreshStatus() } } deinit { if let observer { NotificationCenter.default.removeObserver(observer) } } var isConnected: Bool { status == .connected || status == .connecting || status == .reasserting } /// Show disconnect while system is connecting or app is waiting on start. var canDisconnect: Bool { isConnected || isStarting } /// Creates / refreshes the system VPN configuration (may show iOS permission dialog). func prepare() async { do { let mgr = try await ensureManager(name: "Navis", uri: nil) status = mgr.connection.status lastError = nil } catch { lastError = error.localizedDescription await refreshStatus() } } func refreshStatus() async { do { let mgr = try await loadManager() status = mgr.connection.status } catch { status = .invalid } } func connect(name: String, uri: String, store: ProfileStore) async throws { lastError = nil isStarting = true defer { // If we throw before tunnel reaches connecting, clear the flag. if status != .connecting && status != .connected && status != .reasserting { isStarting = false } } // Persist for the extension (App Group defaults + file). Options alone are unreliable. store.writeTunnelConfig(name: name, uri: uri) var mgr = try await ensureManager(name: name, uri: uri) // Stop stale session so the new URI is applied cleanly. if mgr.connection.status != .disconnected && mgr.connection.status != .invalid { mgr.connection.stopVPNTunnel() try? await Task.sleep(nanoseconds: 500_000_000) // Reload fresh manager after stop to avoid "configuration is stale". mgr = try await loadManager() try await mgr.loadFromPreferences() } guard let session = mgr.connection as? NETunnelProviderSession else { throw StoreError.message("нет NETunnelProviderSession") } do { try session.startVPNTunnel(options: [ "name": name as NSString, "uri": uri as NSString, ]) } catch { // Retry once with a freshly reloaded manager (fixes "configuration is stale"). mgr = try await loadManager() try await mgr.loadFromPreferences() guard let retrySession = mgr.connection as? NETunnelProviderSession else { throw error } try retrySession.startVPNTunnel(options: [ "name": name as NSString, "uri": uri as NSString, ]) } status = mgr.connection.status if status == .connecting || status == .connected { isStarting = true } } func disconnect(store: ProfileStore) async { isStarting = false do { let mgr = try await loadManager() mgr.connection.stopVPNTunnel() status = mgr.connection.status } catch { // ignore } store.clearTunnelConfig() } private func loadManager() async throws -> NETunnelProviderManager { let list = try await NETunnelProviderManager.loadAllFromPreferences() if let existing = list.first(where: { ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == AppConstants.tunnelBundleID }) ?? list.first { return existing } throw StoreError.message("VPN-профиль ещё не создан") } /// Saves Packet Tunnel manager. When `uri` is set, embeds it in providerConfiguration /// so the appex always sees the selected server (not a stale first profile). private func ensureManager(name: String, uri: String?) async throws -> NETunnelProviderManager { let list = try await NETunnelProviderManager.loadAllFromPreferences() let mgr = list.first(where: { ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == AppConstants.tunnelBundleID }) ?? list.first ?? NETunnelProviderManager() let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = AppConstants.tunnelBundleID proto.serverAddress = { guard let uri, !uri.isEmpty else { return "Navis" } let host = LinkParser.hostOf(uri) return host.isEmpty ? name : host }() proto.disconnectOnSleep = false if let uri, !uri.isEmpty { proto.providerConfiguration = [ "uri": uri, "name": name, ] } else if let existing = (mgr.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration { proto.providerConfiguration = existing } mgr.protocolConfiguration = proto mgr.localizedDescription = AppConstants.vpnLocalizedDescription mgr.isEnabled = true try await mgr.saveToPreferences() // Always reload a FRESH manager after save — otherwise startVPNTunnel // throws "configuration is stale". let reloaded = try await NETunnelProviderManager.loadAllFromPreferences() guard let fresh = reloaded.first(where: { ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == AppConstants.tunnelBundleID }) ?? reloaded.first else { throw StoreError.message("VPN-профиль не сохранился") } try await fresh.loadFromPreferences() return fresh } }