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 "-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) } }