Files
navi/ios/Navis/ContentView.swift
T
M4andCursor 7ceb3a5148
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
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>
2026-08-01 22:46:32 +03:00

171 lines
5.8 KiB
Swift

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: ?"
}
}
}