14114d1a58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
13 KiB
Swift
309 lines
13 KiB
Swift
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 "<client>-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 }
|
||
}
|
||
}
|