130 lines
6.4 KiB
Swift
130 lines
6.4 KiB
Swift
import NetworkExtension
|
||
import SwiftUI
|
||
|
||
struct ContentView: View {
|
||
@EnvironmentObject var vpn: VPNController
|
||
@State private var pasteText = ""
|
||
@State private var showImport = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(spacing: 18) {
|
||
profileCard
|
||
if vpn.servers.count > 1 { serversCard }
|
||
powerButton
|
||
statusLine
|
||
optionsCard
|
||
Button { showImport = true } label: {
|
||
Label("Импорт ссылки phobos://", systemImage: "square.and.arrow.down")
|
||
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.borderedProminent).tint(.indigo)
|
||
Link(destination: URL(string: "https://speed.cloudflare.com/")!) {
|
||
Label("Проверить скорость", systemImage: "speedometer").frame(maxWidth: .infinity).padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
if !vpn.message.isEmpty {
|
||
Text(vpn.message).font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.navigationTitle("PCA Phobos VPN")
|
||
.alert("Ссылка из панели", isPresented: $showImport) {
|
||
TextField("phobos://…", text: $pasteText)
|
||
Button("Импорт") { vpn.importLink(pasteText); pasteText = "" }
|
||
Button("Из буфера") { vpn.importLink(UIPasteboard.general.string ?? "") }
|
||
Button("Отмена", role: .cancel) {}
|
||
} message: {
|
||
Text("В панели: клиент → 📱 → ссылка phobos://. Можно просто открыть ссылку на iPhone — профиль импортируется сам.")
|
||
}
|
||
}
|
||
}
|
||
|
||
private var profileCard: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let p = vpn.profile {
|
||
Text(vpn.name).font(.title3.bold())
|
||
Text("Сервер: \(p.target ?? "?")").font(.subheadline).foregroundStyle(.secondary)
|
||
if let tcp = p.tcpPort { Text("Резервный TCP: порт \(tcp)").font(.caption).foregroundStyle(.secondary) }
|
||
} else {
|
||
Text("Профиль не импортирован").font(.headline)
|
||
Text("Откройте на iPhone ссылку phobos:// из панели PCA Phobos или вставьте её кнопкой ниже.")
|
||
.font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
|
||
private var serversCard: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
|
||
ForEach(vpn.servers, id: \.self) { s in
|
||
let n = s["name"] ?? ""
|
||
HStack {
|
||
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
|
||
.foregroundStyle(n == vpn.name ? .green : .secondary)
|
||
Text(n)
|
||
Spacer()
|
||
if n != vpn.name {
|
||
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { vpn.select(n) }
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
|
||
private var powerButton: some View {
|
||
Button(action: vpn.toggle) {
|
||
Text(vpn.isOn ? "Отключить" : "Подключить")
|
||
.font(.title2.bold()).foregroundStyle(.white)
|
||
.frame(width: 180, height: 180)
|
||
.background(Circle().fill(vpn.isOn ? Color.green : Color.indigo))
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
|
||
private var statusLine: some View {
|
||
let text: String = {
|
||
switch vpn.status {
|
||
case .connected: return "🟢 VPN включён" + (vpn.transport.isEmpty ? "" : " · \(vpn.transport)")
|
||
case .connecting, .reasserting: return "🟡 Подключение…"
|
||
case .disconnecting: return "Отключение…"
|
||
default: return "⚪ VPN выключен"
|
||
}
|
||
}()
|
||
return Text(text).font(.headline)
|
||
}
|
||
|
||
private var optionsCard: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Toggle("Российские сайты — напрямую, без VPN", isOn: $vpn.ruDirect)
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Канал до сервера").font(.subheadline.bold())
|
||
Picker("Канал", selection: $vpn.transportMode) {
|
||
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
if vpn.transportMode == "cloud" {
|
||
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
|
||
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
|
||
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
|
||
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
}
|