Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

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:
M4
2026-08-01 22:46:32 +03:00
co-authored by Cursor
parent 8b564110d5
commit 7ceb3a5148
44 changed files with 4165 additions and 72 deletions
+182
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+170
View File
@@ -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: ?"
}
}
}
+38
View File
@@ -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>
+14
View File
@@ -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>
+17
View File
@@ -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() }
}
}
}
}
+190
View File
@@ -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
}
}