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,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: ?"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user