From 14114d1a5804e9d0285e0c97eb9cbf264c7c43e5 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Wed, 7 Oct 2026 21:29:33 +0300 Subject: [PATCH] =?UTF-8?q?Header=20globe=20=E2=86=92=20network=20panel:?= =?UTF-8?q?=20pick=20the=20internet=20exit=20(auto,=20Amnezia,=20each=20VL?= =?UTF-8?q?ESS=20node,=20with=20live=20delays)=20and=20switch=20client=20n?= =?UTF-8?q?etworks=20(Saga,=20Planet9)=20through=20the=20mihomo=20core=20A?= =?UTF-8?q?PI=20without=20reloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 + NotchBuddy/Sources/App/IslandRootView.swift | 8 +- NotchBuddy/Sources/App/NetPanel.swift | 308 ++++++++++++++++++++ 3 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 NotchBuddy/Sources/App/NetPanel.swift diff --git a/.gitignore b/.gitignore index 5cfd873..a475ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ DerivedData/ # Local only .claude/ + +# Secrets never live in the repo (kept in ~/Documents/brov-secrets) +*.conf +guide-*.md +brov-secrets/ diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index e38ead5..88733aa 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -584,12 +584,8 @@ struct IslandHeader: View { } .buttonStyle(.plain) - Button(action: { state.soundEnabled.toggle() }) { - Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") - .font(.system(size: 14)) - .foregroundColor(Color(hex: "#8E939C")) - } - .buttonStyle(.plain) + // Networks (sound lives in Settings and on ⌃⌥M). + NetGlobeButton() } } .padding(.trailing, 16) diff --git a/NotchBuddy/Sources/App/NetPanel.swift b/NotchBuddy/Sources/App/NetPanel.swift new file mode 100644 index 0000000..0db8e1f --- /dev/null +++ b/NotchBuddy/Sources/App/NetPanel.swift @@ -0,0 +1,308 @@ +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 + + /// 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 + // Subscription (VLESS) nodes 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]] { + 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 } + } + } + 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] + } + 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) + return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty + ? "сейчас: \(Self.pretty(autoPick).0)" : sub, + delay: name == "auto" ? lastDelay(autoPick) : lastDelay(name)) + } + } + 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 } + } + 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() + } + + static func pretty(_ name: String) -> (String, String) { + switch name { + case "auto": return ("Авто", "первый живой") + case "amnezia": return ("Амнезия", "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 !net.running { + Text("Запусти ядро: в Терминале `~/Documents/brov-secrets/start.sh` (AmneziaVPN при этом выключи). Скоро это будет делать сам BroV.") + .font(.system(size: 11.5)) + .foregroundColor(Color(hex: "#B0B5BE")) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 440, alignment: .leading) + } else { + 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 + 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 } + } +}