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>
This commit is contained in:
maksarsanjeev
2026-10-07 23:49:11 +03:00
parent dc47247340
commit 52e1e8288b
21 changed files with 1070 additions and 188 deletions
+5 -1
View File
@@ -10,7 +10,11 @@ DerivedData/
# Local only # Local only
.claude/ .claude/
# Secrets never live in the repo (kept in ~/Documents/brov-secrets) # Secrets never live in the repo (root core folder, ~/.brov-secrets)
*.conf *.conf
guide-*.md guide-*.md
brov-secrets/ brov-secrets/
*.vpnkey
api.secret
netcore.json
config.yaml
+9 -2
View File
@@ -19,10 +19,17 @@ DerivedData must stay outside ~/Documents (iCloud adds Finder attributes and cod
## Rules ## Rules
- Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV. - Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV.
- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`). - Swift 6, SwiftUI + AppKit. One dependency: SwiftTerm (term.macOS tabs). The character is 11 Memoji images (`Resources/memoji`) moved by `BotEngine` at 30 fps.
- Secrets live in the Keychain, never on disk or in git. - Secrets live in the Keychain or the root-only network core folder, never in git.
- Never block Claude Code: if the app doesn't answer, the hook exits immediately. - Never block Claude Code: if the app doesn't answer, the hook exits immediately.
- Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms. - Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
- Never approve a Claude Code permission without an explicit click. - Never approve a Claude Code permission without an explicit click.
- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin. - When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
- Pill IDs are stable contract values: never rename an existing pill ID. - Pill IDs are stable contract values: never rename an existing pill ID.
## Network core (globe 🌐 in the header)
- mihomo runs as root: LaunchDaemon `local.maksar.brov.netd`, binary + config + keys + API secret in `/Library/Application Support/BroV/` (root, 0700). Nothing user-writable is read by root.
- BroV never sees the config or the API secret: it talks to the narrow root helper `scripts/netcore/netctl.py` (LaunchDaemon `local.maksar.brov.netctl`, socket `/var/run/brov-netctl.sock`, only the installing user) — commands: state, select, delay, tun, add_key, remove_key.
- `scripts/netcore/gen.py` builds the config (Amnezia keys → provider `keys/amnezia.yaml`, WireGuard clients, VLESS subscription). Install/update: `sudo sh scripts/netcore/install.sh` (verifies the mihomo SHA256, migrates keys). Never commit keys, configs or the guide (`~/.brov-secrets`).
- Reloading the whole core config drops every connection (including this chat); group switches and provider reloads don't.
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime: BroV drives Terminal, Music and Mail through Apple Events. -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
+20
View File
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
exit(0) exit(0)
} }
BroVLaunchAgent.ensure() BroVLaunchAgent.ensure()
Self.pruneInbox()
setupMenuBarItem() setupMenuBarItem()
CodexUsageMonitor.shared.start() CodexUsageMonitor.shared.start()
AgentWatch.shared.start() AgentWatch.shared.start()
@@ -33,6 +34,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
#endif #endif
} }
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
openIsland()
return true
}
/// Copies of dropped files (HookServer.supportDir/inbox) are kept 7 days, then removed.
private static func pruneInbox() {
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-7 * 86400)
for f in files {
let d = (try? f.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantFuture
if d < cutoff { try? fm.removeItem(at: f) }
}
try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inbox.path)
}
// MARK: - Menu bar // MARK: - Menu bar
private func setupMenuBarItem() { private func setupMenuBarItem() {
@@ -56,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Actions // MARK: - Actions
@objc private func openIsland() { @objc private func openIsland() {
islandController?.fsm.openedExternally()
islandController?.expand(to: .overview) islandController?.expand(to: .overview)
} }
+7 -3
View File
@@ -378,7 +378,9 @@ final class AppState: ObservableObject {
@Published var pendingApproval: ApprovalInfo? = nil @Published var pendingApproval: ApprovalInfo? = nil
// Pending AskUserQuestion from Claude Code hook // Pending AskUserQuestion from Claude Code hook
@Published var pendingQuestion: AskQuestion? = nil @Published var pendingQuestion: AskQuestion? = nil {
didSet { QuestionLayout.height = pendingQuestion?.estimatedIslandHeight }
}
// Per-pill flat list of FileDiffs, in order of reception. // Per-pill flat list of FileDiffs, in order of reception.
// Not @Published — steps[] changes already trigger redraws. // Not @Published — steps[] changes already trigger redraws.
@@ -411,11 +413,13 @@ final class AppState: ObservableObject {
private func resetSessionDiffTimer(for pillId: String) { private func resetSessionDiffTimer(for pillId: String) {
sessionDiffTimers[pillId]?.cancel() sessionDiffTimers[pillId]?.cancel()
// The closure is MainActor-isolated (AppState is @MainActor): it must run on the main
// queue. Scheduled on a global queue, Swift 6's isolation check traps and the app quits.
let work = DispatchWorkItem { [weak self] in let work = DispatchWorkItem { [weak self] in
DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) } self?.clearSessionDiffs(for: pillId)
} }
sessionDiffTimers[pillId] = work sessionDiffTimers[pillId] = work
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work) DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
} }
#if !APPSTORE #if !APPSTORE
+32
View File
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
var multiSelect: Bool var multiSelect: Bool
} }
/// Island height of the pending question, readable from the nonisolated `islandSize`. Written on the main actor only.
enum QuestionLayout {
nonisolated(unsafe) static var height: CGFloat?
}
extension AskQuestionItem {
/// True when at least one option carries a description: the card then lists options vertically.
var hasDescriptions: Bool { options.contains { !$0.description.isEmpty } }
}
struct AskQuestion: Equatable { struct AskQuestion: Equatable {
var questions: [AskQuestionItem] // 1–4 questions var questions: [AskQuestionItem] // 1–4 questions
/// Island height that fits the tallest question without truncation (rough estimate, text wraps at ~500 pt).
var estimatedIslandHeight: CGFloat {
func lines(_ text: String, charWidth: CGFloat) -> CGFloat {
max(1, (CGFloat(text.count) * charWidth / 500).rounded(.up))
}
let tallest = questions.map { item -> CGFloat in
var h: CGFloat = 20 + lines(item.question, charWidth: 7) * 17 + 64
if !item.header.isEmpty { h += 14 }
if item.hasDescriptions {
for opt in item.options {
h += 34 + (opt.description.isEmpty ? 0 : lines(opt.description, charWidth: 6.4) * 14)
}
h += 40 // "Другое…" row
} else {
h += item.options.count >= 3 ? 74 : 40
}
if item.multiSelect { h += 34 }
return h
}.max() ?? 160
return min(max(tallest, 160), 560)
}
// MARK: - Parse from tool_input dict // MARK: - Parse from tool_input dict
// Returns nil if the payload is malformed (fallback → Allow/Deny card). // Returns nil if the payload is malformed (fallback → Allow/Deny card).
static func parse(toolInput: [String: Any]) -> AskQuestion? { static func parse(toolInput: [String: Any]) -> AskQuestion? {
+3 -1
View File
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
"--append-system-prompt", Self.notchPrompt] "--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] } if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] } if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] } // Never bypass: the chat gets untrusted input (dropped files, window titles, URLs).
let allowedModes: Set<String> = ["acceptEdits", "plan"]
if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
let p = Process() let p = Process()
p.executableURL = URL(fileURLWithPath: binary) p.executableURL = URL(fileURLWithPath: binary)
@@ -0,0 +1,144 @@
import Foundation
// MARK: - ClaudeSettingsFile
// Reads and rewrites a settings file BroV does not own (~/.claude/settings.json).
// Never start from an empty object when the file is there but unusable, always
// take a backup, and only ever write over the exact bytes the user was shown.
enum ClaudeSettingsFile {
enum Failure: LocalizedError, Equatable {
case unreadable(String)
case invalid(String)
case changed(String)
case backupFailed(String)
case writeFailed(String)
case unexpectedHooks(String)
var errorDescription: String? {
switch self {
case .unreadable(let name):
return "Не удалось прочитать \(name) — BroV его не трогал."
case .invalid(let name):
return "\(name) — некорректный JSON, BroV его не трогал."
case .changed(let name):
return "\(name) изменился после предпросмотра. Ничего не записано — откройте предпросмотр заново."
case .backupFailed(let name):
return "Не удалось сделать резервную копию \(name). Ничего не записано."
case .writeFailed(let name):
return "Не удалось записать \(name). Оригинал не тронут."
case .unexpectedHooks(let name):
return "\(name): \"hooks\" имеет неожиданный тип — BroV его не трогал."
}
}
}
/// The "hooks" object of a settings file. Absent → empty.
/// Present but not an object → throws, so it is never replaced.
static func hooks(in settings: [String: Any], name: String) throws -> [String: Any] {
guard let value = settings["hooks"] else { return [:] }
guard let hooks = value as? [String: Any] else { throw Failure.unexpectedHooks(name) }
return hooks
}
/// The hook groups already declared for one event. Absent → empty.
/// Present but not a list of objects → throws, so it is never replaced.
static func hookGroups(in hooks: [String: Any], event: String, name: String) throws -> [[String: Any]] {
guard let value = hooks[event] else { return [] }
guard let groups = value as? [[String: Any]] else { throw Failure.unexpectedHooks(name) }
return groups
}
/// The settings object and the bytes it was parsed from.
/// Absent file → empty object and nil bytes. An empty file is an empty object.
/// Present but unreadable, or anything that is not a JSON object → throws:
/// not knowing what is in there is not the same as empty.
static func read(at url: URL) throws -> (object: [String: Any], bytes: Data?) {
guard FileManager.default.fileExists(atPath: url.path) else { return ([:], nil) }
let name = url.lastPathComponent
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
if bytes.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D }) {
return ([:], bytes)
}
guard let object = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] else {
throw Failure.invalid(name)
}
return (object, bytes)
}
/// Replaces the file with `data`, after a dated backup.
///
/// `original` is what `read` returned when `data` was computed. If the file
/// holds anything else by now — another tool, the user's own editor — nothing
/// is written. Returns the backup, or nil when there was no file to back up.
@discardableResult
static func write(_ data: Data, to url: URL, expecting original: Data?) throws -> URL? {
let fm = FileManager.default
let name = url.lastPathComponent
let exists = fm.fileExists(atPath: url.path)
var current: Data? = nil
if exists {
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
current = bytes
}
guard current == original else { throw Failure.changed(name) }
// A dotfiles setup often makes settings.json a symlink: write to the file
// it points at, so the link survives the rename below.
let target = url.resolvingSymlinksInPath()
var backupURL: URL? = nil
// settings.json can hold API keys in its `env` block: a new file is ours
// only, and a rewrite keeps the permissions the original had.
var mode = 0o600
if exists {
let backup = freeBackupURL(for: url)
do { try fm.copyItem(at: target, to: backup) } catch { throw Failure.backupFailed(name) }
backupURL = backup
if let found = (try? fm.attributesOfItem(atPath: target.path))?[.posixPermissions] as? NSNumber {
mode = found.intValue & 0o777
}
} else {
try? fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
}
// Written beside the target and renamed over it: a crash or a full disk
// leaves the original intact rather than half a file.
let temp = target.deletingLastPathComponent()
.appendingPathComponent("\(target.lastPathComponent).brov-\(ProcessInfo.processInfo.processIdentifier)")
try? fm.removeItem(at: temp)
guard fm.createFile(atPath: temp.path, contents: data,
attributes: [.posixPermissions: NSNumber(value: 0o600)]) else {
throw Failure.writeFailed(name)
}
do {
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: temp.path)
} catch {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
guard rename(temp.path, target.path) == 0 else {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
return backupURL
}
/// Down to the second, and never an existing name: installing then
/// uninstalling in the same second must not lose the first backup.
private static func freeBackupURL(for url: URL) -> URL {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
let base = "\(url.lastPathComponent).bak-\(formatter.string(from: Date()))"
let dir = url.deletingLastPathComponent()
var candidate = dir.appendingPathComponent(base)
var n = 2
while FileManager.default.fileExists(atPath: candidate.path) {
candidate = dir.appendingPathComponent("\(base)-\(n)")
n += 1
}
return candidate
}
}
+14 -1
View File
@@ -56,12 +56,25 @@ enum DangerCheck {
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"), (#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"), (#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"), (#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
(#"/(NotchBuddy|BroV)/netcore(/|$)|\.vpnkey$"#, "меняет сетевое ядро BroV или его ключи"),
(#"/Library/Launch(Agents|Daemons)/"#, "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
(#"/NotchBuddy/nb-hook"#, "меняет хуки BroV"),
]
/// The same sensitive places when a shell command writes, moves or deletes there.
static let shellPathRules: [Rule] = [
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\bchmod\b|\bchown\b|sed\s+-i|\bpython3?\b|\bperl\b).*(NotchBuddy/netcore|BroV/netcore|\.vpnkey)"#,
reason: "меняет сетевое ядро BroV или его ключи"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\blaunchctl\b|\bplutil\b).*Library/Launch(Agents|Daemons)"#,
reason: "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|sed\s+-i).*(\.claude/settings(\.local)?\.json|NotchBuddy/nb-hook)"#,
reason: "меняет настройки или хуки Claude Code"),
] ]
static func reasons(tool: String, input: [String: Any]) -> [String] { static func reasons(tool: String, input: [String: Any]) -> [String] {
var out: [String] = [] var out: [String] = []
if let command = input["command"] as? String { if let command = input["command"] as? String {
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) { for rule in shellRules + shellPathRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason) out.append(rule.reason)
} }
} }
+43 -57
View File
@@ -1156,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
// MARK: - Claude Code settings.json hook installer // MARK: - Claude Code settings.json hook installer
private var _pendingHooksData: Data? private var _pendingHooksData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingHooksOriginal: Data?
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm. /// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
func previewClaudeHooks() throws -> String { func previewClaudeHooks() throws -> String {
let data = try buildHooksData() let (data, original) = try buildHooksData()
_pendingHooksData = data _pendingHooksData = data
_pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
/// Writes the hooks to disk (call after user confirms preview). /// Writes the hooks to disk (call after user confirms preview).
/// Refused if settings.json changed since the preview, or cannot be backed up.
func writeClaudeHooks() throws { func writeClaudeHooks() throws {
guard let data = _pendingHooksData else { return } guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
// Backup first try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
_pendingHooksData = nil _pendingHooksData = nil
_pendingHooksOriginal = nil
} }
private func buildHooksData() throws -> Data { private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { var settings = snapshot.object
settings = parsed
}
let hookPath = Self.hookScriptPath let hookPath = Self.hookScriptPath
#if APPSTORE #if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag // Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10), ("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10), ("SubagentStart", 10), ("SubagentStop", 10),
] ]
var hooks = settings["hooks"] as? [String: Any] ?? [:] // "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events { for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? [] var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false } existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing hooks[event] = existing
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
]) ])
hooks["PreToolUse"] = preToolUse hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
} }
func uninstallClaudeHooks() throws { func uninstallClaudeHooks() throws {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var settings = snapshot.object
var hooks = settings["hooks"] as? [String: Any] else { return } guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys { for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] { if var matchers = hooks[key] as? [[String: Any]] {
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
} }
settings["hooks"] = hooks settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
} }
// MARK: - Claude plan status line installer // MARK: - Claude plan status line installer
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
} }
private var _pendingStatusLineData: Data? private var _pendingStatusLineData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingStatusLineOriginal: Data?
private var _pendingPreviousData: Data? private var _pendingPreviousData: Data?
private var _pendingDeletePrevious: Bool = false private var _pendingDeletePrevious: Bool = false
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String { func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let d = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] { let settings = snapshot.object
settings = parsed
}
let hookPath = Self.hookScriptPath let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline" let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings, let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data _pendingStatusLineData = data
_pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after // Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String { func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return } guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
let formatter = DateFormatter() try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
// Commit side effects only after successful write // Commit side effects only after successful write
if let prevData = _pendingPreviousData { if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic) try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL) try? FileManager.default.removeItem(at: statusLinePreviousURL)
} }
_pendingStatusLineData = nil _pendingStatusLineData = nil
_pendingStatusLineOriginal = nil
_pendingPreviousData = nil _pendingPreviousData = nil
_pendingDeletePrevious = false _pendingDeletePrevious = false
} }
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// Writes nb-hook script and updates settings.json in one shot. /// Writes nb-hook script and updates settings.json in one shot.
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed). /// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws { func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
let data = try buildHooksData(claudeURL: claudeURL) let (data, original) = try buildHooksData(claudeURL: claudeURL)
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/ // Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
let coucouDir = claudeURL.appendingPathComponent("coucou") let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup) // Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
let formatter = DateFormatter() try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
formatter.dateFormat = "yyyyMMdd-HHmm"
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try data.write(to: settingsURL, options: .atomic)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled") UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
} }
func uninstallClaudeHooksAppStore(claudeURL: URL) throws { func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
guard let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var settings = snapshot.object
var hooks = settings["hooks"] as? [String: Any] else { return } guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys { for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] { if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in matchers.removeAll { matcher in
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
} }
settings["hooks"] = hooks settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled") UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
} }
private func buildHooksData(claudeURL: URL) throws -> Data { private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { var settings = snapshot.object
settings = parsed
}
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container) // Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10), ("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10), ("SubagentStart", 10), ("SubagentStop", 10),
] ]
var hooks = settings["hooks"] as? [String: Any] ?? [:] // "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events { for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? [] var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("coucou") == true || ($0["command"] as? String)?.contains("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true ($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
]) ])
hooks["PreToolUse"] = preToolUse hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
} }
#endif #endif
@@ -20,8 +20,14 @@ final class IslandStateMachine {
/// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island. /// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island.
var isHeldOpen: (() -> Bool)? var isHeldOpen: (() -> Bool)?
/// home → petit delay (seconds). Override for debug. /// home → petit delay (seconds), kept in sync with the auto-close preference.
var homeToPetitDelay: TimeInterval = 15 var homeToPetitDelay: TimeInterval = 15 {
didSet {
guard homeToPetitDelay != oldValue,
state == .home, homeCollapseWork != nil else { return }
scheduleHomeCollapse()
}
}
/// petit → hidden delay (seconds). Override for debug. /// petit → hidden delay (seconds). Override for debug.
var petitToHiddenDelay: TimeInterval = 60 var petitToHiddenDelay: TimeInterval = 60
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse. /// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
+38 -1
View File
@@ -395,7 +395,7 @@ struct QuestionView: View {
Text(item.question) Text(item.question)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8")) .foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(2) .fixedSize(horizontal: false, vertical: true)
// Options (wrapping) or "Other…" compact inline row // Options (wrapping) or "Other…" compact inline row
if curOther { if curOther {
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -429,6 +429,43 @@ struct QuestionView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(Color(hex: "#6B7079")) .foregroundColor(Color(hex: "#6B7079"))
} }
} else if item.hasDescriptions {
// Options with descriptions: a vertical list, label + description underneath.
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
let isSelected = curSel.contains(opt.label)
Button {
if isMulti {
toggleSelection(qi: qi, label: opt.label)
} else {
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(opt.label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#F5F6F8"))
if !opt.description.isEmpty {
Text(opt.description)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#9AA0A8"))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
}
SecondaryButton("Другое…") {
if qi < showOther.count { showOther[qi] = true }
}
}
} else { } else {
ChipFlowLayout(spacing: 6) { ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
private var frameTimer: Timer? private var frameTimer: Timer?
private var keyMonitor: Any? private var keyMonitor: Any?
private var viewSubscription: AnyCancellable? private var viewSubscription: AnyCancellable?
private var autoCloseSubscription: AnyCancellable?
// Confused recovery timer (set by handleDizzy) // Confused recovery timer (set by handleDizzy)
private var confusedRecoveryTimer: DispatchWorkItem? private var confusedRecoveryTimer: DispatchWorkItem?
@@ -163,6 +164,11 @@ final class IslandWindowController: NSWindowController {
// MARK: - FSM wiring // MARK: - FSM wiring
private func wireFSM() { private func wireFSM() {
// Apply the persisted auto-close preference immediately and keep live edits in sync.
autoCloseSubscription = state.$autoCloseInterval.sink { [weak self] delay in
self?.fsm.homeToPetitDelay = delay
}
fsm.onTransition = { [weak self] from, to in fsm.onTransition = { [weak self] from, to in
guard let self else { return } guard let self else { return }
switch to { switch to {
@@ -387,15 +393,19 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now state.lastActivity = .now
} }
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too; /// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too.
/// only a pending approval still keeps the island open. /// `allowPendingApproval`: the notch's own Escape, jump-to-terminal — may fold the
func collapse(byUser: Bool = false) { /// approval card (but not a chat/terminal tab).
if byUser { /// Folding a pending approval never answers it: the request stays pending, the island
guard state.pendingApproval == nil else { return } /// stays compact (held open) and a click or ⌃⌥A brings the card back.
} else { func collapse(byUser: Bool = false, allowPendingApproval: Bool = false) {
let onTallTab = state.mode == .expanded && state.view.isTall
let keepsApprovalPending = state.pendingApproval != nil
&& (byUser || (allowPendingApproval && !onTallTab))
if !byUser && !keepsApprovalPending {
guard fsm.isHeldOpen?() != true else { return } guard fsm.isHeldOpen?() != true else { return }
} }
state.isPinned = false if !keepsApprovalPending { state.isPinned = false }
finishedPinTimer?.cancel() finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now). // Keep the FSM in step with what is on screen (home/coucou → petit now).
fsm.collapse() fsm.collapse()
@@ -418,6 +428,7 @@ final class IslandWindowController: NSWindowController {
collapse(byUser: true) collapse(byUser: true)
} else { } else {
islandPanel.makeKey() islandPanel.makeKey()
fsm.openedExternally()
expand(to: defaultView()) expand(to: defaultView())
} }
@@ -428,6 +439,7 @@ final class IslandWindowController: NSWindowController {
case .goToAlert: case .goToAlert:
if state.pendingApproval != nil { if state.pendingApproval != nil {
islandPanel.makeKey() islandPanel.makeKey()
fsm.openedExternally()
expand(to: .approval) expand(to: .approval)
} else if state.pendingQuestion != nil { } else if state.pendingQuestion != nil {
islandPanel.makeKey() islandPanel.makeKey()
@@ -535,8 +547,9 @@ final class IslandWindowController: NSWindowController {
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse // ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty { if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil) let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
if !consumed && state.mode == .expanded && !state.isPinned { let canCollapse = !state.isPinned || state.pendingApproval != nil
collapse() if !consumed && state.mode == .expanded && canCollapse {
collapse(allowPendingApproval: true)
} }
return true return true
} }
@@ -590,7 +603,7 @@ final class IslandWindowController: NSWindowController {
NSWorkspace.shared.open( NSWorkspace.shared.open(
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")) URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
} }
collapse() collapse(allowPendingApproval: true)
} }
private func performAttachFrontWindow() { private func performAttachFrontWindow() {
@@ -615,6 +628,8 @@ final class IslandWindowController: NSWindowController {
Task { @MainActor in Task { @MainActor in
guard let self = self else { return } guard let self = self else { return }
if event.keyCode == 53 { // Escape if event.keyCode == 53 { // Escape
// Escape typed in another app (Claude Code's own interrupt, an editor…)
// never folds a pending approval away: only Escape in the notch does.
if self.state.mode == .expanded && !self.state.isPinned { if self.state.mode == .expanded && !self.state.isPinned {
self.collapse() self.collapse()
} }
@@ -1187,6 +1202,10 @@ func islandSize(mode: IslandMode, view: IslandView,
let layout = IslandConst.viewLayouts[view]! let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат). // BroV: the chat has its own width (Settings → Чат).
if view.isTall { return (AppState.shared.chatWidth, layout.height) } if view.isTall { return (AppState.shared.chatWidth, layout.height) }
// The question card grows to fit the full question and option descriptions.
if view == .question, let h = QuestionLayout.height {
return (IslandConst.expandedWidth, h)
}
return (IslandConst.expandedWidth, layout.height) return (IslandConst.expandedWidth, layout.height)
} }
} }
+81 -107
View File
@@ -3,8 +3,8 @@ import AppKit
// MARK: - Networks (globe in the header) // MARK: - Networks (globe in the header)
// //
// BroV is the remote for the network core (mihomo). It talks to the core's local REST // BroV is the remote for the network core (mihomo, root). It never touches the core's
// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json): // 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) // • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
// • right column — client networks: groups "<client>-sw" switched between REJECT and // • right column — client networks: groups "<client>-sw" switched between REJECT and
// the client's WireGuard tunnel. // the client's WireGuard tunnel.
@@ -45,64 +45,87 @@ final class NetCore: ObservableObject {
/// Delays measured by the group test (covers the subscription nodes too). /// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:] private var measured: [String: Int] = [:]
private var base = ""
private var secret = ""
static let clientInfo: [String: (title: String, subnet: String)] = [ static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"), "saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"), "planet9": ("Planet9", "192.168.68.0/24"),
] ]
private func loadEndpoint() -> Bool { // MARK: Root helper (netctl.py)
let url = FileManager.default.homeDirectoryForCurrentUser //
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json") // The core, its config, keys and API secret are root-only. BroV only talks to the
guard let data = try? Data(contentsOf: url), // narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
let j = try? JSONSerialization.jsonObject(with: data) as? [String: String], // delay, tun, add_key, remove_key — nothing that could rewrite the core config.
let c = j["controller"], let s = j["secret"] else { return false }
base = c; secret = s private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
return true
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 func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
timeout: TimeInterval = 4) async -> Any? { let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard !base.isEmpty || loadEndpoint(), guard fd >= 0 else { return nil }
let url = URL(string: base + path) else { return nil } defer { close(fd) }
var r = URLRequest(url: url, timeoutInterval: timeout) var tv = timeval(tv_sec: timeout, tv_usec: 0)
r.httpMethod = method setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization") setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
if let body { var addr = sockaddr_un()
r.setValue("application/json", forHTTPHeaderField: "Content-Type") addr.sun_family = sa_family_t(AF_UNIX)
r.httpBody = try? JSONSerialization.data(withJSONObject: body) withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
} }
guard let (data, resp) = try? await URLSession.shared.data(for: r), let connected = withUnsafePointer(to: &addr) {
let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil } $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:] 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. /// Reads groups and last known delays.
func refresh() async { func refresh() async {
guard let all = await request("/proxies") as? [String: Any], guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
let proxies = all["proxies"] as? [String: [String: Any]] else { let proxies = st["proxies"] as? [String: [String: Any]] else {
running = false running = false
return return
} }
running = true running = true
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] { tunOn = st["tun"] as? Bool ?? false
tunOn = tun["enable"] as? Bool ?? false
}
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the // Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies. // provider, not in /proxies.
var providerDelay: [String: Int] = [:] var providerDelay: [String: Int] = [:]
var awgNames: [String] = [] var awgNames: [String] = []
for prov in ["vless-cluster", "amnezia-keys"] { let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
guard let p = await request("/providers/proxies/\(prov)") as? [String: Any], for (prov, list) in providers {
let list = p["proxies"] as? [[String: Any]] else { continue }
for x in list { for x in list {
guard let n = x["name"] as? String else { continue } guard let n = x["name"] as? String else { continue }
if prov == "amnezia-keys" { awgNames.append(n) } 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 } 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? { func lastDelay(_ name: String) -> Int? {
if let d = measured[name] { return d } 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 } if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
@@ -141,108 +164,63 @@ final class NetCore: ObservableObject {
/// Measures every exit and client tunnel (in parallel, inside the core). /// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async { func measure() async {
busy = true busy = true
let test = "url=https://www.gstatic.com/generate_204&timeout=5000" if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] as? [String: Int] {
if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
measured = m measured = m
// Members that didn't answer are missing from the map: mark them dead. // 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 e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
} }
if let m = await request("/group/amnezia/delay?\(test)", timeout: 8) as? [String: Int] { if let m = (await call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v } 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 amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
} }
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) } 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() await refresh()
busy = false busy = false
} }
func select(exit name: String) async { func select(exit name: String) async {
currentExit = name currentExit = name
_ = await request("/proxies/ai-out", method: "PUT", body: ["name": name]) _ = await call(["cmd": "select", "group": "ai-out", "name": name])
SoundEngine.shared.play("blip") SoundEngine.shared.play("blip")
await refresh() await refresh()
} }
func set(client id: String, on: Bool) async { func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on } 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"]) _ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : "REJECT"])
SoundEngine.shared.play(on ? "pop" : "close") SoundEngine.shared.play(on ? "pop" : "close")
if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) } 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() await refresh()
} }
func select(amnezia name: String) async { func select(amnezia name: String) async {
amneziaNow = name amneziaNow = name
_ = await request("/proxies/amnezia", method: "PUT", body: ["name": name]) _ = await call(["cmd": "select", "group": "amnezia", "name": name])
if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) } if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
SoundEngine.shared.play("blip") SoundEngine.shared.play("blip")
await refresh() await refresh()
} }
static var coreDir: URL { /// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore")
}
/// Runs gen.py (the single converter for keys → core config) in the core folder.
private nonisolated static func gen(_ args: [String]) async -> String {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
p.arguments = ["gen.py"] + args
p.currentDirectoryURL = coreDir
let out = Pipe()
p.standardOutput = out
p.standardError = out
guard (try? p.run()) != nil else { cont.resume(returning: ""); return }
let data = out.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
}
}
}
/// Checks a pasted vpn:// key, stores it, rebuilds the Amnezia provider and tests it.
/// Returns a message for the add panel.
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) { func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
let key = text.trimmingCharacters(in: .whitespacesAndNewlines) let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") } guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
let keys = Self.coreDir.appendingPathComponent("keys") guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
let pending = keys.appendingPathComponent(".pending.vpnkey") return (false, "Помощник сетевого ядра не отвечает.")
do {
try key.write(to: pending, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
} catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
let out = await Self.gen(["--check", pending.path])
guard let line = out.split(separator: "\n").last,
let j = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
j["ok"] as? Bool == true else {
try? FileManager.default.removeItem(at: pending)
let err = ((try? JSONSerialization.jsonObject(with: Data((out.split(separator: "\n").last ?? "").utf8))) as? [String: Any])?["error"] as? String
return (false, "Ключ не подходит: \(err ?? "не удалось разобрать")")
} }
let name = (j["name"] as? String) ?? "amnezia" guard r["ok"] as? Bool == true else {
let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)" return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
// File name from the connection name; never overwrite an existing one.
var slug = name.lowercased().map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined()
if slug.isEmpty { slug = "amnezia" }
var dest = keys.appendingPathComponent("\(slug).vpnkey")
var n = 2
while FileManager.default.fileExists(atPath: dest.path) {
dest = keys.appendingPathComponent("\(slug)-\(n).vpnkey"); n += 1
} }
do { try FileManager.default.moveItem(at: pending, to: dest) }
catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
_ = await Self.gen([])
// Live: re-read only the Amnezia provider, the core keeps running.
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
let proxy = "AWG " + dest.deletingPathExtension().lastPathComponent
let enc = proxy.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? proxy
let hc = await request("/providers/proxies/amnezia-keys/\(enc)/healthcheck?url=https://www.gstatic.com/generate_204&timeout=6000",
timeout: 10) as? [String: Any]
await refresh() await refresh()
if let d = hc?["delay"] as? Int, d > 0 { 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") SoundEngine.shared.play("finish")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс") return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
} }
@@ -250,11 +228,7 @@ final class NetCore: ObservableObject {
} }
func removeAmnezia(_ proxyName: String) async { func removeAmnezia(_ proxyName: String) async {
let file = String(proxyName.dropFirst(4)) + ".vpnkey" _ = await call(["cmd": "remove_key", "name": proxyName])
try? FileManager.default.removeItem(at: Self.coreDir.appendingPathComponent("keys").appendingPathComponent(file))
if amneziaNow == proxyName { _ = await request("/proxies/amnezia", method: "PUT", body: ["name": "amnezia-auto"]) }
_ = await Self.gen([])
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
SoundEngine.shared.play("close") SoundEngine.shared.play("close")
await refresh() await refresh()
} }
@@ -272,7 +246,7 @@ final class NetCore: ObservableObject {
} }
lastError = nil lastError = nil
tunOn = on tunOn = on
_ = await request("/configs", method: "PATCH", body: ["tun": ["enable": on]], timeout: 8) _ = await call(["cmd": "tun", "on": on])
SoundEngine.shared.play(on ? "pop" : "close") SoundEngine.shared.play(on ? "pop" : "close")
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
await refresh() await refresh()
@@ -359,7 +333,7 @@ struct NetPanel: View {
} }
if !net.running { if !net.running {
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/brov-secrets/install-netd.sh\nДальше ядро будет запускаться само при включении Мака.") Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/work/macbookbrov/brov/scripts/netcore/install.sh\nДальше ядро будет запускаться само при включении Мака.")
.font(.system(size: 11.5)) .font(.system(size: 11.5))
.foregroundColor(Color(hex: "#B0B5BE")) .foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled) .textSelection(.enabled)
@@ -656,7 +656,6 @@ struct SettingsView: View {
Text("Спрашивать в вырезе").tag("default") Text("Спрашивать в вырезе").tag("default")
Text("Принимать правки").tag("acceptEdits") Text("Принимать правки").tag("acceptEdits")
Text("Только план").tag("plan") Text("Только план").tag("plan")
Text("Без проверок (опасно)").tag("bypassPermissions")
} }
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath) TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
+5 -1
View File
@@ -158,8 +158,12 @@ enum SelectionGrabber {
} }
guard pb.changeCount != before else { return nil } guard pb.changeCount != before else { return nil }
let text = pb.string(forType: .string) let text = pb.string(forType: .string)
// Restore what the user had copied. // Restore what the user had copied — except secrets: password managers mark them
// concealed/transient and clear them on their own; putting them back would defeat that.
pb.clearContents() pb.clearContents()
let secretTypes: Set<String> = ["org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType",
"org.nspasteboard.AutoGeneratedType", "com.agilebits.onepassword"]
if saved.contains(where: { $0.keys.contains { secretTypes.contains($0.rawValue) } }) { return text }
let items = saved.map { dict -> NSPasteboardItem in let items = saved.map { dict -> NSPasteboardItem in
let it = NSPasteboardItem() let it = NSPasteboardItem()
for (t, v) in dict { it.setData(v, forType: t) } for (t, v) in dict { it.setData(v, forType: t) }
+5 -1
View File
@@ -14,7 +14,11 @@ configs:
settings: settings:
base: base:
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: NO # Hardened runtime, no get-task-allow: other processes can't inject code into BroV
# and borrow its Accessibility / Apple Events permissions.
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
CODE_SIGN_ENTITLEMENTS: Resources/BroV.entitlements
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete" OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
# Ad-hoc signature: runs locally, no Apple Developer account needed. # Ad-hoc signature: runs locally, no Apple Developer account needed.
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""BroV network core prototype: builds core/config.yaml for mihomo from src/*.
keys/*.vpnkey one vpn:// key per Amnezia connection (AmneziaWG 2/3); BroV adds them
and rewrites keys/amnezia.yaml itself, gen.py does the same on a full build
src/saga.conf WireGuard client config -> only 192.168.8.0/24
src/planet9.conf WireGuard client config -> only 192.168.68.0/24
src/vless.sub 3x-ui subscription URL (all VLESS nodes)
Secrets stay in this private folder; nothing here goes to git.
"""
import base64, json, os, re, secrets, zlib, configparser
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "src")
KEYS = os.path.join(HERE, "keys")
# Installed next to its inputs in /Library/Application Support/BroV/netcore (root, 0700):
# the root core reads its config from here, nothing user-writable is involved.
CORE = HERE
HOME_NET = "192.168.10.0/24"
CLIENTS = { # name: (conf file, routed subnets)
"saga": ("saga.conf", ["192.168.8.0/24", "10.0.0.0/24"]),
"planet9": ("planet9.conf", ["192.168.68.0/24", "172.3.3.0/24"]),
}
AI_DOMAINS = ["anthropic.com", "claude.ai", "claude.com", "openai.com", "chatgpt.com",
"oaistatic.com", "oaiusercontent.com", "github.com", "githubusercontent.com"]
def mid(v, default):
"""'100-120' -> 110 (mihomo takes single ints for timers)."""
if v is None or v == "":
return default
m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", str(v))
return (int(m.group(1)) + int(m.group(2))) // 2 if m else int(v)
def amnezia(path, name):
key = open(path).read().strip()[len("vpn://"):]
key += "=" * (-len(key) % 4)
j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:]))
awg = j["containers"][0]["awg"]
c = awg["last_config"] if isinstance(awg["last_config"], dict) else json.loads(awg["last_config"])
ver = 3 if c.get("HeaderProtectionKey") else 2
opt = {"version": ver, "jc": int(c["Jc"]), "jmin": int(c["Jmin"]), "jmax": int(c["Jmax"]),
"s1": int(c["S1"]), "s2": int(c["S2"])}
for k in ("S3", "S4"):
if c.get(k):
opt[k.lower()] = int(c[k])
for k in ("H1", "H2", "H3", "H4"):
v = c[k]
opt[k.lower()] = int(v) if str(v).isdigit() else v
for k in ("I1", "I2", "I3", "I4", "I5"):
if c.get(k):
opt[k.lower()] = c[k]
if ver == 3:
opt.update({
"header-protection-key": c["HeaderProtectionKey"],
"content-padding-addition": c.get("ContentPaddingAddition", "0"),
"rekey-after-time": mid(c.get("RekeyAfterTime"), 120),
"rekey-timeout": mid(c.get("RekeyTimeout"), 5),
"reject-after-time": mid(c.get("RejectAfterTime"), 180),
"keepalive-timeout": mid(c.get("KeepaliveTimeout"), 10),
"max-handshake-attempts": mid(c.get("MaxHandshakeAttempts"), 18),
"random-trailers": c.get("RandomTrailers") == "on",
"disable-cookies": c.get("DisableCookies") == "on",
})
return {
"name": name, "type": "wireguard", "server": c["hostName"], "port": int(c["port"]),
"ip": c["client_ip"], "private-key": c["client_priv_key"], "public-key": c["server_pub_key"],
"pre-shared-key": c.get("psk_key") or None, "mtu": int(c.get("mtu", 1376)), "udp": True,
"persistent-keepalive": mid(c.get("persistent_keep_alive"), 25),
"amnezia-wg-option": opt,
}
def wg(name, path):
p = configparser.ConfigParser()
p.optionxform = str
p.read(os.path.join(SRC, path))
i, peer = p["Interface"], p["Peer"]
host, port = peer["Endpoint"].rsplit(":", 1)
out = {"name": name, "type": "wireguard", "server": host, "port": int(port),
"ip": i["Address"].split("/")[0], "private-key": i["PrivateKey"],
"public-key": peer["PublicKey"], "mtu": int(i.get("MTU", 1420)), "udp": True}
if peer.get("PresharedKey"):
out["pre-shared-key"] = peer["PresharedKey"]
if peer.get("PersistentKeepalive"):
out["persistent-keepalive"] = int(peer["PersistentKeepalive"])
return out
def y(v, ind=0):
"""Tiny YAML emitter (no PyYAML dependency)."""
pad = " " * ind
if isinstance(v, dict):
lines = []
for k, x in v.items():
if x is None:
continue
if isinstance(x, (dict, list)) and x:
lines.append(f"{pad}{k}:\n{y(x, ind + 1)}")
else:
lines.append(f"{pad}{k}: {scalar(x)}")
return "\n".join(lines)
if isinstance(v, list):
lines = []
for x in v:
if isinstance(x, dict):
body = y(x, ind + 1).lstrip()
lines.append(f"{pad}- {body}")
else:
lines.append(f"{pad}- {scalar(x)}")
return "\n".join(lines)
return pad + scalar(v)
def scalar(x):
if isinstance(x, bool):
return "true" if x else "false"
if isinstance(x, (int, float)):
return str(x)
if isinstance(x, list) and not x:
return "[]"
return json.dumps(str(x), ensure_ascii=False)
def main():
os.makedirs(CORE, exist_ok=True)
secret_file = os.path.join(CORE, "api.secret")
if not os.path.exists(secret_file):
fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as f:
f.write(secrets.token_urlsafe(24))
api_secret = open(secret_file).read().strip()
# Amnezia connections live in their own provider file so BroV can add keys live.
keyfiles = sorted(f for f in os.listdir(KEYS) if f.endswith(".vpnkey"))
awg = [amnezia(os.path.join(KEYS, f), "AWG " + f[:-len(".vpnkey")]) for f in keyfiles]
prov = os.path.join(KEYS, "amnezia.yaml")
with open(os.open(prov, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
f.write(y({"proxies": awg}) + "\n")
proxies = [wg(n, f) for n, (f, _) in CLIENTS.items()]
sub = open(os.path.join(SRC, "vless.sub")).read().strip()
rules = [f"IP-CIDR,{HOME_NET},DIRECT,no-resolve", "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
# Home's own public IP (RustDesk, Gitea): never via a foreign exit.
"IP-CIDR,79.111.14.0/32,DIRECT,no-resolve", "DOMAIN-SUFFIX,sanjeev.ru,DIRECT"]
for name, (_, nets) in CLIENTS.items():
# Through a switch group: BroV turns client networks on/off without a reload.
rules += [f"IP-CIDR,{n},{name}-sw,no-resolve" for n in nets]
rules += [f"DOMAIN-SUFFIX,{d},ai-out" for d in AI_DOMAINS]
rules += ["DOMAIN-SUFFIX,ru,DIRECT", "DOMAIN-SUFFIX,su,DIRECT", "DOMAIN-SUFFIX,xn--p1ai,DIRECT",
"MATCH,ai-out"]
cfg = {
"mixed-port": 7890, "allow-lan": False, "mode": "rule", "log-level": "error", "ipv6": False,
"external-controller": "127.0.0.1:9097", "secret": api_secret, "unified-delay": True,
"find-process-mode": "strict",
"profile": {"store-selected": True},
"tun": {"enable": True, "stack": "mixed", "auto-route": True, "auto-detect-interface": True,
"dns-hijack": ["any:53"], "mtu": 1400},
"dns": {"enable": True, "ipv6": False, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16",
"fake-ip-filter": ["*.lan", "*.local", "+.duckdns.org"],
"default-nameserver": ["77.88.8.8", "1.1.1.1"],
"proxy-server-nameserver": ["77.88.8.8", "1.1.1.1"],
"nameserver": ["https://1.1.1.1/dns-query#ai-out", "https://8.8.8.8/dns-query#ai-out"],
"direct-nameserver": ["77.88.8.8", "77.88.8.1"]},
"proxies": proxies,
"proxy-providers": {
"amnezia-keys": {"type": "file", "path": "./keys/amnezia.yaml",
"health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204",
"interval": 300}},
"vless-cluster": {
# Fetch the list directly: the nodes themselves are dialled directly anyway.
"type": "http", "url": sub, "interval": 43200, "path": "./providers/vless.yaml", "proxy": "DIRECT",
"health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204", "interval": 300}}},
"proxy-groups": [
# What BroV's globe panel switches: "auto", the Amnezia group, or one VLESS node.
{"name": "ai-out", "type": "select", "proxies": ["auto", "amnezia"], "use": ["vless-cluster"]},
# Fastest alive exit among every Amnezia connection and every VLESS node;
# switches only when another one is 100+ ms faster.
{"name": "auto", "type": "url-test", "use": ["amnezia-keys", "vless-cluster"],
"url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False},
# Amnezia: "amnezia-auto" (fastest connection) or one fixed connection.
{"name": "amnezia", "type": "select", "proxies": ["amnezia-auto"], "use": ["amnezia-keys"]},
{"name": "amnezia-auto", "type": "url-test", "use": ["amnezia-keys"],
"url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False},
] + [
# Client networks: off (REJECT) until switched on in BroV.
{"name": f"{n}-sw", "type": "select", "proxies": ["REJECT", n]} for n in CLIENTS
],
"rules": rules,
}
# The API secret never leaves this root-only folder: BroV goes through netctl.py.
path = os.path.join(CORE, "config.yaml")
with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
f.write("# Generated by gen.py — do not edit by hand, do not share.\n" + y(cfg) + "\n")
print("wrote", path, "|", len(proxies), "proxies + vless subscription |", len(rules), "rules")
def check(path):
"""--check <file>: is this a usable Amnezia key? Prints JSON for BroV."""
try:
raw = open(path).read().strip()
if not raw.startswith("vpn://"):
raise ValueError("ключ должен начинаться с vpn://")
key = raw[len("vpn://"):]
key += "=" * (-len(key) % 4)
j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:]))
cont = j["containers"][0]
if "awg" not in cont:
raise ValueError("это не AmneziaWG (контейнер %s) — пока поддерживается только AmneziaWG" % cont.get("container"))
p = amnezia(path, "check")
print(json.dumps({"ok": True, "name": j.get("description") or p["server"], "server": p["server"],
"port": p["port"], "version": p["amnezia-wg-option"]["version"]}, ensure_ascii=False))
except Exception as e:
print(json.dumps({"ok": False, "error": str(e) or e.__class__.__name__}, ensure_ascii=False))
if __name__ == "__main__":
import sys
if len(sys.argv) == 3 and sys.argv[1] == "--check":
check(sys.argv[2])
else:
main()
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# BroV network core — install / update as root services (run by hand, once per update):
# sudo sh scripts/netcore/install.sh
#
# Layout after install (everything root-owned, nothing a user process can change):
# /Library/Application Support/BroV/mihomo the core, checked against the release SHA256
# /Library/Application Support/BroV/netctl.py narrow helper BroV talks to
# /Library/Application Support/BroV/netcore/ config, gen.py, keys, API secret (0700)
# /Library/LaunchDaemons/local.maksar.brov.netd.plist the core, at boot, restarted on crash
# /Library/LaunchDaemons/local.maksar.brov.netctl.plist the helper (socket /var/run/brov-netctl.sock,
# only your user may connect)
# First run migrates keys from ~/Library/Application Support/NotchBuddy/netcore and then
# deletes that user-writable copy (it is what made root trust user files).
#
# Undo: sudo sh scripts/netcore/uninstall.sh
set -e
[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; }
USER_NAME="${SUDO_USER:?запусти через sudo из своей учётной записи}"
USER_UID=$(id -u "$USER_NAME")
USER_HOME=$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory | awk '{print $2}')
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT="/Library/Application Support/BroV"
CORE="$ROOT/netcore"
OLD="$USER_HOME/Library/Application Support/NotchBuddy/netcore"
BIN="$ROOT/mihomo"
CORE_LABEL="local.maksar.brov.netd"
CTL_LABEL="local.maksar.brov.netctl"
MIHOMO_VERSION="v1.19.32"
MIHOMO_GZ_SHA="3312a6780652c622890fd4357c6a853bbf865464fd047ac7b7f52dab8de18652"
MIHOMO_BIN_SHA="94a386ec0149080deadd86b1f667363bde3c70f7489dba3258e52c56fc9a6d66"
if pgrep -qx AmneziaVPN; then
echo "Приложение AmneziaVPN запущено — закрой его (только приложение, служба не мешает)."
exit 1
fi
umask 077
install -d -m 755 -o root -g wheel "$ROOT"
install -d -m 700 -o root -g wheel "$CORE" "$CORE/keys" "$CORE/src" "$CORE/providers"
install -d -m 755 -o root -g wheel /Library/Logs/BroV
# 1. The core binary: keep the installed one if it matches, else fetch and verify.
if [ "$(shasum -a 256 "$BIN" 2>/dev/null | cut -d' ' -f1)" = "$MIHOMO_BIN_SHA" ]; then
echo "✓ mihomo $MIHOMO_VERSION на месте, контрольная сумма совпадает"
else
echo "→ Скачиваю mihomo $MIHOMO_VERSION с GitHub и проверяю SHA256"
TMP=$(mktemp -d)
curl -fsSL -o "$TMP/m.gz" "https://github.com/MetaCubeX/mihomo/releases/download/$MIHOMO_VERSION/mihomo-darwin-arm64-$MIHOMO_VERSION.gz"
[ "$(shasum -a 256 "$TMP/m.gz" | cut -d' ' -f1)" = "$MIHOMO_GZ_SHA" ] || { echo "✗ архив не совпал с официальной суммой"; rm -rf "$TMP"; exit 1; }
gunzip -c "$TMP/m.gz" > "$TMP/mihomo"
install -m 755 -o root -g wheel "$TMP/mihomo" "$BIN"
rm -rf "$TMP"
fi
# 2. Helper scripts from the repo (no secrets in them).
install -m 700 -o root -g wheel "$HERE/gen.py" "$CORE/gen.py"
install -m 755 -o root -g wheel "$HERE/netctl.py" "$ROOT/netctl.py"
# 3. Migrate secrets from the old user folder — regular files only, never symlinks.
copy_regular() { # src dst
[ -f "$1" ] && [ ! -L "$1" ] && install -m 600 -o root -g wheel "$1" "$2"
return 0
}
if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then
echo "→ Переношу ключи и настройки в $CORE"
for f in "$OLD"/keys/*.vpnkey; do copy_regular "$f" "$CORE/keys/$(basename "$f")"; done
for f in "$OLD"/src/*; do copy_regular "$f" "$CORE/src/$(basename "$f")"; done
[ -f "$CORE/api.secret" ] || copy_regular "$OLD/api.secret" "$CORE/api.secret"
[ -f "$CORE/cache.db" ] || copy_regular "$OLD/cache.db" "$CORE/cache.db"
copy_regular "$OLD/providers/vless.yaml" "$CORE/providers/vless.yaml"
fi
ls "$CORE"/keys/*.vpnkey >/dev/null 2>&1 || { echo "✗ нет ни одного ключа Амнезии в $CORE/keys"; exit 1; }
for f in saga.conf planet9.conf vless.sub; do
[ -f "$CORE/src/$f" ] || { echo "✗ нет $CORE/src/$f"; exit 1; }
done
chown -R root:wheel "$CORE"
chmod -R go-rwx "$CORE"
# 4. Build and check the config as root.
echo "→ Собираю конфиг"
(cd "$CORE" && /usr/bin/python3 gen.py)
"$BIN" -t -d "$CORE" -f "$CORE/config.yaml" >/dev/null
: > /Library/Logs/BroV/netcore.log
chmod 600 /Library/Logs/BroV/netcore.log
# 5. Services.
write_plist() { # label, then program arguments
label=$1; shift
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
echo '<plist version="1.0"><dict>'
echo " <key>Label</key><string>$label</string>"
echo ' <key>ProgramArguments</key><array>'
for a in "$@"; do echo " <string>$a</string>"; done
echo ' </array>'
echo ' <key>RunAtLoad</key><true/>'
echo ' <key>KeepAlive</key><true/>'
echo ' <key>ThrottleInterval</key><integer>5</integer>'
echo " <key>StandardOutPath</key><string>/Library/Logs/BroV/$label.log</string>"
echo " <key>StandardErrorPath</key><string>/Library/Logs/BroV/$label.log</string>"
echo '</dict></plist>'
} > "/Library/LaunchDaemons/$label.plist"
chown root:wheel "/Library/LaunchDaemons/$label.plist"
chmod 644 "/Library/LaunchDaemons/$label.plist"
plutil -lint "/Library/LaunchDaemons/$label.plist" >/dev/null
}
write_plist "$CORE_LABEL" "$BIN" -d "$CORE" -f "$CORE/config.yaml"
write_plist "$CTL_LABEL" /usr/bin/python3 "$ROOT/netctl.py" "$USER_UID"
rm -f /Library/Logs/BroV/netcore.log
echo "→ Запускаю службы"
for label in "$CORE_LABEL" "$CTL_LABEL"; do
launchctl bootout "system/$label" 2>/dev/null || true
done
sleep 1
for label in "$CORE_LABEL" "$CTL_LABEL"; do
launchctl bootstrap system "/Library/LaunchDaemons/$label.plist" 2>/dev/null || launchctl kickstart -k "system/$label"
done
i=0
until [ -S /var/run/brov-netctl.sock ] || [ $i -ge 20 ]; do sleep 0.5; i=$((i+1)); done
if launchctl print "system/$CORE_LABEL" | grep -q 'state = running' && [ -S /var/run/brov-netctl.sock ]; then
echo "✓ Ядро и помощник работают."
else
echo "⚠ Что-то не поднялось. Логи: /Library/Logs/BroV/"
exit 1
fi
# 6. Remove the old user-writable copy (keys, secret, binary) — root no longer reads it.
if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then
rm -rf "$OLD"
echo "✓ Старая копия ключей в ~/Library/Application Support/NotchBuddy/netcore удалена"
fi
rm -f "$USER_HOME/Library/Application Support/NotchBuddy/netcore.json"
echo "Готово. Управление — глобус 🌐 в чёлке BroV."
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/python3
"""BroV network core — narrow root helper (LaunchDaemon local.maksar.brov.netctl).
The core (mihomo) runs as root with its config, keys and API secret in
/Library/Application Support/BroV/netcore (root, 0700). BroV never sees those: it talks
to this helper over a Unix socket that only the installing user may open, and the helper
allows exactly these operations:
state groups, provider nodes with delays, TUN on/off
select {group, name} ai-out / amnezia / saga-sw / planet9-sw, name must be a member
delay {group}|{proxy} speed test of ai-out / amnezia, or of the saga / planet9 tunnel
tun {on} traffic capture on/off
add_key {text} vpn:// Amnezia key: checked by gen.py, stored, provider reloaded
remove_key {name} "AWG <slug>" connection
One JSON object per line in, one per line out. Nothing here can rewrite the core config
or point traffic elsewhere.
"""
import json
import os
import re
import socket
import struct
import subprocess
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
ROOT = "/Library/Application Support/BroV"
CORE = os.path.join(ROOT, "netcore")
KEYS = os.path.join(CORE, "keys")
SOCK = "/var/run/brov-netctl.sock"
API = "http://127.0.0.1:9097"
TEST_URL = "https://www.gstatic.com/generate_204"
SELECT_GROUPS = {"ai-out", "amnezia", "saga-sw", "planet9-sw"}
DELAY_GROUPS = {"ai-out", "amnezia"}
CLIENT_TUNNELS = {"saga", "planet9"}
MAX_REQUEST = 64 * 1024
ALLOWED_UID = int(sys.argv[1]) if len(sys.argv) > 1 else -1
gen_lock = threading.Lock()
def secret():
with open(os.path.join(CORE, "api.secret")) as f:
return f.read().strip()
def api(method, path, body=None, timeout=8):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, method=method, data=data, headers={
"Authorization": "Bearer " + secret(), "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def q(name):
return urllib.parse.quote(name, safe="")
def gen(*args):
return subprocess.run(["/usr/bin/python3", os.path.join(CORE, "gen.py"), *args], cwd=CORE,
capture_output=True, text=True, timeout=60).stdout
# MARK: - Commands
def cmd_state(_):
proxies = api("GET", "/proxies").get("proxies", {})
keep = {}
for name, p in proxies.items():
keep[name] = {"now": p.get("now"), "all": p.get("all"), "history": (p.get("history") or [])[-1:]}
providers = {}
for prov in ("vless-cluster", "amnezia-keys"):
try:
lst = api("GET", "/providers/proxies/" + prov).get("proxies", [])
except Exception:
lst = []
providers[prov] = [{"name": x.get("name"), "history": (x.get("history") or [])[-1:]} for x in lst]
tun = api("GET", "/configs").get("tun", {}).get("enable", False)
return {"ok": True, "proxies": keep, "providers": providers, "tun": tun}
def cmd_select(r):
group, name = r.get("group"), r.get("name")
if group not in SELECT_GROUPS or not isinstance(name, str):
return {"ok": False, "error": "группа не разрешена"}
members = api("GET", "/proxies/" + q(group)).get("all", [])
if name not in members:
return {"ok": False, "error": "такого варианта нет в группе"}
api("PUT", "/proxies/" + q(group), {"name": name})
return {"ok": True}
def cmd_delay(r):
test = "url=" + q(TEST_URL) + "&timeout=5000"
if r.get("group") in DELAY_GROUPS:
return {"ok": True, "delays": api("GET", "/group/%s/delay?%s" % (q(r["group"]), test), timeout=10)}
if r.get("proxy") in CLIENT_TUNNELS:
try:
d = api("GET", "/proxies/%s/delay?%s" % (q(r["proxy"]), test), timeout=10).get("delay", 0)
except Exception:
d = 0
return {"ok": True, "delays": {r["proxy"]: d}}
return {"ok": False, "error": "замер не разрешён"}
def cmd_tun(r):
on = r.get("on")
if not isinstance(on, bool):
return {"ok": False, "error": "нужно on: true/false"}
api("PATCH", "/configs", {"tun": {"enable": on}})
return {"ok": True}
def provider_reload_and_test(proxy):
api("PUT", "/providers/proxies/amnezia-keys")
try:
hc = api("GET", "/providers/proxies/amnezia-keys/%s/healthcheck?url=%s&timeout=6000"
% (q(proxy), q(TEST_URL)), timeout=10)
return hc.get("delay", 0)
except Exception:
return 0
def cmd_add_key(r):
text = r.get("text")
if not isinstance(text, str) or not text.strip().startswith("vpn://") or len(text) > 20000:
return {"ok": False, "error": "ключ должен начинаться с vpn://"}
with gen_lock:
pending = os.path.join(KEYS, ".pending.vpnkey")
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as f:
f.write(text.strip())
try:
check = json.loads(gen("--check", pending).strip().splitlines()[-1])
except Exception:
check = {"ok": False, "error": "не удалось разобрать ключ"}
if not check.get("ok"):
os.remove(pending)
return {"ok": False, "error": check.get("error", "ключ не подходит")}
slug = re.sub(r"[^a-z0-9]+", "-", str(check.get("name", "amnezia")).lower()).strip("-") or "amnezia"
dest, n = os.path.join(KEYS, slug + ".vpnkey"), 2
while os.path.exists(dest):
dest, n = os.path.join(KEYS, "%s-%d.vpnkey" % (slug, n)), n + 1
os.rename(pending, dest)
gen()
proxy = "AWG " + os.path.basename(dest)[:-len(".vpnkey")]
return {"ok": True, "name": check.get("name"), "server": "%s:%s" % (check.get("server"), check.get("port")),
"proxy": proxy, "delay": provider_reload_and_test(proxy)}
def cmd_remove_key(r):
name = r.get("name", "")
m = re.fullmatch(r"AWG ([a-z0-9-]+)", name) if isinstance(name, str) else None
if not m:
return {"ok": False, "error": "неверное имя подключения"}
path = os.path.join(KEYS, m.group(1) + ".vpnkey")
if not os.path.isfile(path) or os.path.islink(path):
return {"ok": False, "error": "такого подключения нет"}
with gen_lock:
if api("GET", "/proxies/amnezia").get("now") == name:
api("PUT", "/proxies/amnezia", {"name": "amnezia-auto"})
os.remove(path)
gen()
api("PUT", "/providers/proxies/amnezia-keys")
return {"ok": True}
COMMANDS = {"state": cmd_state, "select": cmd_select, "delay": cmd_delay, "tun": cmd_tun,
"add_key": cmd_add_key, "remove_key": cmd_remove_key}
# MARK: - Socket server
def peer_uid(conn):
# LOCAL_PEERCRED (SOL_LOCAL=0, opt=1) → struct xucred { u_int cr_version; uid_t cr_uid; … }
cred = conn.getsockopt(0, 1, 76)
return struct.unpack_from("I", cred, 4)[0]
def handle(conn):
try:
if peer_uid(conn) not in (0, ALLOWED_UID):
return
conn.settimeout(30)
buf = b""
while b"\n" not in buf and len(buf) < MAX_REQUEST:
chunk = conn.recv(8192)
if not chunk:
break
buf += chunk
req = json.loads(buf.split(b"\n", 1)[0] or b"{}")
fn = COMMANDS.get(req.get("cmd"))
if fn is None:
resp = {"ok": False, "error": "неизвестная команда"}
else:
try:
resp = fn(req)
except urllib.error.URLError:
resp = {"ok": False, "error": "ядро не отвечает"}
except Exception as e:
resp = {"ok": False, "error": str(e) or e.__class__.__name__}
conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode())
except Exception:
pass
finally:
conn.close()
def main():
if ALLOWED_UID < 0:
sys.exit("usage: netctl.py <uid allowed to connect>")
try:
os.unlink(SOCK)
except FileNotFoundError:
pass
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
old = os.umask(0o177)
srv.bind(SOCK)
os.umask(old)
os.chown(SOCK, ALLOWED_UID, -1)
os.chmod(SOCK, 0o600)
srv.listen(8)
while True:
conn, _ = srv.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Removes the BroV network core services (sudo sh scripts/netcore/uninstall.sh).
# Keys stay in /Library/Application Support/BroV/netcore unless you pass --purge.
# After this the Mac goes online by itself; turn AmneziaVPN back on if needed.
set -e
[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; }
for label in local.maksar.brov.netctl local.maksar.brov.netd; do
launchctl bootout "system/$label" 2>/dev/null || true
rm -f "/Library/LaunchDaemons/$label.plist"
done
rm -f /var/run/brov-netctl.sock
if [ "$1" = "--purge" ]; then
rm -rf "/Library/Application Support/BroV" /Library/Logs/BroV
echo "✓ Службы, ядро и ключи удалены."
else
echo "✓ Службы ядра BroV остановлены и удалены. Ключи остались в /Library/Application Support/BroV/netcore (root)."
fi