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>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user