91878fa2e3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
29 KiB
Swift
600 lines
29 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
|
||
// MARK: - Networks (globe in the header)
|
||
//
|
||
// BroV is the remote for the network core (mihomo). It talks to the core's local REST
|
||
// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json):
|
||
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
|
||
// • right column — client networks: groups "<client>-sw" switched between REJECT and
|
||
// the client's WireGuard tunnel.
|
||
// Switching a group never reloads the core, so open connections (this chat) survive.
|
||
|
||
struct NetExit: Identifiable, Equatable {
|
||
var id: String // proxy name in the core
|
||
var title: String
|
||
var subtitle: String
|
||
var delay: Int? // ms, nil = unknown, 0 = dead
|
||
}
|
||
|
||
struct NetClient: Identifiable, Equatable {
|
||
var id: String // "saga"
|
||
var title: String
|
||
var subnet: String
|
||
var on: Bool
|
||
var delay: Int?
|
||
}
|
||
|
||
@MainActor
|
||
final class NetCore: ObservableObject {
|
||
static let shared = NetCore()
|
||
|
||
@Published var running = false
|
||
@Published var exits: [NetExit] = []
|
||
@Published var currentExit = ""
|
||
@Published var autoPick = "" // what "auto" chose
|
||
@Published var clients: [NetClient] = []
|
||
@Published var busy = false
|
||
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
|
||
@Published var tunOn = false
|
||
@Published var lastError: String?
|
||
/// Amnezia connections (provider "amnezia-keys"), the group's choice and auto's pick.
|
||
@Published var amneziaConns: [NetExit] = []
|
||
@Published var amneziaNow = ""
|
||
@Published var amneziaAutoPick = ""
|
||
|
||
/// Delays measured by the group test (covers the subscription nodes too).
|
||
private var measured: [String: Int] = [:]
|
||
private var base = ""
|
||
private var secret = ""
|
||
|
||
static let clientInfo: [String: (title: String, subnet: String)] = [
|
||
"saga": ("Сага", "192.168.8.0/24"),
|
||
"planet9": ("Planet9", "192.168.68.0/24"),
|
||
]
|
||
|
||
private func loadEndpoint() -> Bool {
|
||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json")
|
||
guard let data = try? Data(contentsOf: url),
|
||
let j = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||
let c = j["controller"], let s = j["secret"] else { return false }
|
||
base = c; secret = s
|
||
return true
|
||
}
|
||
|
||
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
|
||
timeout: TimeInterval = 4) async -> Any? {
|
||
guard !base.isEmpty || loadEndpoint(),
|
||
let url = URL(string: base + path) else { return nil }
|
||
var r = URLRequest(url: url, timeoutInterval: timeout)
|
||
r.httpMethod = method
|
||
r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization")
|
||
if let body {
|
||
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
r.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
}
|
||
guard let (data, resp) = try? await URLSession.shared.data(for: r),
|
||
let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
|
||
return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:]
|
||
}
|
||
|
||
/// Reads groups and last known delays.
|
||
func refresh() async {
|
||
guard let all = await request("/proxies") as? [String: Any],
|
||
let proxies = all["proxies"] as? [String: [String: Any]] else {
|
||
running = false
|
||
return
|
||
}
|
||
running = true
|
||
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
|
||
tunOn = tun["enable"] as? Bool ?? false
|
||
}
|
||
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
|
||
// provider, not in /proxies.
|
||
var providerDelay: [String: Int] = [:]
|
||
var awgNames: [String] = []
|
||
for prov in ["vless-cluster", "amnezia-keys"] {
|
||
guard let p = await request("/providers/proxies/\(prov)") as? [String: Any],
|
||
let list = p["proxies"] as? [[String: Any]] else { continue }
|
||
for x in list {
|
||
guard let n = x["name"] as? String else { continue }
|
||
if prov == "amnezia-keys" { awgNames.append(n) }
|
||
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
|
||
}
|
||
}
|
||
func lastDelay(_ name: String) -> Int? {
|
||
if let d = measured[name] { return d }
|
||
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
|
||
return providerDelay[name]
|
||
}
|
||
amneziaNow = proxies["amnezia"]?["now"] as? String ?? ""
|
||
amneziaAutoPick = proxies["amnezia-auto"]?["now"] as? String ?? ""
|
||
let amneziaEffective = amneziaNow == "amnezia-auto" ? amneziaAutoPick : amneziaNow
|
||
amneziaConns = [NetExit(id: "amnezia-auto", title: "Авто",
|
||
subtitle: amneziaAutoPick.isEmpty ? "быстрейшее подключение"
|
||
: "сейчас: \(Self.pretty(amneziaAutoPick).0)",
|
||
delay: lastDelay(amneziaAutoPick))]
|
||
+ awgNames.map { NetExit(id: $0, title: Self.pretty($0).0, subtitle: "AmneziaWG", delay: lastDelay($0)) }
|
||
if let g = proxies["ai-out"], let members = g["all"] as? [String] {
|
||
currentExit = g["now"] as? String ?? ""
|
||
autoPick = proxies["auto"]?["now"] as? String ?? ""
|
||
exits = members.map { name in
|
||
let (t, sub) = Self.pretty(name)
|
||
let delay: Int? = name == "auto" ? lastDelay(autoPick)
|
||
: (name == "amnezia" ? lastDelay(amneziaEffective) : lastDelay(name))
|
||
return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
|
||
? "сейчас: \(Self.pretty(autoPick).0)"
|
||
: (name == "amnezia" && !amneziaEffective.isEmpty
|
||
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
|
||
delay: delay)
|
||
}
|
||
}
|
||
clients = Self.clientInfo.keys.sorted().compactMap { id in
|
||
guard let g = proxies["\(id)-sw"] else { return nil }
|
||
let info = Self.clientInfo[id]!
|
||
return NetClient(id: id, title: info.title, subnet: info.subnet,
|
||
on: (g["now"] as? String) == id, delay: lastDelay(id))
|
||
}
|
||
}
|
||
|
||
/// Measures every exit and client tunnel (in parallel, inside the core).
|
||
func measure() async {
|
||
busy = true
|
||
let test = "url=https://www.gstatic.com/generate_204&timeout=5000"
|
||
if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
|
||
measured = m
|
||
// Members that didn't answer are missing from the map: mark them dead.
|
||
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
|
||
}
|
||
if let m = await request("/group/amnezia/delay?\(test)", timeout: 8) as? [String: Int] {
|
||
for (k, v) in m { measured[k] = v }
|
||
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
|
||
}
|
||
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
|
||
await refresh()
|
||
busy = false
|
||
}
|
||
|
||
func select(exit name: String) async {
|
||
currentExit = name
|
||
_ = await request("/proxies/ai-out", method: "PUT", body: ["name": name])
|
||
SoundEngine.shared.play("blip")
|
||
await refresh()
|
||
}
|
||
|
||
func set(client id: String, on: Bool) async {
|
||
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
|
||
_ = await request("/proxies/\(id)-sw", method: "PUT", body: ["name": on ? id : "REJECT"])
|
||
SoundEngine.shared.play(on ? "pop" : "close")
|
||
if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) }
|
||
await refresh()
|
||
}
|
||
|
||
func select(amnezia name: String) async {
|
||
amneziaNow = name
|
||
_ = await request("/proxies/amnezia", method: "PUT", body: ["name": name])
|
||
if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) }
|
||
SoundEngine.shared.play("blip")
|
||
await refresh()
|
||
}
|
||
|
||
static var coreDir: URL {
|
||
FileManager.default.homeDirectoryForCurrentUser
|
||
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore")
|
||
}
|
||
|
||
/// Runs gen.py (the single converter for keys → core config) in the core folder.
|
||
private nonisolated static func gen(_ args: [String]) async -> String {
|
||
await withCheckedContinuation { cont in
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
let p = Process()
|
||
p.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
|
||
p.arguments = ["gen.py"] + args
|
||
p.currentDirectoryURL = coreDir
|
||
let out = Pipe()
|
||
p.standardOutput = out
|
||
p.standardError = out
|
||
guard (try? p.run()) != nil else { cont.resume(returning: ""); return }
|
||
let data = out.fileHandleForReading.readDataToEndOfFile()
|
||
p.waitUntilExit()
|
||
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Checks a pasted vpn:// key, stores it, rebuilds the Amnezia provider and tests it.
|
||
/// Returns a message for the add panel.
|
||
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
|
||
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
|
||
let keys = Self.coreDir.appendingPathComponent("keys")
|
||
let pending = keys.appendingPathComponent(".pending.vpnkey")
|
||
do {
|
||
try key.write(to: pending, atomically: true, encoding: .utf8)
|
||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
|
||
} catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
|
||
let out = await Self.gen(["--check", pending.path])
|
||
guard let line = out.split(separator: "\n").last,
|
||
let j = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
|
||
j["ok"] as? Bool == true else {
|
||
try? FileManager.default.removeItem(at: pending)
|
||
let err = ((try? JSONSerialization.jsonObject(with: Data((out.split(separator: "\n").last ?? "").utf8))) as? [String: Any])?["error"] as? String
|
||
return (false, "Ключ не подходит: \(err ?? "не удалось разобрать")")
|
||
}
|
||
let name = (j["name"] as? String) ?? "amnezia"
|
||
let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)"
|
||
// File name from the connection name; never overwrite an existing one.
|
||
var slug = name.lowercased().map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined()
|
||
if slug.isEmpty { slug = "amnezia" }
|
||
var dest = keys.appendingPathComponent("\(slug).vpnkey")
|
||
var n = 2
|
||
while FileManager.default.fileExists(atPath: dest.path) {
|
||
dest = keys.appendingPathComponent("\(slug)-\(n).vpnkey"); n += 1
|
||
}
|
||
do { try FileManager.default.moveItem(at: pending, to: dest) }
|
||
catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
|
||
_ = await Self.gen([])
|
||
// Live: re-read only the Amnezia provider, the core keeps running.
|
||
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
|
||
let proxy = "AWG " + dest.deletingPathExtension().lastPathComponent
|
||
let enc = proxy.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? proxy
|
||
let hc = await request("/providers/proxies/amnezia-keys/\(enc)/healthcheck?url=https://www.gstatic.com/generate_204&timeout=6000",
|
||
timeout: 10) as? [String: Any]
|
||
await refresh()
|
||
if let d = hc?["delay"] as? Int, d > 0 {
|
||
SoundEngine.shared.play("finish")
|
||
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
|
||
}
|
||
return (true, "Ключ «\(name)» сохранён (\(server)), но сервер пока не отвечает. Он будет участвовать в выборе, когда оживёт.")
|
||
}
|
||
|
||
func removeAmnezia(_ proxyName: String) async {
|
||
let file = String(proxyName.dropFirst(4)) + ".vpnkey"
|
||
try? FileManager.default.removeItem(at: Self.coreDir.appendingPathComponent("keys").appendingPathComponent(file))
|
||
if amneziaNow == proxyName { _ = await request("/proxies/amnezia", method: "PUT", body: ["name": "amnezia-auto"]) }
|
||
_ = await Self.gen([])
|
||
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
|
||
SoundEngine.shared.play("close")
|
||
await refresh()
|
||
}
|
||
|
||
static var amneziaRunning: Bool {
|
||
// The app holds routes; its background AmneziaVPN-service doesn't.
|
||
NSWorkspace.shared.runningApplications.contains { $0.localizedName == "AmneziaVPN" }
|
||
}
|
||
|
||
/// Turns traffic capture on/off without a password (the core keeps running).
|
||
func setTun(_ on: Bool) async {
|
||
if on && Self.amneziaRunning {
|
||
lastError = "Сначала выключи AmneziaVPN — иначе она и ядро подерутся за маршрут."
|
||
return
|
||
}
|
||
lastError = nil
|
||
tunOn = on
|
||
_ = await request("/configs", method: "PATCH", body: ["tun": ["enable": on]], timeout: 8)
|
||
SoundEngine.shared.play(on ? "pop" : "close")
|
||
try? await Task.sleep(for: .seconds(1))
|
||
await refresh()
|
||
}
|
||
|
||
static func pretty(_ name: String) -> (String, String) {
|
||
switch name {
|
||
case "auto": return ("Авто", "самый быстрый выход")
|
||
case "amnezia": return ("Амнезия", "AmneziaWG")
|
||
case "amnezia-auto": return ("Авто", "быстрейшее подключение")
|
||
case let n where n.hasPrefix("AWG "): return (String(n.dropFirst(4)), "AmneziaWG")
|
||
case let n where n.contains("node3"): return ("node3", "VLESS · Нидерланды")
|
||
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
|
||
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
|
||
case "SkandiFlora": return ("SkandiFlora", "VLESS · Стокгольм")
|
||
case "Helsinki": return ("Helsinki", "VLESS · Хельсинки")
|
||
default: return (name, "VLESS")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Header button
|
||
|
||
struct NetGlobeButton: View {
|
||
@ObservedObject private var net = NetCore.shared
|
||
@State private var open = false
|
||
|
||
var body: some View {
|
||
Button { open.toggle() } label: {
|
||
Image(systemName: "globe")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(open ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
|
||
.overlay(alignment: .topTrailing) {
|
||
Circle()
|
||
.fill(net.running ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
|
||
.frame(width: 5, height: 5)
|
||
.offset(x: 2, y: -1)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Сети")
|
||
.task { await net.refresh() }
|
||
.popover(isPresented: $open, arrowEdge: .bottom) {
|
||
NetPanel(net: net)
|
||
.notchPopoverStyle()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Panel
|
||
|
||
struct NetPanel: View {
|
||
@ObservedObject var net: NetCore
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "globe").font(.system(size: 13, weight: .semibold))
|
||
Text("Сети").font(.system(size: 14, weight: .semibold))
|
||
Circle().fill(net.running ? Color(hex: "#34D399") : Color(hex: "#F4505E")).frame(width: 6, height: 6)
|
||
Text(net.running ? "ядро работает" : "ядро не запущено")
|
||
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||
Spacer()
|
||
if net.running {
|
||
Button {
|
||
Task { await net.measure() }
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
if net.busy { ProgressView().controlSize(.mini) }
|
||
else { Image(systemName: "speedometer").font(.system(size: 11)) }
|
||
Text("Замерить").font(.system(size: 11, weight: .medium))
|
||
}
|
||
.padding(.horizontal, 8).frame(height: 22)
|
||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(net.busy)
|
||
}
|
||
}
|
||
|
||
if let err = net.lastError {
|
||
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
|
||
.fixedSize(horizontal: false, vertical: true).frame(width: 444, alignment: .leading)
|
||
}
|
||
|
||
if !net.running {
|
||
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/brov-secrets/install-netd.sh\nДальше ядро будет запускаться само при включении Мака.")
|
||
.font(.system(size: 11.5))
|
||
.foregroundColor(Color(hex: "#B0B5BE"))
|
||
.textSelection(.enabled)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.frame(width: 444, alignment: .leading)
|
||
} else {
|
||
NetRow(title: "Ядро перехватывает трафик",
|
||
subtitle: net.tunOn ? "весь трафик Мака идёт через BroV" : "выключено — Мак ходит сам (можно включить AmneziaVPN)",
|
||
delay: nil, isOn: net.tunOn, accent: "#A78BFA") {
|
||
Task { await net.setTun(!net.tunOn) }
|
||
}
|
||
.frame(width: 444)
|
||
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 1)
|
||
HStack(alignment: .top, spacing: 14) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
||
ForEach(net.exits) { e in
|
||
if e.id == "amnezia" {
|
||
AmneziaSection(net: net, exit: e)
|
||
} else {
|
||
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay,
|
||
isOn: net.currentExit == e.id, accent: "#D97757") {
|
||
guard net.currentExit != e.id else { return }
|
||
Task { await net.select(exit: e.id) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(width: 230)
|
||
|
||
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("СЕТИ КЛИЕНТОВ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
||
ForEach(net.clients) { c in
|
||
NetRow(title: c.title, subtitle: c.subnet, delay: c.on ? c.delay : nil,
|
||
isOn: c.on, accent: "#34D399") {
|
||
Task { await net.set(client: c.id, on: !c.on) }
|
||
}
|
||
}
|
||
Text("Выключенная сеть недоступна. Claude и интернет это не трогает.")
|
||
.font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.padding(.top, 4)
|
||
}
|
||
.frame(width: 200)
|
||
}
|
||
}
|
||
}
|
||
.padding(14)
|
||
.task {
|
||
await net.refresh()
|
||
if net.running { await net.measure() }
|
||
}
|
||
}
|
||
}
|
||
|
||
struct NetRow: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let delay: Int?
|
||
let isOn: Bool
|
||
let accent: String
|
||
let action: () -> Void
|
||
@State private var hovered = false
|
||
|
||
private var delayText: String {
|
||
guard let d = delay else { return "" }
|
||
return d == 0 ? "нет ответа" : "\(d) мс"
|
||
}
|
||
private var delayColor: String {
|
||
guard let d = delay else { return "#6B7079" }
|
||
if d == 0 { return "#F4505E" }
|
||
return d < 120 ? "#34D399" : (d < 300 ? "#F5A524" : "#F4505E")
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 8) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(title).font(.system(size: 12.5, weight: .semibold))
|
||
Text(subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
|
||
}
|
||
Spacer(minLength: 4)
|
||
Text(delayText).font(.system(size: 10.5, weight: .medium)).monospacedDigit()
|
||
.foregroundColor(Color(hex: delayColor))
|
||
// Switch look-alike
|
||
ZStack(alignment: isOn ? .trailing : .leading) {
|
||
Capsule().fill(isOn ? Color(hex: accent) : Color.white.opacity(0.14))
|
||
.frame(width: 30, height: 18)
|
||
Circle().fill(Color.white).frame(width: 14, height: 14).padding(2)
|
||
}
|
||
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isOn)
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 5)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(hovered ? 0.06 : 0)))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onHover { hovered = $0 }
|
||
}
|
||
}
|
||
|
||
// MARK: - Amnezia: several connections
|
||
|
||
/// "Амнезия" row: the switch picks Amnezia as the exit, the name opens the list of
|
||
/// connections (Авто / each key) with "+ Добавить подключение" at the end.
|
||
struct AmneziaSection: View {
|
||
@ObservedObject var net: NetCore
|
||
let exit: NetExit
|
||
@State private var expanded = false
|
||
@State private var adding = false
|
||
@State private var confirmRemove: String?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 8) {
|
||
Button { withAnimation(.easeOut(duration: 0.15)) { expanded.toggle() } } label: {
|
||
HStack(spacing: 6) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 4) {
|
||
Text(exit.title).font(.system(size: 12.5, weight: .semibold))
|
||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||
.font(.system(size: 8.5, weight: .bold)).foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
Text(exit.subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
|
||
}
|
||
Spacer(minLength: 4)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
NetRow(title: "", subtitle: "", delay: exit.delay, isOn: net.currentExit == "amnezia", accent: "#D97757") {
|
||
guard net.currentExit != "amnezia" else { return }
|
||
Task { await net.select(exit: "amnezia") }
|
||
}
|
||
.frame(width: 110)
|
||
}
|
||
.padding(.leading, 8)
|
||
|
||
if expanded {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
ForEach(net.amneziaConns) { c in
|
||
NetRow(title: c.title, subtitle: c.subtitle, delay: c.delay,
|
||
isOn: net.currentExit == "amnezia" && net.amneziaNow == c.id, accent: "#D97757") {
|
||
Task { await net.select(amnezia: c.id) }
|
||
}
|
||
.contextMenu {
|
||
if c.id != "amnezia-auto" {
|
||
Button("Удалить подключение…", role: .destructive) { confirmRemove = c.id }
|
||
}
|
||
}
|
||
}
|
||
Button { adding = true } label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "plus.circle.fill").font(.system(size: 12))
|
||
Text("Добавить подключение").font(.system(size: 12, weight: .medium))
|
||
}
|
||
.foregroundColor(Color(hex: "#D97757"))
|
||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.popover(isPresented: $adding, arrowEdge: .trailing) {
|
||
AddAmneziaKeyView(net: net) { adding = false }
|
||
.notchPopoverStyle()
|
||
}
|
||
}
|
||
.padding(.leading, 12)
|
||
.overlay(alignment: .leading) {
|
||
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1).padding(.leading, 6)
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog("Удалить подключение «\(NetCore.pretty(confirmRemove ?? "").0)»?",
|
||
isPresented: Binding(get: { confirmRemove != nil }, set: { if !$0 { confirmRemove = nil } })) {
|
||
Button("Удалить", role: .destructive) {
|
||
if let n = confirmRemove { Task { await net.removeAmnezia(n) } }
|
||
confirmRemove = nil
|
||
}
|
||
Button("Отмена", role: .cancel) { confirmRemove = nil }
|
||
} message: {
|
||
Text("Ключ будет удалён с этого Мака. Вернуть можно, вставив его снова.")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Paste a vpn:// key → BroV checks it, stores it and tells whether it connected.
|
||
struct AddAmneziaKeyView: View {
|
||
@ObservedObject var net: NetCore
|
||
let onDone: () -> Void
|
||
@State private var text = ""
|
||
@State private var working = false
|
||
@State private var result: (ok: Bool, message: String)?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Новое подключение Амнезии").font(.system(size: 13, weight: .semibold))
|
||
Text("Вставь ключ vpn:// из приложения Amnezia (Поделиться → AmneziaWG или ключ целиком).")
|
||
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
TextEditor(text: $text)
|
||
.font(.system(size: 10.5, design: .monospaced))
|
||
.scrollContentBackground(.hidden)
|
||
.padding(6)
|
||
.frame(height: 90)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
|
||
if let r = result {
|
||
Text(r.message)
|
||
.font(.system(size: 11.5, weight: .medium))
|
||
.foregroundColor(Color(hex: r.ok ? "#34D399" : "#FB923C"))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
HStack {
|
||
Spacer()
|
||
Button(result?.ok == true ? "Готово" : "Отмена") { onDone() }
|
||
Button {
|
||
working = true
|
||
Task {
|
||
result = await net.addAmneziaKey(text)
|
||
if result?.ok == true { text = "" }
|
||
working = false
|
||
}
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
if working { ProgressView().controlSize(.small) }
|
||
Text("Проверить и добавить")
|
||
}
|
||
}
|
||
.keyboardShortcut(.defaultAction)
|
||
.disabled(working || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(width: 340)
|
||
}
|
||
}
|