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,182 @@
|
||||
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("Отключено")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import SwiftUI
|
||||
import NetworkExtension
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
controls
|
||||
Divider()
|
||||
profileList
|
||||
footer
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.navigationTitle("Navis")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Text(
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String
|
||||
?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
?? "3.8.2"
|
||||
)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("URL подписки", text: $model.subURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Или вставьте vless/hy2/… ссылки", text: $model.importText, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Подписка") {
|
||||
Task { await model.importSubscription() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.busy || model.subURL.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
|
||||
Button("Ссылки") {
|
||||
model.importPastedLinks()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.busy || model.importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button("Пинг") {
|
||||
Task { await model.pingAll() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.busy || model.profiles.isEmpty)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Toggle("Автолучший", isOn: Binding(
|
||||
get: { model.autoBest },
|
||||
set: { model.setAutoBest($0) }
|
||||
))
|
||||
.disabled(model.busy)
|
||||
Spacer()
|
||||
vpnButtons
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var vpnButtons: some View {
|
||||
if model.vpn.canDisconnect || model.busy {
|
||||
Button("Отключить") {
|
||||
Task { await model.disconnect() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
} else {
|
||||
Button("Подключить") {
|
||||
Task { await model.connectSelected() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.activeID == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private var profileList: some View {
|
||||
List(model.profiles) { p in
|
||||
Button {
|
||||
model.select(p.id)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(p.name)
|
||||
.foregroundStyle(p.protocolKind.supportedOnIOS ? .primary : .secondary)
|
||||
.lineLimit(1)
|
||||
Text(subtitle(for: p))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if let ping = model.pings[p.id] {
|
||||
Text(ping.ok ? "\(ping.ms) ms" : "—")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(ping.ok ? .green : .secondary)
|
||||
}
|
||||
if model.activeID == p.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func subtitle(for p: Profile) -> String {
|
||||
let base = "\(p.protocolKind.label) · \(p.host)"
|
||||
if p.protocolKind.supportedOnIOS { return base }
|
||||
return base + " · недоступен на iOS"
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(statusLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.meta)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(model.metaErr ? .red : .primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
|
||||
private var statusLine: String {
|
||||
switch model.vpn.status {
|
||||
case .invalid:
|
||||
return "VPN: ожидает разрешение — нажмите Подключить (нужен VLESS/Trojan)"
|
||||
case .disconnected:
|
||||
return "VPN: отключён"
|
||||
case .connecting:
|
||||
return "VPN: подключение…"
|
||||
case .connected:
|
||||
return "VPN: подключён"
|
||||
case .reasserting:
|
||||
return "VPN: переподключение…"
|
||||
case .disconnecting:
|
||||
return "VPN: отключение…"
|
||||
@unknown default:
|
||||
return "VPN: ?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Navis</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.8.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.8.2.13</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.win.evilfox.navis</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct NavisApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(model)
|
||||
.onAppear {
|
||||
model.refresh()
|
||||
Task { await model.vpn.prepare() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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