From 91878fa2e34c0ce06cc355640ac666e3d1dcf744 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Wed, 7 Oct 2026 22:14:16 +0300 Subject: [PATCH] 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 --- NotchBuddy/Sources/App/NetPanel.swift | 284 ++++++++++++++++++++++++-- 1 file changed, 268 insertions(+), 16 deletions(-) diff --git a/NotchBuddy/Sources/App/NetPanel.swift b/NotchBuddy/Sources/App/NetPanel.swift index 48f3810..d896c90 100644 --- a/NotchBuddy/Sources/App/NetPanel.swift +++ b/NotchBuddy/Sources/App/NetPanel.swift @@ -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,12 +171,99 @@ 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). func setTun(_ on: Bool) async { if on && Self.amneziaRunning { @@ -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,10 +377,14 @@ 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 - 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) } + 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) } + } } } } @@ -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) + } +}