diff --git a/.gitignore b/.gitignore
index a475ef7..30680da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,11 @@ DerivedData/
# Local only
.claude/
-# Secrets never live in the repo (kept in ~/Documents/brov-secrets)
+# Secrets never live in the repo (root core folder, ~/.brov-secrets)
*.conf
guide-*.md
brov-secrets/
+*.vpnkey
+api.secret
+netcore.json
+config.yaml
diff --git a/CLAUDE.md b/CLAUDE.md
index 3205d0e..330b8ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,10 +19,17 @@ DerivedData must stay outside ~/Documents (iCloud adds Finder attributes and cod
## 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.
-- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`).
-- Secrets live in the Keychain, never on disk or in git.
+- 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 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 overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
- Never approve a Claude Code permission without an explicit click.
- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
- 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.
+
diff --git a/NotchBuddy/Resources/BroV.entitlements b/NotchBuddy/Resources/BroV.entitlements
new file mode 100644
index 0000000..36756c8
--- /dev/null
+++ b/NotchBuddy/Resources/BroV.entitlements
@@ -0,0 +1,9 @@
+
+
+
+
+
+ com.apple.security.automation.apple-events
+
+
+
diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift
index df080ff..45084aa 100644
--- a/NotchBuddy/Sources/App/AppDelegate.swift
+++ b/NotchBuddy/Sources/App/AppDelegate.swift
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
exit(0)
}
BroVLaunchAgent.ensure()
+ Self.pruneInbox()
setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
@@ -33,6 +34,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
#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
private func setupMenuBarItem() {
@@ -56,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Actions
@objc private func openIsland() {
+ islandController?.fsm.openedExternally()
islandController?.expand(to: .overview)
}
diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift
index b8679e8..65a8df1 100644
--- a/NotchBuddy/Sources/App/AppState.swift
+++ b/NotchBuddy/Sources/App/AppState.swift
@@ -378,7 +378,9 @@ final class AppState: ObservableObject {
@Published var pendingApproval: ApprovalInfo? = nil
// 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.
// Not @Published — steps[] changes already trigger redraws.
@@ -411,11 +413,13 @@ final class AppState: ObservableObject {
private func resetSessionDiffTimer(for pillId: String) {
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
- DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) }
+ self?.clearSessionDiffs(for: pillId)
}
sessionDiffTimers[pillId] = work
- DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
}
#if !APPSTORE
diff --git a/NotchBuddy/Sources/App/AskQuestion.swift b/NotchBuddy/Sources/App/AskQuestion.swift
index e3e7266..c599268 100644
--- a/NotchBuddy/Sources/App/AskQuestion.swift
+++ b/NotchBuddy/Sources/App/AskQuestion.swift
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
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 {
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
// Returns nil if the payload is malformed (fallback → Allow/Deny card).
static func parse(toolInput: [String: Any]) -> AskQuestion? {
diff --git a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift
index a627431..3d330ec 100644
--- a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift
+++ b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
"--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] }
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 = ["acceptEdits", "plan"]
+ if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
let p = Process()
p.executableURL = URL(fileURLWithPath: binary)
diff --git a/NotchBuddy/Sources/App/ClaudeSettingsFile.swift b/NotchBuddy/Sources/App/ClaudeSettingsFile.swift
new file mode 100644
index 0000000..41f48d7
--- /dev/null
+++ b/NotchBuddy/Sources/App/ClaudeSettingsFile.swift
@@ -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
+ }
+}
diff --git a/NotchBuddy/Sources/App/DangerCheck.swift b/NotchBuddy/Sources/App/DangerCheck.swift
index fab51b5..0d4d332 100644
--- a/NotchBuddy/Sources/App/DangerCheck.swift
+++ b/NotchBuddy/Sources/App/DangerCheck.swift
@@ -56,12 +56,25 @@ enum DangerCheck {
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(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] {
var out: [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)
}
}
diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift
index 2497802..7655f2a 100644
--- a/NotchBuddy/Sources/App/HookServer.swift
+++ b/NotchBuddy/Sources/App/HookServer.swift
@@ -1156,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
// MARK: - Claude Code settings.json hook installer
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.
func previewClaudeHooks() throws -> String {
- let data = try buildHooksData()
+ let (data, original) = try buildHooksData()
_pendingHooksData = data
+ _pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? ""
}
/// 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 {
guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
- // Backup first
- 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)
+ try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
_pendingHooksData = nil
+ _pendingHooksOriginal = nil
}
- private func buildHooksData() throws -> Data {
+ private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
- var settings: [String: Any] = [:]
- if let data = try? Data(contentsOf: settingsURL),
- let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
- settings = parsed
- }
+ // Unreadable or invalid settings must stop here, never count as empty.
+ let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
+ var settings = snapshot.object
let hookPath = Self.hookScriptPath
#if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 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 {
- 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.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
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 {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
- guard let data = try? Data(contentsOf: settingsURL),
- var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- var hooks = settings["hooks"] as? [String: Any] else { return }
+ let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
+ var settings = snapshot.object
+ guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
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
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
}
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 _pendingDeletePrevious: Bool = false
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
- var settings: [String: Any] = [:]
- if let d = try? Data(contentsOf: settingsURL),
- let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
- settings = parsed
- }
+ // Unreadable or invalid settings must stop here, never count as empty.
+ let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
+ let settings = snapshot.object
let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data
+ _pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
- 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)
+ try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
// Commit side effects only after successful write
if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL)
}
_pendingStatusLineData = nil
+ _pendingStatusLineOriginal = nil
_pendingPreviousData = nil
_pendingDeletePrevious = false
}
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// 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).
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/
let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json")
- let formatter = DateFormatter()
- 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)
+ try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
}
func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
- guard let data = try? Data(contentsOf: settingsURL),
- var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- var hooks = settings["hooks"] as? [String: Any] else { return }
+ let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
+ var settings = snapshot.object
+ guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
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")
}
- private func buildHooksData(claudeURL: URL) throws -> Data {
+ private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
- var settings: [String: Any] = [:]
- if let data = try? Data(contentsOf: settingsURL),
- let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
- settings = parsed
- }
+ // Unreadable or invalid settings must stop here, never count as empty.
+ let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
+ var settings = snapshot.object
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 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 {
- 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("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
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
diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift
index a8fe331..178dcf0 100644
--- a/NotchBuddy/Sources/App/IslandStateMachine.swift
+++ b/NotchBuddy/Sources/App/IslandStateMachine.swift
@@ -20,8 +20,14 @@ final class IslandStateMachine {
/// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island.
var isHeldOpen: (() -> Bool)?
- /// home → petit delay (seconds). Override for debug.
- var homeToPetitDelay: TimeInterval = 15
+ /// home → petit delay (seconds), kept in sync with the auto-close preference.
+ var homeToPetitDelay: TimeInterval = 15 {
+ didSet {
+ guard homeToPetitDelay != oldValue,
+ state == .home, homeCollapseWork != nil else { return }
+ scheduleHomeCollapse()
+ }
+ }
/// petit → hidden delay (seconds). Override for debug.
var petitToHiddenDelay: TimeInterval = 60
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift
index eb0869b..404cacc 100644
--- a/NotchBuddy/Sources/App/IslandViewContent.swift
+++ b/NotchBuddy/Sources/App/IslandViewContent.swift
@@ -395,7 +395,7 @@ struct QuestionView: View {
Text(item.question)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
- .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
// Options (wrapping) or "Other…" compact inline row
if curOther {
HStack(spacing: 6) {
@@ -429,6 +429,43 @@ struct QuestionView: View {
.buttonStyle(.plain)
.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 {
ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift
index a1bfa5e..8135844 100644
--- a/NotchBuddy/Sources/App/IslandWindowController.swift
+++ b/NotchBuddy/Sources/App/IslandWindowController.swift
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
private var frameTimer: Timer?
private var keyMonitor: Any?
private var viewSubscription: AnyCancellable?
+ private var autoCloseSubscription: AnyCancellable?
// Confused recovery timer (set by handleDizzy)
private var confusedRecoveryTimer: DispatchWorkItem?
@@ -163,6 +164,11 @@ final class IslandWindowController: NSWindowController {
// MARK: - FSM wiring
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
guard let self else { return }
switch to {
@@ -387,15 +393,19 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now
}
- /// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
- /// only a pending approval still keeps the island open.
- func collapse(byUser: Bool = false) {
- if byUser {
- guard state.pendingApproval == nil else { return }
- } else {
+ /// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too.
+ /// `allowPendingApproval`: the notch's own Escape, jump-to-terminal — may fold the
+ /// approval card (but not a chat/terminal tab).
+ /// Folding a pending approval never answers it: the request stays pending, the island
+ /// stays compact (held open) and a click or ⌃⌥A brings the card back.
+ 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 }
}
- state.isPinned = false
+ if !keepsApprovalPending { state.isPinned = false }
finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now).
fsm.collapse()
@@ -418,6 +428,7 @@ final class IslandWindowController: NSWindowController {
collapse(byUser: true)
} else {
islandPanel.makeKey()
+ fsm.openedExternally()
expand(to: defaultView())
}
@@ -428,6 +439,7 @@ final class IslandWindowController: NSWindowController {
case .goToAlert:
if state.pendingApproval != nil {
islandPanel.makeKey()
+ fsm.openedExternally()
expand(to: .approval)
} else if state.pendingQuestion != nil {
islandPanel.makeKey()
@@ -535,8 +547,9 @@ final class IslandWindowController: NSWindowController {
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
- if !consumed && state.mode == .expanded && !state.isPinned {
- collapse()
+ let canCollapse = !state.isPinned || state.pendingApproval != nil
+ if !consumed && state.mode == .expanded && canCollapse {
+ collapse(allowPendingApproval: true)
}
return true
}
@@ -590,7 +603,7 @@ final class IslandWindowController: NSWindowController {
NSWorkspace.shared.open(
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
}
- collapse()
+ collapse(allowPendingApproval: true)
}
private func performAttachFrontWindow() {
@@ -615,6 +628,8 @@ final class IslandWindowController: NSWindowController {
Task { @MainActor in
guard let self = self else { return }
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 {
self.collapse()
}
@@ -1187,6 +1202,10 @@ func islandSize(mode: IslandMode, view: IslandView,
let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат).
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)
}
}
diff --git a/NotchBuddy/Sources/App/NetPanel.swift b/NotchBuddy/Sources/App/NetPanel.swift
index d896c90..303db09 100644
--- a/NotchBuddy/Sources/App/NetPanel.swift
+++ b/NotchBuddy/Sources/App/NetPanel.swift
@@ -3,8 +3,8 @@ import AppKit
// MARK: - Networks (globe in the header)
//
-// BroV is the remote for the network core (mihomo). It talks to the core's local REST
-// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json):
+// 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 "-sw" switched between REJECT and
// the client's WireGuard tunnel.
@@ -45,64 +45,87 @@ final class NetCore: ObservableObject {
/// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:]
- private var base = ""
- private var secret = ""
static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"),
]
- private func loadEndpoint() -> Bool {
- let url = FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json")
- guard let data = try? Data(contentsOf: url),
- let j = try? JSONSerialization.jsonObject(with: data) as? [String: String],
- let c = j["controller"], let s = j["secret"] else { return false }
- base = c; secret = s
- return true
+ // 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 func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
- timeout: TimeInterval = 4) async -> Any? {
- guard !base.isEmpty || loadEndpoint(),
- let url = URL(string: base + path) else { return nil }
- var r = URLRequest(url: url, timeoutInterval: timeout)
- r.httpMethod = method
- r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization")
- if let body {
- r.setValue("application/json", forHTTPHeaderField: "Content-Type")
- r.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ 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.size))
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.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) }
}
- guard let (data, resp) = try? await URLSession.shared.data(for: r),
- let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
- return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:]
+ let connected = withUnsafePointer(to: &addr) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ connect(fd, $0, socklen_t(MemoryLayout.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.. 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 }
@@ -141,108 +164,63 @@ final class NetCore: ObservableObject {
/// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async {
busy = true
- let test = "url=https://www.gstatic.com/generate_204&timeout=5000"
- if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
+ 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 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 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()
busy = false
}
func select(exit name: String) async {
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")
await refresh()
}
func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
- _ = await request("/proxies/\(id)-sw", method: "PUT", body: ["name": on ? id : "REJECT"])
+ _ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : "REJECT"])
SoundEngine.shared.play(on ? "pop" : "close")
- if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) }
+ 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 request("/proxies/amnezia", method: "PUT", body: ["name": name])
- if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) }
+ _ = 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()
}
- static var coreDir: URL {
- 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.
+ /// 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://") }
- let keys = Self.coreDir.appendingPathComponent("keys")
- let pending = keys.appendingPathComponent(".pending.vpnkey")
- 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 ?? "не удалось разобрать")")
+ guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
+ return (false, "Помощник сетевого ядра не отвечает.")
}
- let name = (j["name"] as? String) ?? "amnezia"
- let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)"
- // 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
+ guard r["ok"] as? Bool == true else {
+ return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
}
- 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()
- 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")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
}
@@ -250,11 +228,7 @@ final class NetCore: ObservableObject {
}
func removeAmnezia(_ proxyName: String) async {
- let file = String(proxyName.dropFirst(4)) + ".vpnkey"
- 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)
+ _ = await call(["cmd": "remove_key", "name": proxyName])
SoundEngine.shared.play("close")
await refresh()
}
@@ -272,7 +246,7 @@ final class NetCore: ObservableObject {
}
lastError = nil
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")
try? await Task.sleep(for: .seconds(1))
await refresh()
@@ -359,7 +333,7 @@ struct NetPanel: View {
}
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))
.foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled)
diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift
index a8063e9..fc8d65a 100644
--- a/NotchBuddy/Sources/App/SettingsView.swift
+++ b/NotchBuddy/Sources/App/SettingsView.swift
@@ -656,7 +656,6 @@ struct SettingsView: View {
Text("Спрашивать в вырезе").tag("default")
Text("Принимать правки").tag("acceptEdits")
Text("Только план").tag("plan")
- Text("Без проверок (опасно)").tag("bypassPermissions")
}
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
diff --git a/NotchBuddy/Sources/App/Translator.swift b/NotchBuddy/Sources/App/Translator.swift
index 7c150e1..96229ff 100644
--- a/NotchBuddy/Sources/App/Translator.swift
+++ b/NotchBuddy/Sources/App/Translator.swift
@@ -158,8 +158,12 @@ enum SelectionGrabber {
}
guard pb.changeCount != before else { return nil }
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()
+ let secretTypes: Set = ["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 it = NSPasteboardItem()
for (t, v) in dict { it.setData(v, forType: t) }
diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml
index 41bb6a1..9dc0665 100644
--- a/NotchBuddy/project.yml
+++ b/NotchBuddy/project.yml
@@ -14,7 +14,11 @@ configs:
settings:
base:
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"
# Ad-hoc signature: runs locally, no Apple Developer account needed.
CODE_SIGN_STYLE: Manual
diff --git a/scripts/netcore/gen.py b/scripts/netcore/gen.py
new file mode 100755
index 0000000..3a15ab5
--- /dev/null
+++ b/scripts/netcore/gen.py
@@ -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 : 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()
diff --git a/scripts/netcore/install.sh b/scripts/netcore/install.sh
new file mode 100644
index 0000000..d697033
--- /dev/null
+++ b/scripts/netcore/install.sh
@@ -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 ''
+ echo ''
+ echo ''
+ echo " Label$label"
+ echo ' ProgramArguments'
+ for a in "$@"; do echo " $a"; done
+ echo ' '
+ echo ' RunAtLoad'
+ echo ' KeepAlive'
+ echo ' ThrottleInterval5'
+ echo " StandardOutPath/Library/Logs/BroV/$label.log"
+ echo " StandardErrorPath/Library/Logs/BroV/$label.log"
+ echo ''
+ } > "/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."
diff --git a/scripts/netcore/netctl.py b/scripts/netcore/netctl.py
new file mode 100644
index 0000000..505b4b2
--- /dev/null
+++ b/scripts/netcore/netctl.py
@@ -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 " 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 ")
+ 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()
diff --git a/scripts/netcore/uninstall.sh b/scripts/netcore/uninstall.sh
new file mode 100644
index 0000000..cc2f977
--- /dev/null
+++ b/scripts/netcore/uninstall.sh
@@ -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