Compare commits

..

11 Commits

Author SHA1 Message Date
maksarsanjeev 9e02402555 install.sh: wait for both services before reporting (no false alarm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:42 +03:00
maksarsanjeev a7a0195d34 Network core: home WireGuard as a client network (off = direct at home, on = via home tunnel when away); drop Saga's overlapping 10.0.0.0/24 tunnel subnet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:31:14 +03:00
maksarsanjeev 0d5c68f83b Sign with the personal Apple Development certificate (team V3NLZK45Q4): stable identity, Accessibility survives rebuilds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:33:28 +03:00
maksarsanjeev efb3bac8fc Translator hotkey: Option + ` (key left of 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:07:35 +03:00
maksarsanjeev 52e1e8288b Security + upstream fixes
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore)
- Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days
- Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:49:11 +03:00
maksarsanjeev dc47247340 Efficiency: Release builds by default, only the visible island screen is built, character at 30 fps, adaptive mouse polling (60/30/10 Hz), hex colours parsed once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:13:47 +03:00
maksarsanjeev 91878fa2e3 Network panel: several Amnezia connections (expand the Amnezia row, pick a connection or Auto, add a vpn:// key with live check, remove), auto = fastest across all Amnezia and VLESS exits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:14:16 +03:00
maksarsanjeev 5a119d01a1 Network panel: core on/off (TUN) toggle without a password, Amnezia conflict guard, install hint for the netd service
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:42:40 +03:00
maksarsanjeev 14114d1a58 Header globe → network panel: pick the internet exit (auto, Amnezia, each VLESS node, with live delays) and switch client networks (Saga, Planet9) through the mihomo core API without reloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:29:33 +03:00
maksarsanjeev e11d06c73e Func is one button with a dark panel of functions; Translator (⌃⌥R or Func): selected text RU<->EN via macOS Translation or claude Haiku, card holds the notch open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:57:06 +03:00
maksarsanjeev 9570dc190f Func pill: eyedropper (colour in many formats in a new brov-chat tab, HEX to clipboard, context for claude) and live Mac card (CPU, cores, RAM, SSD)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:45:38 +03:00
30 changed files with 2410 additions and 101 deletions
+9
View File
@@ -9,3 +9,12 @@ DerivedData/
# Local only # Local only
.claude/ .claude/
# Secrets never live in the repo (root core folder, ~/.brov-secrets)
*.conf
guide-*.md
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)
} }
+12 -1
View File
@@ -27,6 +27,7 @@ enum DesktopApps {
struct AppGridView: View { struct AppGridView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var showSystem = false
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit. /// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? { private var claudeLimit: PillLimit? {
@@ -85,7 +86,7 @@ struct AppGridView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
ShelfPill() ShelfPill()
EmptyPill() FuncPill(state: state, showSystem: $showSystem)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude)) AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
@@ -96,6 +97,16 @@ struct AppGridView: View {
.padding(.trailing, 20) .padding(.trailing, 20)
.padding(.vertical, 6) .padding(.vertical, 6)
if let t = state.translation {
TranslationCard(state: state, result: t)
.transition(.opacity)
}
if showSystem {
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
.transition(.opacity)
}
#if !APPSTORE #if !APPSTORE
if state.showingPlanDetail { if state.showingPlanDetail {
CardBackground(wash: nil) CardBackground(wash: nil)
+10 -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
@@ -493,6 +497,9 @@ final class AppState: ObservableObject {
} }
} }
/// Func → Переводчик result shown on the home view.
@Published var translation: TranslationResult? = nil
// MARK: Agent health (BroV) // MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = [] @Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false @Published var claudeNeedsLogin = false
+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 -2
View File
@@ -14,7 +14,8 @@ struct BotCanvasView: View {
@State private var fader = MemojiFader() @State private var fader = MemojiFader()
var body: some View { var body: some View {
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in // 30 fps is plenty for the character (the display would drive 120 on ProMotion).
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: state.mode == .hidden)) { timeline in
Canvas { context, size in Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate let now = timeline.date.timeIntervalSinceReferenceDate
let dtRaw = min(0.05, now - engine.lastTime) let dtRaw = min(0.05, now - engine.lastTime)
@@ -191,7 +192,7 @@ struct MiniBotCanvasView: View {
} }
var body: some View { var body: some View {
TimelineView(.animation) { timeline in TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
Canvas { context, size in Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate let now = timeline.date.timeIntervalSinceReferenceDate
let dt = min(0.05, now - engine.lastTime) let dt = min(0.05, now - engine.lastTime)
+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)
@@ -275,6 +275,7 @@ final class ClaudeService {
let tab = state.activeTabId let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab) let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab } func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil
var prompt = query var prompt = query
// Context goes with the first message of a conversation, like the API chat. // Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context { if cli.sessionID == nil, let context {
@@ -293,6 +294,8 @@ final class ClaudeService {
} }
} }
if let seed { prompt = seed + "\n\n" + prompt }
let placeholder = ChatMessage(role: .assistant, content: "") let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id let msgId = placeholder.id
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) } if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
@@ -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)
} }
} }
+466
View File
@@ -0,0 +1,466 @@
import SwiftUI
import AppKit
import Darwin
// MARK: - Func pill (top-right slot of the home view)
//
// Small tools, one icon each:
// • Пипетка — pick any pixel on screen; a new brov-chat tab explains the colour in many
// formats (computed locally, instant) and the HEX goes to the clipboard. The colour is
// passed to claude as context for follow-up questions in that tab.
// • Мак — live card with CPU / cores / RAM / SSD of this Mac.
struct FuncPill: View {
@ObservedObject var state: AppState
@Binding var showSystem: Bool
@State private var open = false
@State private var hovered = false
var body: some View {
Button { open.toggle() } label: {
HStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 13))
.foregroundColor(Color(hex: "#A78BFA"))
Text("Func")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(Color(hex: "#F5F6F8"))
Spacer(minLength: 4)
Image(systemName: open ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(hex: "#5F646D"))
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(hovered || open ? Color(hex: "#A78BFA").opacity(0.14) : Color(hex: "#0E0F11")))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(hex: "#A78BFA").opacity(hovered || open ? 0.5 : 0.2), lineWidth: 1))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { hovered = h } }
.popover(isPresented: $open, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 2) {
FuncRow(symbol: "eyedropper", color: "#F472B6", title: "Пипетка",
subtitle: "цвет пикселя во всех форматах", hint: nil) {
open = false
ColorPicking.start(state: state)
}
FuncRow(symbol: "cpu", color: "#34D399", title: "Мак",
subtitle: "CPU, ядра, ОЗУ, SSD", hint: nil) {
open = false
state.translation = nil
withAnimation(.easeOut(duration: 0.18)) { showSystem = true }
}
FuncRow(symbol: "character.bubble", color: "#60A5FA", title: "Переводчик",
subtitle: "выделенный текст RU ↔ EN", hint: "⌥`") {
open = false
showSystem = false
TranslatorRun.start(state: state)
}
}
.padding(8)
.frame(width: 280)
.notchPopoverStyle()
}
}
}
struct FuncRow: View {
let symbol: String
let color: String
let title: String
let subtitle: String
let hint: String?
let action: () -> Void
@State private var hovered = false
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: color))
.frame(width: 26, height: 26)
.background(RoundedRectangle(cornerRadius: 7).fill(Color(hex: color).opacity(0.15)))
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.system(size: 13, weight: .semibold))
Text(subtitle).font(.system(size: 11)).foregroundColor(.secondary)
}
Spacer()
if let hint {
Text(hint).font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(hovered ? 0.08 : 0)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
struct FuncButton: View {
let symbol: String
let title: String
let action: () -> Void
@State private var hovered = false
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
Image(systemName: symbol).font(.system(size: 11, weight: .semibold))
Text(title).font(.system(size: 11, weight: .medium))
}
.foregroundColor(Color(hex: hovered ? "#F5F6F8" : "#B0B5BE"))
.padding(.horizontal, 9)
.frame(height: 26)
.background(Capsule().fill(Color.white.opacity(hovered ? 0.12 : 0.06)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
// MARK: - Eyedropper
@MainActor
enum ColorPicking {
private static var sampler: NSColorSampler?
static func start(state: AppState) {
SoundEngine.shared.play("blip")
let s = NSColorSampler()
sampler = s
s.show { picked in
Task { @MainActor in
sampler = nil
guard let picked, let c = picked.usingColorSpace(.sRGB) else { return }
deliver(ColorFormats(c), state: state)
}
}
}
private static func deliver(_ f: ColorFormats, state: AppState) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(f.hex, forType: .string)
// A fresh brov-chat tab (or the current chat one when all 6 tabs are taken).
if state.tabs.count < AppState.maxTabs {
state.openTab(.chat)
} else if let chat = state.tabs.first(where: { $0.kind == .chat }) {
state.activateTab(chat.id)
}
if let tab = state.activeTabId {
ChatTabs.shared.seed(tab, "Пользователь выбрал пипеткой цвет \(f.hex) (rgb \(f.rgbTriple)). Вопросы ниже — про этот цвет.")
}
state.chatHistory.append(ChatMessage(role: .assistant, content: f.markdown))
NotificationCenter.default.post(name: .hookExpand, object: IslandView.prompt)
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
}
}
struct ColorFormats {
let r: Double, g: Double, b: Double // 0…1 sRGB
init(_ c: NSColor) {
r = Double(c.redComponent); g = Double(c.greenComponent); b = Double(c.blueComponent)
}
var R: Int { Int((r * 255).rounded()) }
var G: Int { Int((g * 255).rounded()) }
var B: Int { Int((b * 255).rounded()) }
var hex: String { String(format: "#%02X%02X%02X", R, G, B) }
var rgbTriple: String { "\(R), \(G), \(B)" }
var hsl: (h: Int, s: Int, l: Int) {
let mx = max(r, g, b), mn = min(r, g, b), l = (mx + mn) / 2
guard mx != mn else { return (0, 0, Int((l * 100).rounded())) }
let d = mx - mn
let s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn)
return (hue(mx: mx, d: d), Int((s * 100).rounded()), Int((l * 100).rounded()))
}
var hsb: (h: Int, s: Int, b: Int) {
let mx = max(r, g, b), mn = min(r, g, b), d = mx - mn
let s = mx == 0 ? 0 : d / mx
return (d == 0 ? 0 : hue(mx: mx, d: d), Int((s * 100).rounded()), Int((mx * 100).rounded()))
}
var cmyk: (c: Int, m: Int, y: Int, k: Int) {
let k = 1 - max(r, g, b)
guard k < 1 else { return (0, 0, 0, 100) }
func p(_ v: Double) -> Int { Int(((1 - v - k) / (1 - k) * 100).rounded()) }
return (p(r), p(g), p(b), Int((k * 100).rounded()))
}
private func hue(mx: Double, d: Double) -> Int {
var h: Double
if mx == r { h = (g - b) / d + (g < b ? 6 : 0) }
else if mx == g { h = (b - r) / d + 2 }
else { h = (r - g) / d + 4 }
h *= 60
return Int(h.rounded()) % 360
}
/// Relative luminance → which text colour reads on top of it.
var luminance: Double {
func lin(_ v: Double) -> Double { v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) }
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
var contrastWhite: Double { 1.05 / (luminance + 0.05) }
var contrastBlack: Double { (luminance + 0.05) / 0.05 }
var nearestName: (en: String, ru: String) {
ColorNames.nearest(R, G, B)
}
var markdown: String {
let (h1, s1, l1) = hsl, (h2, s2, b2) = hsb, (c, m, y, k) = cmyk
let name = nearestName
let text = contrastWhite >= contrastBlack ? "белый" : "чёрный"
return """
**\(hex)** — \(name.ru) (ближе всего к `\(name.en)`). HEX уже в буфере обмена.
- **HEX:** `\(hex)` · без решётки `\(hex.dropFirst())` · с альфой `\(hex)FF`
- **RGB:** `rgb(\(R), \(G), \(B))` · дроби `\(String(format: "%.3f, %.3f, %.3f", r, g, b))`
- **HSL:** `hsl(\(h1), \(s1)%, \(l1)%)`
- **HSB/HSV:** `\(h2)°, \(s2)%, \(b2)%`
- **CMYK:** `\(c)%, \(m)%, \(y)%, \(k)%`
- **SwiftUI:** `Color(red: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)))`
- **NSColor/UIColor:** `NSColor(srgbRed: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)), alpha: 1)`
- **Android:** `0xFF\(hex.dropFirst())` · **0x:** `0x\(hex.dropFirst())`
- **Контраст:** с белым \(String(format: "%.1f", contrastWhite)):1, с чёрным \(String(format: "%.1f", contrastBlack)):1 — текст лучше делать \(text)
"""
}
}
/// CSS named colours (subset is enough to give a recognisable name).
enum ColorNames {
static let table: [(String, String, Int, Int, Int)] = [
("black", "чёрный", 0, 0, 0), ("white", "белый", 255, 255, 255), ("gray", "серый", 128, 128, 128),
("silver", "серебристый", 192, 192, 192), ("dimgray", "тёмно-серый", 105, 105, 105),
("gainsboro", "светло-серый", 220, 220, 220), ("red", "красный", 255, 0, 0),
("darkred", "тёмно-красный", 139, 0, 0), ("crimson", "малиновый", 220, 20, 60),
("firebrick", "кирпичный", 178, 34, 34), ("salmon", "лососевый", 250, 128, 114),
("tomato", "томатный", 255, 99, 71), ("coral", "коралловый", 255, 127, 80),
("orangered", "красно-оранжевый", 255, 69, 0), ("orange", "оранжевый", 255, 165, 0),
("darkorange", "тёмно-оранжевый", 255, 140, 0), ("gold", "золотой", 255, 215, 0),
("yellow", "жёлтый", 255, 255, 0), ("khaki", "хаки", 240, 230, 140),
("beige", "бежевый", 245, 245, 220), ("wheat", "пшеничный", 245, 222, 179),
("tan", "песочный", 210, 180, 140), ("peru", "перу (коричнево-рыжий)", 205, 133, 63),
("chocolate", "шоколадный", 210, 105, 30), ("sienna", "сиена", 160, 82, 45),
("saddlebrown", "коричневый", 139, 69, 19), ("maroon", "бордовый", 128, 0, 0),
("olive", "оливковый", 128, 128, 0), ("yellowgreen", "жёлто-зелёный", 154, 205, 50),
("lime", "лаймовый", 0, 255, 0), ("limegreen", "лаймово-зелёный", 50, 205, 50),
("green", "зелёный", 0, 128, 0), ("darkgreen", "тёмно-зелёный", 0, 100, 0),
("forestgreen", "лесной зелёный", 34, 139, 34), ("seagreen", "морской зелёный", 46, 139, 87),
("mediumseagreen", "изумрудный", 60, 179, 113), ("springgreen", "весенний зелёный", 0, 255, 127),
("teal", "бирюзово-зелёный (teal)", 0, 128, 128), ("turquoise", "бирюзовый", 64, 224, 208),
("cyan", "голубой (циан)", 0, 255, 255), ("lightblue", "светло-голубой", 173, 216, 230),
("skyblue", "небесный", 135, 206, 235), ("deepskyblue", "ярко-голубой", 0, 191, 255),
("dodgerblue", "синий доджер", 30, 144, 255), ("steelblue", "стальной синий", 70, 130, 180),
("royalblue", "королевский синий", 65, 105, 225), ("blue", "синий", 0, 0, 255),
("mediumblue", "средне-синий", 0, 0, 205), ("navy", "тёмно-синий (navy)", 0, 0, 128),
("midnightblue", "полуночно-синий", 25, 25, 112), ("slateblue", "сланцево-синий", 106, 90, 205),
("indigo", "индиго", 75, 0, 130), ("purple", "фиолетовый", 128, 0, 128),
("darkviolet", "тёмно-фиолетовый", 148, 0, 211), ("mediumpurple", "лавандово-фиолетовый", 147, 112, 219),
("orchid", "орхидея", 218, 112, 214), ("violet", "фиалковый", 238, 130, 238),
("plum", "сливовый", 221, 160, 221), ("lavender", "лавандовый", 230, 230, 250),
("magenta", "пурпурный (маджента)", 255, 0, 255), ("deeppink", "ярко-розовый", 255, 20, 147),
("hotpink", "горячий розовый", 255, 105, 180), ("pink", "розовый", 255, 192, 203),
("lightpink", "светло-розовый", 255, 182, 193), ("mistyrose", "туманно-розовый", 255, 228, 225),
("ivory", "слоновая кость", 255, 255, 240), ("linen", "льняной", 250, 240, 230),
("slategray", "сланцево-серый", 112, 128, 144), ("darkslategray", "тёмный сланец", 47, 79, 79),
("cadetblue", "кадетский синий", 95, 158, 160), ("aquamarine", "аквамарин", 127, 255, 212),
]
static func nearest(_ r: Int, _ g: Int, _ b: Int) -> (en: String, ru: String) {
// Weighted RGB distance ("redmean"), good enough for naming.
var best = table[0], bestD = Double.infinity
for c in table {
let rm = Double(r + c.2) / 2
let dr = Double(r - c.2), dg = Double(g - c.3), db = Double(b - c.4)
let d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db
if d < bestD { bestD = d; best = c }
}
return (best.0, best.1)
}
}
// MARK: - System info
struct SystemSnapshot {
var chip = ""
var model = ""
var pCores = 0, eCores = 0, logical = 0
var cpuLoad: Double = 0 // 0…1 overall
var ramTotal: UInt64 = 0, ramUsed: UInt64 = 0
var diskTotal: Int64 = 0, diskFree: Int64 = 0
var uptime: TimeInterval = 0
var osVersion = ""
}
@MainActor
final class SystemMonitor: ObservableObject {
static let shared = SystemMonitor()
@Published var snap = SystemSnapshot()
private var timer: Timer?
private var prevTicks: (user: UInt64, sys: UInt64, idle: UInt64, nice: UInt64)?
func start() {
guard timer == nil else { return }
snap = Self.staticInfo()
refresh()
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
Task { @MainActor in SystemMonitor.shared.refresh() }
}
}
func stop() { timer?.invalidate(); timer = nil; prevTicks = nil }
private func refresh() {
var s = snap
s.cpuLoad = cpuLoad() ?? s.cpuLoad
(s.ramTotal, s.ramUsed) = Self.memory()
if let v = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey]) {
s.diskTotal = Int64(v.volumeTotalCapacity ?? 0)
s.diskFree = v.volumeAvailableCapacityForImportantUsage ?? 0
}
s.uptime = ProcessInfo.processInfo.systemUptime
snap = s
}
private static func sysctlString(_ name: String) -> String {
var size = 0
sysctlbyname(name, nil, &size, nil, 0)
guard size > 0 else { return "" }
var buf = [CChar](repeating: 0, count: size)
sysctlbyname(name, &buf, &size, nil, 0)
return String(cString: buf)
}
private static func sysctlInt(_ name: String) -> Int {
var v: Int64 = 0
var size = MemoryLayout<Int64>.size
return sysctlbyname(name, &v, &size, nil, 0) == 0 ? Int(v) : 0
}
private static func staticInfo() -> SystemSnapshot {
var s = SystemSnapshot()
s.chip = sysctlString("machdep.cpu.brand_string")
s.model = sysctlString("hw.model")
s.pCores = sysctlInt("hw.perflevel0.physicalcpu")
s.eCores = sysctlInt("hw.perflevel1.physicalcpu")
s.logical = sysctlInt("hw.logicalcpu")
let v = ProcessInfo.processInfo.operatingSystemVersion
s.osVersion = "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
return s
}
/// Used = what Activity Monitor calls "Memory Used": app (active+inactive−purgeable… ≈ internal) + wired + compressed.
private static func memory() -> (UInt64, UInt64) {
let total = ProcessInfo.processInfo.physicalMemory
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return (total, 0) }
let page = UInt64(getpagesize())
let app = UInt64(stats.internal_page_count) - UInt64(stats.purgeable_count)
let used = (app + UInt64(stats.wire_count) + UInt64(stats.compressor_page_count)) * page
return (total, min(total, used))
}
private func cpuLoad() -> Double? {
var info = host_cpu_load_info()
var count = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.size / MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
let t = (user: UInt64(info.cpu_ticks.0), sys: UInt64(info.cpu_ticks.1),
idle: UInt64(info.cpu_ticks.2), nice: UInt64(info.cpu_ticks.3))
defer { prevTicks = t }
guard let p = prevTicks else { return nil }
let busy = Double((t.user - p.user) + (t.sys - p.sys) + (t.nice - p.nice))
let all = busy + Double(t.idle - p.idle)
return all > 0 ? busy / all : nil
}
}
struct SystemCard: View {
@ObservedObject private var mon = SystemMonitor.shared
let onClose: () -> Void
private func gb(_ b: Int64) -> String { String(format: "%.0f ГБ", Double(b) / 1_000_000_000) }
private func gib(_ b: UInt64) -> String { String(format: "%.1f ГБ", Double(b) / 1_073_741_824) }
var body: some View {
let s = mon.snap
let diskUsed = max(0, s.diskTotal - s.diskFree)
ZStack(alignment: .topTrailing) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 6) {
Text(s.chip.isEmpty ? "Mac" : s.chip).font(.system(size: 12.5, weight: .semibold))
Text("· \(s.model) · \(s.osVersion)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C"))
}
SysRow(title: "CPU", value: "\(Int((s.cpuLoad * 100).rounded()))% · ядер \(s.pCores) произв. + \(s.eCores) эфф. (\(s.logical) потоков)",
fraction: s.cpuLoad)
SysRow(title: "ОЗУ", value: "занято \(gib(s.ramUsed)) из \(gib(s.ramTotal)) · свободно \(gib(s.ramTotal - s.ramUsed))",
fraction: s.ramTotal > 0 ? Double(s.ramUsed) / Double(s.ramTotal) : 0)
SysRow(title: "SSD", value: "занято \(gb(diskUsed)) из \(gb(s.diskTotal)) · свободно \(gb(s.diskFree))",
fraction: s.diskTotal > 0 ? Double(diskUsed) / Double(s.diskTotal) : 0)
}
.padding(.leading, 104)
.padding(.trailing, 36)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
Button(action: onClose) {
Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 18, height: 18)
.background(Circle().fill(Color.white.opacity(0.08)))
}
.buttonStyle(.plain)
.padding(10)
}
.onAppear { SystemMonitor.shared.start() }
.onDisappear { SystemMonitor.shared.stop() }
}
}
private struct SysRow: View {
let title: String
let value: String
let fraction: Double
var body: some View {
HStack(spacing: 8) {
Text(title).font(.system(size: 10.5, weight: .bold, design: .monospaced))
.foregroundColor(Color(hex: "#8E939C")).frame(width: 30, alignment: .leading)
VStack(alignment: .leading, spacing: 3) {
Text(value).font(.system(size: 11)).lineLimit(1).minimumScaleFactor(0.8)
GeometryReader { g in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08))
Capsule().fill(Color(hex: ClaudePlanGauge.color(for: fraction * 100)))
.frame(width: max(3, g.size.width * min(1, fraction)))
}
}
.frame(height: 4)
}
}
}
}
+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
+6 -7
View File
@@ -498,7 +498,9 @@ struct IslandContentView: View {
.animation(.easeInOut(duration: 0.2), value: state.view == .confused) .animation(.easeInOut(duration: 0.2), value: state.view == .confused)
ZStack { ZStack {
ForEach(IslandView.allCases, id: \.self) { v in // Only the visible screen is built (plus the chat, to keep its draft and
// scroll): hidden screens used to keep their animations and timers running.
ForEach(IslandView.allCases.filter { $0 == state.view || $0 == .prompt }, id: \.self) { v in
let active = state.view == v let active = state.view == v
// Views that fill available height instead of the fixed 98pt content frame: // Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so // chat (prompt) is always flexible; mail is flexible only when active so
@@ -514,6 +516,7 @@ struct IslandContentView: View {
.opacity(active ? 1 : 0) .opacity(active ? 1 : 0)
.scaleEffect(active ? 1 : 0.97) .scaleEffect(active ? 1 : 0.97)
.allowsHitTesting(active) .allowsHitTesting(active)
.transition(.opacity)
.animation(anim, value: state.view) .animation(anim, value: state.view)
} }
} }
@@ -584,12 +587,8 @@ struct IslandHeader: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: { state.soundEnabled.toggle() }) { // Networks (sound lives in Settings and on ⌃⌥M).
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") NetGlobeButton()
.font(.system(size: 14))
.foregroundColor(Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
} }
} }
.padding(.trailing, 16) .padding(.trailing, 16)
@@ -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 {
@@ -211,21 +217,39 @@ final class IslandWindowController: NSWindowController {
fsm.isHeldOpen = { fsm.isHeldOpen = {
let s = AppState.shared let s = AppState.shared
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall) return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
|| (s.mode == .expanded && s.translation != nil)
} }
} }
// MARK: - 60 Hz polling loop // MARK: - 60 Hz polling loop
private func startPolling() { private var pollInterval: TimeInterval = 0
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
/// Mouse polling rate follows what's on screen: 60 Hz only while dragging (ghost,
/// chat handle), 30 Hz with the island open (the character draws at 30 fps),
/// 10 Hz when folded (hover still reacts within 0.1 s).
private var desiredPollInterval: TimeInterval {
if inAttachDrag || attachDragStart != nil || state.chatDragHeight != nil { return 1.0 / 60.0 }
return state.mode == .expanded ? 1.0 / 30.0 : 1.0 / 10.0
}
private func startPolling(interval: TimeInterval = 1.0 / 10.0) {
frameTimer?.invalidate()
pollInterval = interval
let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in self.pollFrame() } Task { @MainActor in self.pollFrame() }
} }
RunLoop.main.add(frameTimer!, forMode: .common) // Let macOS coalesce wakeups with other timers.
t.tolerance = interval * 0.2
RunLoop.main.add(t, forMode: .common)
frameTimer = t
} }
private func pollFrame() { private func pollFrame() {
guard let panel = window as? IslandPanel else { return } guard let panel = window as? IslandPanel else { return }
let want = desiredPollInterval
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
let mouse = NSEvent.mouseLocation let mouse = NSEvent.mouseLocation
@@ -369,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()
@@ -400,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())
} }
@@ -410,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()
@@ -445,6 +475,9 @@ final class IslandWindowController: NSWindowController {
case .desktopToggle: case .desktopToggle:
DesktopMochiController.shared.flyOutOrHome() DesktopMochiController.shared.flyOutOrHome()
case .translate:
TranslatorRun.start(state: state)
case .wardrobeToggle: case .wardrobeToggle:
if state.mode == .expanded && state.view == .wardrobe { if state.mode == .expanded && state.view == .wardrobe {
collapse() collapse()
@@ -514,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
} }
@@ -569,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() {
@@ -594,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()
} }
@@ -1166,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)
} }
} }
+580
View File
@@ -0,0 +1,580 @@
import SwiftUI
import AppKit
// MARK: - Networks (globe in the header)
//
// BroV is the remote for the network core (mihomo, root). It never touches the core's
// config or API secret: every action goes through the narrow root helper netctl.py:
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
// • right column — client networks: groups "<client>-sw" switched between REJECT and
// the client's WireGuard tunnel.
// Switching a group never reloads the core, so open connections (this chat) survive.
struct NetExit: Identifiable, Equatable {
var id: String // proxy name in the core
var title: String
var subtitle: String
var delay: Int? // ms, nil = unknown, 0 = dead
}
struct NetClient: Identifiable, Equatable {
var id: String // "saga"
var title: String
var subnet: String
var on: Bool
var delay: Int?
/// What "off" means: REJECT (network unreachable) or DIRECT (home: you're there).
var offName: String = "REJECT"
}
@MainActor
final class NetCore: ObservableObject {
static let shared = NetCore()
@Published var running = false
@Published var exits: [NetExit] = []
@Published var currentExit = ""
@Published var autoPick = "" // what "auto" chose
@Published var clients: [NetClient] = []
@Published var busy = false
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
@Published var tunOn = false
@Published var lastError: String?
/// Amnezia connections (provider "amnezia-keys"), the group's choice and auto's pick.
@Published var amneziaConns: [NetExit] = []
@Published var amneziaNow = ""
@Published var amneziaAutoPick = ""
/// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:]
static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"),
"home": ("Дом", "192.168.10.0/24 · вне дома"),
]
/// Order in the panel.
static let clientOrder = ["home", "saga", "planet9"]
// MARK: Root helper (netctl.py)
//
// The core, its config, keys and API secret are root-only. BroV only talks to the
// narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
// delay, tun, add_key, remove_key — nothing that could rewrite the core config.
private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
private func call(_ req: [String: Any], timeout: Int = 12) async -> [String: Any]? {
guard let body = try? JSONSerialization.data(withJSONObject: req) else { return nil }
// Raw bytes cross threads (Sendable); JSON is parsed back here.
let reply: Data? = await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
cont.resume(returning: Self.callSync(body, timeout: timeout))
}
}
guard let reply else { return nil }
return try? JSONSerialization.jsonObject(with: reply) as? [String: Any]
}
private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var tv = timeval(tv_sec: timeout, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
}
let connected = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { return nil }
var data = body
data.append(0x0A)
let sent = data.withUnsafeBytes { send(fd, $0.baseAddress, data.count, 0) }
guard sent == data.count else { return nil }
var out = Data()
var buf = [UInt8](repeating: 0, count: 65536)
while !out.contains(0x0A) {
let n = recv(fd, &buf, buf.count, 0)
if n <= 0 { break }
out.append(contentsOf: buf[0..<n])
}
guard let line = out.split(separator: 0x0A).first else { return nil }
return Data(line)
}
/// Reads groups and last known delays.
func refresh() async {
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
let proxies = st["proxies"] as? [String: [String: Any]] else {
running = false
return
}
running = true
tunOn = st["tun"] as? Bool ?? false
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies.
var providerDelay: [String: Int] = [:]
var awgNames: [String] = []
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
for (prov, list) in providers {
for x in list {
guard let n = x["name"] as? String else { continue }
if prov == "amnezia-keys" { awgNames.append(n) }
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
}
}
awgNames.sort()
func lastDelay(_ name: String) -> Int? {
if let d = measured[name] { return d }
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
return providerDelay[name]
}
amneziaNow = proxies["amnezia"]?["now"] as? String ?? ""
amneziaAutoPick = proxies["amnezia-auto"]?["now"] as? String ?? ""
let amneziaEffective = amneziaNow == "amnezia-auto" ? amneziaAutoPick : amneziaNow
amneziaConns = [NetExit(id: "amnezia-auto", title: "Авто",
subtitle: amneziaAutoPick.isEmpty ? "быстрейшее подключение"
: "сейчас: \(Self.pretty(amneziaAutoPick).0)",
delay: lastDelay(amneziaAutoPick))]
+ awgNames.map { NetExit(id: $0, title: Self.pretty($0).0, subtitle: "AmneziaWG", delay: lastDelay($0)) }
if let g = proxies["ai-out"], let members = g["all"] as? [String] {
currentExit = g["now"] as? String ?? ""
autoPick = proxies["auto"]?["now"] as? String ?? ""
exits = members.map { name in
let (t, sub) = Self.pretty(name)
let delay: Int? = name == "auto" ? lastDelay(autoPick)
: (name == "amnezia" ? lastDelay(amneziaEffective) : lastDelay(name))
return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
? "сейчас: \(Self.pretty(autoPick).0)"
: (name == "amnezia" && !amneziaEffective.isEmpty
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
delay: delay)
}
}
clients = Self.clientOrder.compactMap { id in
guard let g = proxies["\(id)-sw"], let info = Self.clientInfo[id] else { return nil }
let all = g["all"] as? [String] ?? []
return NetClient(id: id, title: info.title, subnet: info.subnet,
on: (g["now"] as? String) == id, delay: lastDelay(id),
offName: all.first ?? "REJECT")
}
}
/// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async {
busy = true
if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] as? [String: Int] {
measured = m
// Members that didn't answer are missing from the map: mark them dead.
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
}
if let m = (await call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
}
for c in clients where c.on {
if let m = (await call(["cmd": "delay", "proxy": c.id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
}
await refresh()
busy = false
}
func select(exit name: String) async {
currentExit = name
_ = await call(["cmd": "select", "group": "ai-out", "name": name])
SoundEngine.shared.play("blip")
await refresh()
}
func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
let off = clients.first(where: { $0.id == id })?.offName ?? "REJECT"
_ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : off])
SoundEngine.shared.play(on ? "pop" : "close")
if on, let m = (await call(["cmd": "delay", "proxy": id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
await refresh()
}
func select(amnezia name: String) async {
amneziaNow = name
_ = await call(["cmd": "select", "group": "amnezia", "name": name])
if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
SoundEngine.shared.play("blip")
await refresh()
}
/// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
return (false, "Помощник сетевого ядра не отвечает.")
}
guard r["ok"] as? Bool == true else {
return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
}
await refresh()
let name = r["name"] as? String ?? "?"
let server = r["server"] as? String ?? "?"
if let d = r["delay"] as? Int, d > 0 {
SoundEngine.shared.play("finish")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
}
return (true, "Ключ «\(name)» сохранён (\(server)), но сервер пока не отвечает. Он будет участвовать в выборе, когда оживёт.")
}
func removeAmnezia(_ proxyName: String) async {
_ = await call(["cmd": "remove_key", "name": proxyName])
SoundEngine.shared.play("close")
await refresh()
}
static var amneziaRunning: Bool {
// The app holds routes; its background AmneziaVPN-service doesn't.
NSWorkspace.shared.runningApplications.contains { $0.localizedName == "AmneziaVPN" }
}
/// Turns traffic capture on/off without a password (the core keeps running).
func setTun(_ on: Bool) async {
if on && Self.amneziaRunning {
lastError = "Сначала выключи AmneziaVPN — иначе она и ядро подерутся за маршрут."
return
}
lastError = nil
tunOn = on
_ = await call(["cmd": "tun", "on": on])
SoundEngine.shared.play(on ? "pop" : "close")
try? await Task.sleep(for: .seconds(1))
await refresh()
}
static func pretty(_ name: String) -> (String, String) {
switch name {
case "auto": return ("Авто", "самый быстрый выход")
case "amnezia": return ("Амнезия", "AmneziaWG")
case "amnezia-auto": return ("Авто", "быстрейшее подключение")
case let n where n.hasPrefix("AWG "): return (String(n.dropFirst(4)), "AmneziaWG")
case let n where n.contains("node3"): return ("node3", "VLESS · Нидерланды")
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
case "SkandiFlora": return ("SkandiFlora", "VLESS · Стокгольм")
case "Helsinki": return ("Helsinki", "VLESS · Хельсинки")
default: return (name, "VLESS")
}
}
}
// MARK: - Header button
struct NetGlobeButton: View {
@ObservedObject private var net = NetCore.shared
@State private var open = false
var body: some View {
Button { open.toggle() } label: {
Image(systemName: "globe")
.font(.system(size: 14))
.foregroundColor(open ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
.overlay(alignment: .topTrailing) {
Circle()
.fill(net.running ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
.offset(x: 2, y: -1)
}
}
.buttonStyle(.plain)
.help("Сети")
.task { await net.refresh() }
.popover(isPresented: $open, arrowEdge: .bottom) {
NetPanel(net: net)
.notchPopoverStyle()
}
}
}
// MARK: - Panel
struct NetPanel: View {
@ObservedObject var net: NetCore
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "globe").font(.system(size: 13, weight: .semibold))
Text("Сети").font(.system(size: 14, weight: .semibold))
Circle().fill(net.running ? Color(hex: "#34D399") : Color(hex: "#F4505E")).frame(width: 6, height: 6)
Text(net.running ? "ядро работает" : "ядро не запущено")
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
Spacer()
if net.running {
Button {
Task { await net.measure() }
} label: {
HStack(spacing: 4) {
if net.busy { ProgressView().controlSize(.mini) }
else { Image(systemName: "speedometer").font(.system(size: 11)) }
Text("Замерить").font(.system(size: 11, weight: .medium))
}
.padding(.horizontal, 8).frame(height: 22)
.background(Capsule().fill(Color.white.opacity(0.08)))
}
.buttonStyle(.plain)
.disabled(net.busy)
}
}
if let err = net.lastError {
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
.fixedSize(horizontal: false, vertical: true).frame(width: 444, alignment: .leading)
}
if !net.running {
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/work/macbookbrov/brov/scripts/netcore/install.sh\nДальше ядро будет запускаться само при включении Мака.")
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 444, alignment: .leading)
} else {
NetRow(title: "Ядро перехватывает трафик",
subtitle: net.tunOn ? "весь трафик Мака идёт через BroV" : "выключено — Мак ходит сам (можно включить AmneziaVPN)",
delay: nil, isOn: net.tunOn, accent: "#A78BFA") {
Task { await net.setTun(!net.tunOn) }
}
.frame(width: 444)
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 1)
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
ForEach(net.exits) { e in
if e.id == "amnezia" {
AmneziaSection(net: net, exit: e)
} else {
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay,
isOn: net.currentExit == e.id, accent: "#D97757") {
guard net.currentExit != e.id else { return }
Task { await net.select(exit: e.id) }
}
}
}
}
.frame(width: 230)
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1)
VStack(alignment: .leading, spacing: 4) {
Text("СЕТИ КЛИЕНТОВ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
ForEach(net.clients) { c in
NetRow(title: c.title, subtitle: c.subnet, delay: c.on ? c.delay : nil,
isOn: c.on, accent: "#34D399") {
Task { await net.set(client: c.id, on: !c.on) }
}
}
Text("Выключенная сеть клиента недоступна; «Дом» выключен — значит напрямую (ты дома). Claude и интернет это не трогает.")
.font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
.frame(width: 200)
}
}
}
.padding(14)
.task {
await net.refresh()
if net.running { await net.measure() }
}
}
}
struct NetRow: View {
let title: String
let subtitle: String
let delay: Int?
let isOn: Bool
let accent: String
let action: () -> Void
@State private var hovered = false
private var delayText: String {
guard let d = delay else { return "" }
return d == 0 ? "нет ответа" : "\(d) мс"
}
private var delayColor: String {
guard let d = delay else { return "#6B7079" }
if d == 0 { return "#F4505E" }
return d < 120 ? "#34D399" : (d < 300 ? "#F5A524" : "#F4505E")
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.system(size: 12.5, weight: .semibold))
Text(subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
}
Spacer(minLength: 4)
Text(delayText).font(.system(size: 10.5, weight: .medium)).monospacedDigit()
.foregroundColor(Color(hex: delayColor))
// Switch look-alike
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule().fill(isOn ? Color(hex: accent) : Color.white.opacity(0.14))
.frame(width: 30, height: 18)
Circle().fill(Color.white).frame(width: 14, height: 14).padding(2)
}
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isOn)
}
.padding(.horizontal, 8).padding(.vertical, 5)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(hovered ? 0.06 : 0)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
// MARK: - Amnezia: several connections
/// "Амнезия" row: the switch picks Amnezia as the exit, the name opens the list of
/// connections (Авто / each key) with "+ Добавить подключение" at the end.
struct AmneziaSection: View {
@ObservedObject var net: NetCore
let exit: NetExit
@State private var expanded = false
@State private var adding = false
@State private var confirmRemove: String?
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Button { withAnimation(.easeOut(duration: 0.15)) { expanded.toggle() } } label: {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(exit.title).font(.system(size: 12.5, weight: .semibold))
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.system(size: 8.5, weight: .bold)).foregroundColor(Color(hex: "#8E939C"))
}
Text(exit.subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
}
Spacer(minLength: 4)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
NetRow(title: "", subtitle: "", delay: exit.delay, isOn: net.currentExit == "amnezia", accent: "#D97757") {
guard net.currentExit != "amnezia" else { return }
Task { await net.select(exit: "amnezia") }
}
.frame(width: 110)
}
.padding(.leading, 8)
if expanded {
VStack(alignment: .leading, spacing: 1) {
ForEach(net.amneziaConns) { c in
NetRow(title: c.title, subtitle: c.subtitle, delay: c.delay,
isOn: net.currentExit == "amnezia" && net.amneziaNow == c.id, accent: "#D97757") {
Task { await net.select(amnezia: c.id) }
}
.contextMenu {
if c.id != "amnezia-auto" {
Button("Удалить подключение…", role: .destructive) { confirmRemove = c.id }
}
}
}
Button { adding = true } label: {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill").font(.system(size: 12))
Text("Добавить подключение").font(.system(size: 12, weight: .medium))
}
.foregroundColor(Color(hex: "#D97757"))
.padding(.horizontal, 8).padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $adding, arrowEdge: .trailing) {
AddAmneziaKeyView(net: net) { adding = false }
.notchPopoverStyle()
}
}
.padding(.leading, 12)
.overlay(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1).padding(.leading, 6)
}
}
}
.confirmationDialog("Удалить подключение «\(NetCore.pretty(confirmRemove ?? "").0)»?",
isPresented: Binding(get: { confirmRemove != nil }, set: { if !$0 { confirmRemove = nil } })) {
Button("Удалить", role: .destructive) {
if let n = confirmRemove { Task { await net.removeAmnezia(n) } }
confirmRemove = nil
}
Button("Отмена", role: .cancel) { confirmRemove = nil }
} message: {
Text("Ключ будет удалён с этого Мака. Вернуть можно, вставив его снова.")
}
}
}
/// Paste a vpn:// key → BroV checks it, stores it and tells whether it connected.
struct AddAmneziaKeyView: View {
@ObservedObject var net: NetCore
let onDone: () -> Void
@State private var text = ""
@State private var working = false
@State private var result: (ok: Bool, message: String)?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Новое подключение Амнезии").font(.system(size: 13, weight: .semibold))
Text("Вставь ключ vpn:// из приложения Amnezia (Поделиться → AmneziaWG или ключ целиком).")
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
.fixedSize(horizontal: false, vertical: true)
TextEditor(text: $text)
.font(.system(size: 10.5, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(6)
.frame(height: 90)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
if let r = result {
Text(r.message)
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(Color(hex: r.ok ? "#34D399" : "#FB923C"))
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button(result?.ok == true ? "Готово" : "Отмена") { onDone() }
Button {
working = true
Task {
result = await net.addAmneziaKey(text)
if result?.ok == true { text = "" }
working = false
}
} label: {
HStack(spacing: 5) {
if working { ProgressView().controlSize(.small) }
Text("Проверить и добавить")
}
}
.keyboardShortcut(.defaultAction)
.disabled(working || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(14)
.frame(width: 340)
}
}
@@ -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)
+4 -1
View File
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
case translate = "translate" // ⌥` — translate the selected text (BroV)
// MARK: UserDefaults keys // MARK: UserDefaults keys
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .muteToggle: return "Выключить / включить звук" case .muteToggle: return "Выключить / включить звук"
case .desktopToggle: return "BroV на рабочий стол / обратно" case .desktopToggle: return "BroV на рабочий стол / обратно"
case .wardrobeToggle: return "Открыть / закрыть гардероб" case .wardrobeToggle: return "Открыть / закрыть гардероб"
case .translate: return "Перевести выделенный текст"
} }
} }
@@ -104,6 +106,7 @@ enum ShortcutLogic {
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M .muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D .desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G .wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
.translate: ShortcutSpec(keyCode: 50, nsFlags: ShortcutSpec.optBit), // ⌥` (key left of 1)
] ]
// MARK: - Load / save (UserDefaults) // MARK: - Load / save (UserDefaults)
@@ -196,7 +199,7 @@ enum ShortcutLogic {
0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X", 0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X",
8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y", 8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y",
17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J", 17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J",
40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", 40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", 50:"`", 27:"-", 29:"0",
36:"↩", 51:"⌫", 53:"⎋", 36:"↩", 51:"⌫", 53:"⎋",
123:"←", 124:"→", 125:"↓", 126:"↑", 123:"←", 124:"→", 125:"↓", 126:"↑",
18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9", 18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9",
+24
View File
@@ -45,6 +45,15 @@ final class ChatTabs {
return c return c
} }
private var seeds: [UUID: String] = [:]
/// Context prepended to the first message sent from a tab (e.g. the picked colour).
func seed(_ id: UUID, _ text: String) { seeds[id] = text }
func takeSeed(_ id: UUID?) -> String? {
guard let id else { return nil }
return seeds.removeValue(forKey: id)
}
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h } func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] } func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
@@ -73,6 +82,7 @@ final class ChatTabs {
clis[id]?.cancel() clis[id]?.cancel()
clis.removeValue(forKey: id) clis.removeValue(forKey: id)
histories.removeValue(forKey: id) histories.removeValue(forKey: id)
seeds.removeValue(forKey: id)
} }
} }
@@ -260,6 +270,7 @@ struct TabChip: View {
} }
.padding(14) .padding(14)
.frame(width: 260) .frame(width: 260)
.notchPopoverStyle()
} }
} }
} }
@@ -293,6 +304,7 @@ struct NewTabButton: View {
} }
} }
.padding(10) .padding(10)
.notchPopoverStyle()
} }
} }
@@ -321,3 +333,15 @@ struct NewTabButton: View {
.disabled(state.tabs.count >= AppState.maxTabs) .disabled(state.tabs.count >= AppState.maxTabs)
} }
} }
extension View {
/// Popovers out of the notch: dark like the island, readable text whatever the
/// island's foreground colour is.
func notchPopoverStyle() -> some View {
self
.foregroundColor(Color(hex: "#F5F6F8"))
.background(Color(hex: "#16171B"))
.environment(\.colorScheme, .dark)
}
}
+248
View File
@@ -0,0 +1,248 @@
import SwiftUI
import AppKit
import Translation
// MARK: - Translator (Func → Переводчик, ⌥`)
//
// Takes the text selected in the front app (Accessibility; falls back to a simulated ⌘C
// with the clipboard restored afterwards; then to whatever is already on the clipboard),
// detects the direction (Cyrillic → English, anything else → Russian) and translates it
// with the on-device macOS Translation when that language pair is installed, otherwise
// with `claude` (Haiku). The result shows as a card in the notch's home view.
struct TranslationResult: Equatable {
var source: String
var text: String = ""
var toRussian: Bool
var engine: String = ""
var error: String? = nil
var loading = true
}
@MainActor
enum TranslatorRun {
static func start(state: AppState) {
Task { @MainActor in
guard let source = await SelectionGrabber.grab(), !source.isEmpty else {
state.translation = TranslationResult(source: "", toRussian: true,
error: SelectionGrabber.trusted
? "Не нашёл выделенного текста. Выдели текст в приложении и нажми ⌥`."
: "Дай BroV доступ: Системные настройки → Конфиденциальность → Универсальный доступ → BroV. Потом выдели текст и нажми ⌥`.",
loading: false)
show(state)
return
}
let toRussian = !Self.isMostlyCyrillic(source)
state.translation = TranslationResult(source: source, toRussian: toRussian)
show(state)
do {
let (text, engine) = try await Translator.translate(source, toRussian: toRussian)
guard state.translation?.source == source else { return }
state.translation?.text = text
state.translation?.engine = engine
state.translation?.loading = false
SoundEngine.shared.play("pop")
} catch {
guard state.translation?.source == source else { return }
state.translation?.error = error.localizedDescription
state.translation?.loading = false
}
}
}
private static func show(_ state: AppState) {
if state.mode != .expanded || !(state.view == .overview || state.view == .empty) {
NotificationCenter.default.post(name: .hookExpand, object: IslandView.overview)
}
}
static func isMostlyCyrillic(_ s: String) -> Bool {
var cyr = 0, lat = 0
for u in s.unicodeScalars {
if (0x0400...0x04FF).contains(u.value) { cyr += 1 }
else if CharacterSet.letters.contains(u), u.isASCII { lat += 1 }
}
return cyr > lat
}
}
enum Translator {
static func translate(_ text: String, toRussian: Bool) async throws -> (String, String) {
let ru = Locale.Language(identifier: "ru")
let en = Locale.Language(identifier: "en")
if #available(macOS 26.0, *) {
let target = toRussian ? ru : en
// Source: English when going to Russian (the common case), else Russian.
let source = toRussian ? en : ru
let status = await LanguageAvailability().status(from: source, to: target)
if status == .installed {
let session = TranslationSession(installedSource: source, target: target)
if let r = try? await session.translate(text) {
return (r.targetText, "macOS")
}
}
}
return (try await viaClaude(text, toRussian: toRussian), "Claude Haiku")
}
private static func scratchDir() -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("brov-translate")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.path
}
private static func viaClaude(_ text: String, toRussian: Bool) async throws -> String {
let instruction = toRussian
? "Translate the text below into Russian. Output only the translation, keep formatting."
: "Переведи текст ниже на английский. Выведи только перевод, сохрани форматирование."
let cli = ClaudeCodeCLI()
let state = await AppState.shared
let out = try await cli.send(prompt: instruction + "\n\n" + text, model: "haiku",
// An empty temp folder: no file access prompts, nothing to read.
workingDirectory: Self.scratchDir(), binaryPath: await state.claudeBinaryPath,
permissionMode: "plan") { _ in }
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - Selected text of the front app
@MainActor
enum SelectionGrabber {
static var trusted: Bool { AXIsProcessTrusted() }
static func grab() async -> String? {
if !trusted {
// Shows the system prompt once; meanwhile use the clipboard.
let opts = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
return clipboardText()
}
if let s = axSelection(), !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return s }
// ⌘C only when BroV isn't the key window (hotkey path); from a click in the notch
// the keystroke would land in BroV itself.
if NSApp.keyWindow == nil, let s = await copyViaCommandC(), !s.isEmpty { return s }
return clipboardText()
}
private static func axSelection() -> String? {
let system = AXUIElementCreateSystemWide()
var focused: CFTypeRef?
guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focused) == .success,
let el = focused else { return nil }
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(el as! AXUIElement, kAXSelectedTextAttribute as CFString, &value) == .success
else { return nil }
return value as? String
}
/// Simulates ⌘C in the front app, reads the copy, then puts the old clipboard back.
private static func copyViaCommandC() async -> String? {
let pb = NSPasteboard.general
let before = pb.changeCount
let saved = pb.pasteboardItems?.map { item -> [NSPasteboard.PasteboardType: Data] in
var d: [NSPasteboard.PasteboardType: Data] = [:]
for t in item.types { if let v = item.data(forType: t) { d[t] = v } }
return d
} ?? []
let src = CGEventSource(stateID: .combinedSessionState)
let down = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: true) // C
down?.flags = .maskCommand
let up = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: false)
up?.flags = .maskCommand
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
for _ in 0..<10 {
try? await Task.sleep(for: .milliseconds(30))
if pb.changeCount != before { break }
}
guard pb.changeCount != before else { return nil }
let text = pb.string(forType: .string)
// 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<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 it = NSPasteboardItem()
for (t, v) in dict { it.setData(v, forType: t) }
return it
}
if !items.isEmpty { pb.writeObjects(items) }
return text
}
private static func clipboardText() -> String? {
NSPasteboard.general.string(forType: .string)
}
}
// MARK: - Card
struct TranslationCard: View {
@ObservedObject var state: AppState
let result: TranslationResult
var body: some View {
ZStack(alignment: .topTrailing) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: "character.bubble").font(.system(size: 11, weight: .semibold))
Text(result.error != nil ? "Перевод" : (result.toRussian ? "Перевод на русский" : "Перевод на английский"))
.font(.system(size: 11.5, weight: .semibold))
if !result.engine.isEmpty {
Text("· \(result.engine)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
}
}
.foregroundColor(Color(hex: "#B0B5BE"))
if let err = result.error {
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
.fixedSize(horizontal: false, vertical: true)
} else if result.loading {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(result.source).lineLimit(1).font(.system(size: 11.5)).foregroundColor(Color(hex: "#6B7079"))
}
} else {
ScrollView(.vertical, showsIndicators: false) {
Text(result.text)
.font(.system(size: 12.5))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 8) {
FuncButton(symbol: "doc.on.doc", title: "Скопировать") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(result.text, forType: .string)
SoundEngine.shared.play("blip")
}
FuncButton(symbol: "bubble.left", title: "В чат") {
if state.tabs.count < AppState.maxTabs { state.openTab(.chat) } else { state.view = .prompt }
if let tab = state.activeTabId {
ChatTabs.shared.seed(tab, "Исходный текст:\n\(result.source)\n\nПеревод:\n\(result.text)\n\nВопросы ниже — про этот перевод.")
}
state.chatHistory.append(ChatMessage(role: .assistant, content: "**Перевод:**\n\n\(result.text)"))
state.translation = nil
}
Text(result.source).lineLimit(1).font(.system(size: 10.5)).foregroundColor(Color(hex: "#5F646D"))
}
}
}
.padding(.leading, 104)
.padding(.trailing, 36)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Button { withAnimation(.easeOut(duration: 0.18)) { state.translation = nil } } label: {
Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 18, height: 18)
.background(Circle().fill(Color.white.opacity(0.08)))
}
.buttonStyle(.plain)
.padding(10)
}
}
}
+17 -1
View File
@@ -3,28 +3,44 @@ import CoreGraphics
// Hex color helpers shared by the Mac app and the iPhone app. // Hex color helpers shared by the Mac app and the iPhone app.
// Views ask for the same few dozen hex colours on every frame: parse each string once.
private enum HexCache {
nonisolated(unsafe) static var colors: [String: Color] = [:]
nonisolated(unsafe) static var cgColors: [String: CGColor] = [:]
static let lock = NSLock()
}
// MARK: - Color from hex string // MARK: - Color from hex string
extension Color { extension Color {
init(hex: String) { init(hex: String) {
HexCache.lock.lock()
if let c = HexCache.colors[hex] { HexCache.lock.unlock(); self = c; return }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0 let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255 let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255 let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255 let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b) self.init(red: r, green: g, blue: b)
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
} }
} }
// MARK: - CGColor from hex string // MARK: - CGColor from hex string
func cgColorFromHex(_ hex: String) -> CGColor? { func cgColorFromHex(_ hex: String) -> CGColor? {
HexCache.lock.lock()
if let c = HexCache.cgColors[hex] { HexCache.lock.unlock(); return c }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard let val = UInt64(h, radix: 16) else { return nil } guard let val = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((val >> 16) & 0xFF) / 255 let r = CGFloat((val >> 16) & 0xFF) / 255
let g = CGFloat((val >> 8) & 0xFF) / 255 let g = CGFloat((val >> 8) & 0xFF) / 255
let b = CGFloat( val & 0xFF) / 255 let b = CGFloat( val & 0xFF) / 255
return CGColor(red: r, green: g, blue: b, alpha: 1) let c = CGColor(red: r, green: g, blue: b, alpha: 1)
HexCache.lock.lock(); HexCache.cgColors[hex] = c; HexCache.lock.unlock()
return c
} }
extension CGColor { extension CGColor {
+12 -6
View File
@@ -6,7 +6,8 @@ options:
xcodeVersion: "27.0" xcodeVersion: "27.0"
createIntermediateGroups: true createIntermediateGroups: true
# BroV — personal fork of Coucou (MIT). Mac app only, local unsigned builds. # BroV — personal fork of Coucou (MIT). Mac app only, signed with the personal
# Apple Development certificate (stable identity: TCC grants survive rebuilds).
configs: configs:
Debug: debug Debug: debug
Release: release Release: release
@@ -14,14 +15,19 @@ 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. # Personal Team certificate (free Apple ID in Xcode). With ad-hoc signing macOS
# forgot Accessibility after every build; a real identity keeps it.
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGNING_REQUIRED: NO CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: V3NLZK45Q4
# The only third-party dependency: a real terminal emulator for the term.macOS tabs. # The only third-party dependency: a real terminal emulator for the term.macOS tabs.
packages: packages:
+4 -2
View File
@@ -6,10 +6,12 @@ cd "$(dirname "$0")/../NotchBuddy"
D="$HOME/Library/Developer/Xcode/DerivedData/BroV" D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
xcodegen -q xcodegen -q
LOG=$(mktemp) LOG=$(mktemp)
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true # Release by default: optimised code (Debug is several times heavier on CPU).
CONF="${1:-Release}"
xcodebuild -scheme BroV -configuration "$CONF" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true
grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
APP="$D/Build/Products/${1:-Debug}/BroV.app" APP="$D/Build/Products/$CONF/BroV.app"
# Stop the running copy. If launchd keeps it alive, unload the job first or it would # Stop the running copy. If launchd keeps it alive, unload the job first or it would
# restart the old binary mid-copy. # restart the old binary mid-copy.
PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist" PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"
+234
View File
@@ -0,0 +1,234 @@
#!/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, what "off" means)
# Client networks: off = REJECT (unreachable). Only the LANs are routed — their
# tunnel subnets overlap (Saga and home both use 10.0.0.x).
"saga": ("saga.conf", ["192.168.8.0/24"], "REJECT"),
"planet9": ("planet9.conf", ["192.168.68.0/24", "172.3.3.0/24"], "REJECT"),
# Home: off = DIRECT (you're at home, the LAN is right there); on = through the
# home WireGuard when away. Optional: only if src/home.conf exists.
"home": ("home.conf", ["192.168.10.0/24"], "DIRECT"),
}
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")
clients = {n: c for n, c in CLIENTS.items() if os.path.exists(os.path.join(SRC, c[0]))}
proxies = [wg(n, f) for n, (f, _, _) in clients.items()]
sub = open(os.path.join(SRC, "vless.sub")).read().strip()
rules = ([] if "home" in clients else [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.
# Client networks: first option = "off" (REJECT, or DIRECT for home).
{"name": f"{n}-sw", "type": "select", "proxies": [off, n]} for n, (_, _, off) in clients.items()
],
"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()
+153
View File
@@ -0,0 +1,153 @@
#!/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
# Extra WireGuard networks dropped into ~/.brov-secrets/wg/<name>.conf (e.g. home.conf):
# moved into the root folder, the user copy is removed.
for f in "$USER_HOME"/.brov-secrets/wg/*.conf; do
[ -f "$f" ] && [ ! -L "$f" ] || continue
install -m 600 -o root -g wheel "$f" "$CORE/src/$(basename "$f")" && rm -f "$f"
echo "✓ WireGuard $(basename "$f" .conf) перенесён в ядро"
done
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
# Services take a moment to start: wait up to 10 s before judging.
running() {
launchctl print "system/$CORE_LABEL" 2>/dev/null | grep -q 'state = running' &&
launchctl print "system/$CTL_LABEL" 2>/dev/null | grep -q 'state = running' &&
[ -S /var/run/brov-netctl.sock ]
}
i=0
until running || [ $i -ge 20 ]; do sleep 0.5; i=$((i+1)); done
if running; 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 / home-sw, name must be a member
delay {group}|{proxy} speed test of ai-out / amnezia, or of the saga / planet9 / home 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", "home-sw"}
DELAY_GROUPS = {"ai-out", "amnezia"}
CLIENT_TUNNELS = {"saga", "planet9", "home"}
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