52e1e8288b
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore) - Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days - Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
27 KiB
Swift
574 lines
27 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
|
||
// MARK: - Networks (globe in the header)
|
||
//
|
||
// BroV is the remote for the network core (mihomo, root). It never touches the core's
|
||
// config or API secret: every action goes through the narrow root helper netctl.py:
|
||
// • 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] = [:]
|
||
|
||
static let clientInfo: [String: (title: String, subnet: String)] = [
|
||
"saga": ("Сага", "192.168.8.0/24"),
|
||
"planet9": ("Planet9", "192.168.68.0/24"),
|
||
]
|
||
|
||
// MARK: Root helper (netctl.py)
|
||
//
|
||
// The core, its config, keys and API secret are root-only. BroV only talks to the
|
||
// narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
|
||
// delay, tun, add_key, remove_key — nothing that could rewrite the core config.
|
||
|
||
private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
|
||
|
||
private func call(_ req: [String: Any], timeout: Int = 12) async -> [String: Any]? {
|
||
guard let body = try? JSONSerialization.data(withJSONObject: req) else { return nil }
|
||
// Raw bytes cross threads (Sendable); JSON is parsed back here.
|
||
let reply: Data? = await withCheckedContinuation { cont in
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
cont.resume(returning: Self.callSync(body, timeout: timeout))
|
||
}
|
||
}
|
||
guard let reply else { return nil }
|
||
return try? JSONSerialization.jsonObject(with: reply) as? [String: Any]
|
||
}
|
||
|
||
private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
|
||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||
guard fd >= 0 else { return nil }
|
||
defer { close(fd) }
|
||
var tv = timeval(tv_sec: timeout, tv_usec: 0)
|
||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||
var addr = sockaddr_un()
|
||
addr.sun_family = sa_family_t(AF_UNIX)
|
||
withUnsafeMutablePointer(to: &addr.sun_path) {
|
||
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
|
||
}
|
||
let connected = withUnsafePointer(to: &addr) {
|
||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||
}
|
||
}
|
||
guard connected == 0 else { return nil }
|
||
var data = body
|
||
data.append(0x0A)
|
||
let sent = data.withUnsafeBytes { send(fd, $0.baseAddress, data.count, 0) }
|
||
guard sent == data.count else { return nil }
|
||
var out = Data()
|
||
var buf = [UInt8](repeating: 0, count: 65536)
|
||
while !out.contains(0x0A) {
|
||
let n = recv(fd, &buf, buf.count, 0)
|
||
if n <= 0 { break }
|
||
out.append(contentsOf: buf[0..<n])
|
||
}
|
||
guard let line = out.split(separator: 0x0A).first else { return nil }
|
||
return Data(line)
|
||
}
|
||
|
||
/// Reads groups and last known delays.
|
||
func refresh() async {
|
||
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
|
||
let proxies = st["proxies"] as? [String: [String: Any]] else {
|
||
running = false
|
||
return
|
||
}
|
||
running = true
|
||
tunOn = st["tun"] 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] = []
|
||
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
|
||
for (prov, list) in providers {
|
||
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 }
|
||
}
|
||
}
|
||
awgNames.sort()
|
||
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
|
||
if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] 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 call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] 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 where c.on {
|
||
if let m = (await call(["cmd": "delay", "proxy": c.id], timeout: 15))?["delays"] as? [String: Int] {
|
||
for (k, v) in m { measured[k] = v }
|
||
}
|
||
}
|
||
await refresh()
|
||
busy = false
|
||
}
|
||
|
||
func select(exit name: String) async {
|
||
currentExit = name
|
||
_ = await call(["cmd": "select", "group": "ai-out", "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 call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : "REJECT"])
|
||
SoundEngine.shared.play(on ? "pop" : "close")
|
||
if on, let m = (await call(["cmd": "delay", "proxy": id], timeout: 15))?["delays"] as? [String: Int] {
|
||
for (k, v) in m { measured[k] = v }
|
||
}
|
||
await refresh()
|
||
}
|
||
|
||
func select(amnezia name: String) async {
|
||
amneziaNow = name
|
||
_ = await call(["cmd": "select", "group": "amnezia", "name": name])
|
||
if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
|
||
SoundEngine.shared.play("blip")
|
||
await refresh()
|
||
}
|
||
|
||
/// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
|
||
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
|
||
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
|
||
guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
|
||
return (false, "Помощник сетевого ядра не отвечает.")
|
||
}
|
||
guard r["ok"] as? Bool == true else {
|
||
return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
|
||
}
|
||
await refresh()
|
||
let name = r["name"] as? String ?? "?"
|
||
let server = r["server"] as? String ?? "?"
|
||
if let d = r["delay"] as? Int, d > 0 {
|
||
SoundEngine.shared.play("finish")
|
||
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
|
||
}
|
||
return (true, "Ключ «\(name)» сохранён (\(server)), но сервер пока не отвечает. Он будет участвовать в выборе, когда оживёт.")
|
||
}
|
||
|
||
func removeAmnezia(_ proxyName: String) async {
|
||
_ = await call(["cmd": "remove_key", "name": proxyName])
|
||
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 call(["cmd": "tun", "on": on])
|
||
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/work/macbookbrov/brov/scripts/netcore/install.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)
|
||
}
|
||
}
|