Network panel: several Amnezia connections (expand the Amnezia row, pick a connection or Auto, add a vpn:// key with live check, remove), auto = fastest across all Amnezia and VLESS exits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 22:14:16 +03:00
parent 5a119d01a1
commit 91878fa2e3
+268 -16
View File
@@ -38,6 +38,10 @@ final class NetCore: ObservableObject {
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN). /// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
@Published var tunOn = false @Published var tunOn = false
@Published var lastError: String? @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). /// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:] private var measured: [String: Int] = [:]
@@ -86,13 +90,17 @@ final class NetCore: ObservableObject {
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] { if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
tunOn = tun["enable"] as? Bool ?? false tunOn = tun["enable"] as? Bool ?? false
} }
// Subscription (VLESS) nodes keep their history in the provider, not in /proxies. // Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies.
var providerDelay: [String: Int] = [:] var providerDelay: [String: Int] = [:]
if let p = await request("/providers/proxies/vless-cluster") as? [String: Any], var awgNames: [String] = []
let list = p["proxies"] as? [[String: Any]] { 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 { for x in list {
if let n = x["name"] as? String, let h = x["history"] as? [[String: Any]], guard let n = x["name"] as? String else { continue }
let d = h.last?["delay"] as? Int { providerDelay[n] = d } 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? { func lastDelay(_ name: String) -> Int? {
@@ -100,14 +108,26 @@ final class NetCore: ObservableObject {
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d } if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
return providerDelay[name] 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] { if let g = proxies["ai-out"], let members = g["all"] as? [String] {
currentExit = g["now"] as? String ?? "" currentExit = g["now"] as? String ?? ""
autoPick = proxies["auto"]?["now"] as? String ?? "" autoPick = proxies["auto"]?["now"] as? String ?? ""
exits = members.map { name in exits = members.map { name in
let (t, sub) = Self.pretty(name) 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 return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
? "сейчас: \(Self.pretty(autoPick).0)" : sub, ? "сейчас: \(Self.pretty(autoPick).0)"
delay: name == "auto" ? lastDelay(autoPick) : lastDelay(name)) : (name == "amnezia" && !amneziaEffective.isEmpty
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
delay: delay)
} }
} }
clients = Self.clientInfo.keys.sorted().compactMap { id in clients = Self.clientInfo.keys.sorted().compactMap { id in
@@ -127,6 +147,10 @@ final class NetCore: ObservableObject {
// Members that didn't answer are missing from the map: mark them dead. // 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 } 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) } for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
await refresh() await refresh()
busy = false busy = false
@@ -147,12 +171,99 @@ final class NetCore: ObservableObject {
await refresh() await refresh()
} }
static var amneziaRunning: Bool { func select(amnezia name: String) async {
NSWorkspace.shared.runningApplications.contains { amneziaNow = name
($0.bundleIdentifier ?? "").lowercased().contains("amnezia") || ($0.localizedName ?? "").contains("Amnezia") _ = 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). /// Turns traffic capture on/off without a password (the core keeps running).
func setTun(_ on: Bool) async { func setTun(_ on: Bool) async {
if on && Self.amneziaRunning { if on && Self.amneziaRunning {
@@ -169,8 +280,10 @@ final class NetCore: ObservableObject {
static func pretty(_ name: String) -> (String, String) { static func pretty(_ name: String) -> (String, String) {
switch name { switch name {
case "auto": return ("Авто", "первый живой") case "auto": return ("Авто", "самый быстрый выход")
case "amnezia": return ("Амнезия", "AmneziaWG · свой сервер") 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("node3"): return ("node3", "VLESS · Нидерланды")
case let n where n.contains("node2"): return ("node2", "VLESS · Париж") case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм") case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
@@ -264,10 +377,14 @@ struct NetPanel: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079")) Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
ForEach(net.exits) { e in ForEach(net.exits) { e in
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay, if e.id == "amnezia" {
isOn: net.currentExit == e.id, accent: "#D97757") { AmneziaSection(net: net, exit: e)
guard net.currentExit != e.id else { return } } else {
Task { await net.select(exit: e.id) } 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) }
}
} }
} }
} }
@@ -345,3 +462,138 @@ struct NetRow: View {
.onHover { hovered = $0 } .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)
}
}