Files
brov-macbook/NotchBuddy/Sources/App/NetPanel.swift
T
maksarsanjeev 52e1e8288b Security + upstream fixes
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore)
- Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days
- Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:49:11 +03:00

574 lines
27 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, 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 "<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?
/// 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<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.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<sockaddr_un>.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..<n])
}
guard let line = out.split(separator: 0x0A).first else { return nil }
return Data(line)
}
/// Reads groups and last known delays.
func refresh() async {
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
let proxies = st["proxies"] as? [String: [String: Any]] else {
running = false
return
}
running = true
tunOn = st["tun"] 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] = []
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
for (prov, list) in providers {
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 }
}
}
awgNames.sort()
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
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)
}
}