132 lines
5.7 KiB
Swift
132 lines
5.7 KiB
Swift
import NetworkExtension
|
||
import SwiftUI
|
||
|
||
@main
|
||
struct PCAPhobosApp: App {
|
||
@StateObject private var vpn = VPNController()
|
||
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
ContentView()
|
||
.environmentObject(vpn)
|
||
.onOpenURL { vpn.importLink($0.absoluteString) } // phobos:// from the panel
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Owns the NETunnelProviderManager: profile, options, connect/disconnect, live status.
|
||
@MainActor
|
||
final class VPNController: ObservableObject {
|
||
@Published var name = UserDefaults.standard.string(forKey: "name") ?? ""
|
||
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
|
||
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
|
||
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
|
||
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
|
||
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
|
||
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
|
||
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
|
||
@Published var status: NEVPNStatus = .invalid
|
||
@Published var transport = ""
|
||
@Published var message = ""
|
||
|
||
private var manager: NETunnelProviderManager?
|
||
private var timer: Timer?
|
||
|
||
init() {
|
||
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
|
||
Task { await load() }
|
||
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
||
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
|
||
}
|
||
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in self?.pollTransport() }
|
||
}
|
||
}
|
||
|
||
var profile: PhobosProfile? { conf.isEmpty ? nil : PhobosProfile(conf) }
|
||
var isOn: Bool { status == .connected || status == .connecting || status == .reasserting }
|
||
|
||
func load() async {
|
||
let all = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? []
|
||
manager = all.first
|
||
status = manager?.connection.status ?? .invalid
|
||
}
|
||
|
||
func importLink(_ link: String) {
|
||
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
|
||
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
|
||
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
|
||
select(parsed.name)
|
||
message = "Профиль «\(name)» импортирован"
|
||
}
|
||
|
||
func select(_ server: String) {
|
||
guard let s = servers.first(where: { $0["name"] == server }) else { return }
|
||
name = server
|
||
conf = s["conf"] ?? ""
|
||
persist()
|
||
Task { await save(reconnect: isOn) }
|
||
}
|
||
|
||
func remove(_ server: String) {
|
||
servers.removeAll { $0["name"] == server }
|
||
if server == name {
|
||
if let next = servers.first?["name"] { select(next); return }
|
||
if isOn { manager?.connection.stopVPNTunnel() }
|
||
name = ""; conf = ""
|
||
}
|
||
persist()
|
||
}
|
||
|
||
private func persist() {
|
||
UserDefaults.standard.set(servers, forKey: "servers")
|
||
UserDefaults.standard.set(conf, forKey: "conf")
|
||
UserDefaults.standard.set(name, forKey: "name")
|
||
}
|
||
|
||
private func saveOptions() {
|
||
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
|
||
UserDefaults.standard.set(transportMode, forKey: "transport")
|
||
UserDefaults.standard.set(wsURL, forKey: "wsURL")
|
||
Task { await save(reconnect: isOn) }
|
||
}
|
||
|
||
/// Write the VPN configuration to iOS settings (asks the user once).
|
||
func save(reconnect: Bool = false) async {
|
||
guard let profile else { return }
|
||
let m = manager ?? NETunnelProviderManager()
|
||
let proto = NETunnelProviderProtocol()
|
||
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
|
||
proto.serverAddress = profile.targetHost ?? "Phobos"
|
||
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
|
||
m.protocolConfiguration = proto
|
||
m.localizedDescription = "PCA Phobos"
|
||
m.isEnabled = true
|
||
do {
|
||
try await m.saveToPreferences()
|
||
try await m.loadFromPreferences()
|
||
manager = m
|
||
if reconnect { m.connection.stopVPNTunnel(); try await Task.sleep(nanoseconds: 1_000_000_000); try m.connection.startVPNTunnel() }
|
||
} catch {
|
||
message = "Не удалось сохранить VPN: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
func toggle() {
|
||
Task {
|
||
if isOn { manager?.connection.stopVPNTunnel(); return }
|
||
guard profile != nil else { message = "Сначала импортируйте ссылку phobos:// из панели"; return }
|
||
await save()
|
||
do { try manager?.connection.startVPNTunnel() } catch { message = "Ошибка: \(error.localizedDescription)" }
|
||
}
|
||
}
|
||
|
||
private func pollTransport() {
|
||
guard status == .connected, let session = manager?.connection as? NETunnelProviderSession else { transport = ""; return }
|
||
try? session.sendProviderMessage(Data()) { [weak self] data in
|
||
guard let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||
Task { @MainActor in self?.transport = obj["transport"] as? String ?? "" }
|
||
}
|
||
}
|
||
}
|