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:
@@ -38,6 +38,10 @@ final class NetCore: ObservableObject {
|
||||
/// 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] = [:]
|
||||
@@ -86,13 +90,17 @@ final class NetCore: ObservableObject {
|
||||
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
|
||||
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] = [:]
|
||||
if let p = await request("/providers/proxies/vless-cluster") as? [String: Any],
|
||||
let list = p["proxies"] as? [[String: Any]] {
|
||||
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 {
|
||||
if let n = x["name"] as? String, let h = x["history"] as? [[String: Any]],
|
||||
let d = h.last?["delay"] as? Int { providerDelay[n] = d }
|
||||
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? {
|
||||
@@ -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 }
|
||||
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)" : sub,
|
||||
delay: name == "auto" ? lastDelay(autoPick) : lastDelay(name))
|
||||
? "сейчас: \(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
|
||||
@@ -127,6 +147,10 @@ final class NetCore: ObservableObject {
|
||||
// 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
|
||||
@@ -147,10 +171,97 @@ final class NetCore: ObservableObject {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
static var amneziaRunning: Bool {
|
||||
NSWorkspace.shared.runningApplications.contains {
|
||||
($0.bundleIdentifier ?? "").lowercased().contains("amnezia") || ($0.localizedName ?? "").contains("Amnezia")
|
||||
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).
|
||||
@@ -169,8 +280,10 @@ final class NetCore: ObservableObject {
|
||||
|
||||
static func pretty(_ name: String) -> (String, String) {
|
||||
switch name {
|
||||
case "auto": return ("Авто", "первый живой")
|
||||
case "amnezia": return ("Амнезия", "AmneziaWG · свой сервер")
|
||||
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 · Стокгольм")
|
||||
@@ -264,6 +377,9 @@ struct NetPanel: View {
|
||||
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 }
|
||||
@@ -271,6 +387,7 @@ struct NetPanel: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 230)
|
||||
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1)
|
||||
@@ -345,3 +462,138 @@ struct NetRow: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user