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>
191 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|