Files
brov-macbook/NotchBuddy/Sources/App/NetPanel.swift
T

348 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
@Published var tunOn = false
@Published var lastError: String?
/// 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
}
// 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 var amneziaRunning: Bool {
NSWorkspace.shared.runningApplications.contains {
($0.bundleIdentifier ?? "").lowercased().contains("amnezia") || ($0.localizedName ?? "").contains("Amnezia")
}
}
/// 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 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
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 }
}
}