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 "-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.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.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.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.. 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) } }