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

191 lines
6.8 KiB
Swift

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
}
}