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
.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
- Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV.
- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`).
- Secrets live in the Keychain, never on disk or in git.
- Swift 6, SwiftUI + AppKit. One dependency: SwiftTerm (term.macOS tabs). The character is 11 Memoji images (`Resources/memoji`) moved by `BotEngine` at 30 fps.
- Secrets live in the Keychain or the root-only network core folder, never in git.
- Never block Claude Code: if the app doesn't answer, the hook exits immediately.
- Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
- Never approve a Claude Code permission without an explicit click.
- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
- Pill IDs are stable contract values: never rename an existing pill ID.
## Network core (globe 🌐 in the header)
- mihomo runs as root: LaunchDaemon `local.maksar.brov.netd`, binary + config + keys + API secret in `/Library/Application Support/BroV/` (root, 0700). Nothing user-writable is read by root.
- BroV never sees the config or the API secret: it talks to the narrow root helper `scripts/netcore/netctl.py` (LaunchDaemon `local.maksar.brov.netctl`, socket `/var/run/brov-netctl.sock`, only the installing user) — commands: state, select, delay, tun, add_key, remove_key.
- `scripts/netcore/gen.py` builds the config (Amnezia keys → provider `keys/amnezia.yaml`, WireGuard clients, VLESS subscription). Install/update: `sudo sh scripts/netcore/install.sh` (verifies the mihomo SHA256, migrates keys). Never commit keys, configs or the guide (`~/.brov-secrets`).
- Reloading the whole core config drops every connection (including this chat); group switches and provider reloads don't.
+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)
}
BroVLaunchAgent.ensure()
Self.pruneInbox()
setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
@@ -33,6 +34,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
#endif
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
openIsland()
return true
}
/// Copies of dropped files (HookServer.supportDir/inbox) are kept 7 days, then removed.
private static func pruneInbox() {
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-7 * 86400)
for f in files {
let d = (try? f.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantFuture
if d < cutoff { try? fm.removeItem(at: f) }
}
try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inbox.path)
}
// MARK: - Menu bar
private func setupMenuBarItem() {
@@ -56,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Actions
@objc private func openIsland() {
islandController?.fsm.openedExternally()
islandController?.expand(to: .overview)
}
+12 -1
View File
@@ -27,6 +27,7 @@ enum DesktopApps {
struct AppGridView: View {
@ObservedObject var state: AppState
@State private var showSystem = false
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? {
@@ -85,7 +86,7 @@ struct AppGridView: View {
VStack(spacing: 8) {
HStack(spacing: 8) {
ShelfPill()
EmptyPill()
FuncPill(state: state, showSystem: $showSystem)
}
HStack(spacing: 8) {
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
@@ -96,6 +97,16 @@ struct AppGridView: View {
.padding(.trailing, 20)
.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 state.showingPlanDetail {
CardBackground(wash: nil)
+10 -3
View File
@@ -378,7 +378,9 @@ final class AppState: ObservableObject {
@Published var pendingApproval: ApprovalInfo? = nil
// Pending AskUserQuestion from Claude Code hook
@Published var pendingQuestion: AskQuestion? = nil
@Published var pendingQuestion: AskQuestion? = nil {
didSet { QuestionLayout.height = pendingQuestion?.estimatedIslandHeight }
}
// Per-pill flat list of FileDiffs, in order of reception.
// Not @Published — steps[] changes already trigger redraws.
@@ -411,11 +413,13 @@ final class AppState: ObservableObject {
private func resetSessionDiffTimer(for pillId: String) {
sessionDiffTimers[pillId]?.cancel()
// The closure is MainActor-isolated (AppState is @MainActor): it must run on the main
// queue. Scheduled on a global queue, Swift 6's isolation check traps and the app quits.
let work = DispatchWorkItem { [weak self] in
DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) }
self?.clearSessionDiffs(for: pillId)
}
sessionDiffTimers[pillId] = work
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work)
DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
}
#if !APPSTORE
@@ -493,6 +497,9 @@ final class AppState: ObservableObject {
}
}
/// Func → Переводчик result shown on the home view.
@Published var translation: TranslationResult? = nil
// MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false
+32
View File
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
var multiSelect: Bool
}
/// Island height of the pending question, readable from the nonisolated `islandSize`. Written on the main actor only.
enum QuestionLayout {
nonisolated(unsafe) static var height: CGFloat?
}
extension AskQuestionItem {
/// True when at least one option carries a description: the card then lists options vertically.
var hasDescriptions: Bool { options.contains { !$0.description.isEmpty } }
}
struct AskQuestion: Equatable {
var questions: [AskQuestionItem] // 1–4 questions
/// Island height that fits the tallest question without truncation (rough estimate, text wraps at ~500 pt).
var estimatedIslandHeight: CGFloat {
func lines(_ text: String, charWidth: CGFloat) -> CGFloat {
max(1, (CGFloat(text.count) * charWidth / 500).rounded(.up))
}
let tallest = questions.map { item -> CGFloat in
var h: CGFloat = 20 + lines(item.question, charWidth: 7) * 17 + 64
if !item.header.isEmpty { h += 14 }
if item.hasDescriptions {
for opt in item.options {
h += 34 + (opt.description.isEmpty ? 0 : lines(opt.description, charWidth: 6.4) * 14)
}
h += 40 // "Другое…" row
} else {
h += item.options.count >= 3 ? 74 : 40
}
if item.multiSelect { h += 34 }
return h
}.max() ?? 160
return min(max(tallest, 160), 560)
}
// MARK: - Parse from tool_input dict
// Returns nil if the payload is malformed (fallback → Allow/Deny card).
static func parse(toolInput: [String: Any]) -> AskQuestion? {
+3 -2
View File
@@ -14,7 +14,8 @@ struct BotCanvasView: View {
@State private var fader = MemojiFader()
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
let now = timeline.date.timeIntervalSinceReferenceDate
let dtRaw = min(0.05, now - engine.lastTime)
@@ -191,7 +192,7 @@ struct MiniBotCanvasView: View {
}
var body: some View {
TimelineView(.animation) { timeline in
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate
let dt = min(0.05, now - engine.lastTime)
+3 -1
View File
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
"--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
// Never bypass: the chat gets untrusted input (dropped files, window titles, URLs).
let allowedModes: Set<String> = ["acceptEdits", "plan"]
if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
let p = Process()
p.executableURL = URL(fileURLWithPath: binary)
@@ -275,6 +275,7 @@ final class ClaudeService {
let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil
var prompt = query
// Context goes with the first message of a conversation, like the API chat.
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 msgId = placeholder.id
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"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
(#"/(NotchBuddy|BroV)/netcore(/|$)|\.vpnkey$"#, "меняет сетевое ядро BroV или его ключи"),
(#"/Library/Launch(Agents|Daemons)/"#, "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
(#"/NotchBuddy/nb-hook"#, "меняет хуки BroV"),
]
/// The same sensitive places when a shell command writes, moves or deletes there.
static let shellPathRules: [Rule] = [
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\bchmod\b|\bchown\b|sed\s+-i|\bpython3?\b|\bperl\b).*(NotchBuddy/netcore|BroV/netcore|\.vpnkey)"#,
reason: "меняет сетевое ядро BroV или его ключи"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\blaunchctl\b|\bplutil\b).*Library/Launch(Agents|Daemons)"#,
reason: "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|sed\s+-i).*(\.claude/settings(\.local)?\.json|NotchBuddy/nb-hook)"#,
reason: "меняет настройки или хуки Claude Code"),
]
static func reasons(tool: String, input: [String: Any]) -> [String] {
var out: [String] = []
if let command = input["command"] as? String {
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
for rule in shellRules + shellPathRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason)
}
}
+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
private var _pendingHooksData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingHooksOriginal: Data?
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
func previewClaudeHooks() throws -> String {
let data = try buildHooksData()
let (data, original) = try buildHooksData()
_pendingHooksData = data
_pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? ""
}
/// Writes the hooks to disk (call after user confirms preview).
/// Refused if settings.json changed since the preview, or cannot be backed up.
func writeClaudeHooks() throws {
guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
// Backup first
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
_pendingHooksData = nil
_pendingHooksOriginal = nil
}
private func buildHooksData() throws -> Data {
private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
let hookPath = Self.hookScriptPath
#if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
func uninstallClaudeHooks() throws {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
}
// MARK: - Claude plan status line installer
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
}
private var _pendingStatusLineData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingStatusLineOriginal: Data?
private var _pendingPreviousData: Data?
private var _pendingDeletePrevious: Bool = false
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let d = try? Data(contentsOf: settingsURL),
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let settings = snapshot.object
let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data
_pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
// Commit side effects only after successful write
if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL)
}
_pendingStatusLineData = nil
_pendingStatusLineOriginal = nil
_pendingPreviousData = nil
_pendingDeletePrevious = false
}
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// Writes nb-hook script and updates settings.json in one shot.
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
let data = try buildHooksData(claudeURL: claudeURL)
let (data, original) = try buildHooksData(claudeURL: claudeURL)
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
}
func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
}
private func buildHooksData(claudeURL: URL) throws -> Data {
private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
#endif
+6 -7
View File
@@ -498,7 +498,9 @@ struct IslandContentView: View {
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
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
// Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so
@@ -514,6 +516,7 @@ struct IslandContentView: View {
.opacity(active ? 1 : 0)
.scaleEffect(active ? 1 : 0.97)
.allowsHitTesting(active)
.transition(.opacity)
.animation(anim, value: state.view)
}
}
@@ -584,12 +587,8 @@ struct IslandHeader: View {
}
.buttonStyle(.plain)
Button(action: { state.soundEnabled.toggle() }) {
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash")
.font(.system(size: 14))
.foregroundColor(Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
// Networks (sound lives in Settings and on ⌃⌥M).
NetGlobeButton()
}
}
.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.
var isHeldOpen: (() -> Bool)?
/// home → petit delay (seconds). Override for debug.
var homeToPetitDelay: TimeInterval = 15
/// home → petit delay (seconds), kept in sync with the auto-close preference.
var homeToPetitDelay: TimeInterval = 15 {
didSet {
guard homeToPetitDelay != oldValue,
state == .home, homeCollapseWork != nil else { return }
scheduleHomeCollapse()
}
}
/// petit → hidden delay (seconds). Override for debug.
var petitToHiddenDelay: TimeInterval = 60
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
+38 -1
View File
@@ -395,7 +395,7 @@ struct QuestionView: View {
Text(item.question)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Options (wrapping) or "Other…" compact inline row
if curOther {
HStack(spacing: 6) {
@@ -429,6 +429,43 @@ struct QuestionView: View {
.buttonStyle(.plain)
.foregroundColor(Color(hex: "#6B7079"))
}
} else if item.hasDescriptions {
// Options with descriptions: a vertical list, label + description underneath.
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
let isSelected = curSel.contains(opt.label)
Button {
if isMulti {
toggleSelection(qi: qi, label: opt.label)
} else {
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(opt.label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#F5F6F8"))
if !opt.description.isEmpty {
Text(opt.description)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#9AA0A8"))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
}
SecondaryButton("Другое…") {
if qi < showOther.count { showOther[qi] = true }
}
}
} else {
ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
private var frameTimer: Timer?
private var keyMonitor: Any?
private var viewSubscription: AnyCancellable?
private var autoCloseSubscription: AnyCancellable?
// Confused recovery timer (set by handleDizzy)
private var confusedRecoveryTimer: DispatchWorkItem?
@@ -163,6 +164,11 @@ final class IslandWindowController: NSWindowController {
// MARK: - FSM wiring
private func wireFSM() {
// Apply the persisted auto-close preference immediately and keep live edits in sync.
autoCloseSubscription = state.$autoCloseInterval.sink { [weak self] delay in
self?.fsm.homeToPetitDelay = delay
}
fsm.onTransition = { [weak self] from, to in
guard let self else { return }
switch to {
@@ -211,21 +217,39 @@ final class IslandWindowController: NSWindowController {
fsm.isHeldOpen = {
let s = AppState.shared
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
|| (s.mode == .expanded && s.translation != nil)
}
}
// MARK: - 60 Hz polling loop
private func startPolling() {
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
private var pollInterval: TimeInterval = 0
/// 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 }
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() {
guard let panel = window as? IslandPanel else { return }
let want = desiredPollInterval
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
let mouse = NSEvent.mouseLocation
@@ -369,15 +393,19 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now
}
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
/// only a pending approval still keeps the island open.
func collapse(byUser: Bool = false) {
if byUser {
guard state.pendingApproval == nil else { return }
} else {
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too.
/// `allowPendingApproval`: the notch's own Escape, jump-to-terminal — may fold the
/// approval card (but not a chat/terminal tab).
/// Folding a pending approval never answers it: the request stays pending, the island
/// stays compact (held open) and a click or ⌃⌥A brings the card back.
func collapse(byUser: Bool = false, allowPendingApproval: Bool = false) {
let onTallTab = state.mode == .expanded && state.view.isTall
let keepsApprovalPending = state.pendingApproval != nil
&& (byUser || (allowPendingApproval && !onTallTab))
if !byUser && !keepsApprovalPending {
guard fsm.isHeldOpen?() != true else { return }
}
state.isPinned = false
if !keepsApprovalPending { state.isPinned = false }
finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now).
fsm.collapse()
@@ -400,6 +428,7 @@ final class IslandWindowController: NSWindowController {
collapse(byUser: true)
} else {
islandPanel.makeKey()
fsm.openedExternally()
expand(to: defaultView())
}
@@ -410,6 +439,7 @@ final class IslandWindowController: NSWindowController {
case .goToAlert:
if state.pendingApproval != nil {
islandPanel.makeKey()
fsm.openedExternally()
expand(to: .approval)
} else if state.pendingQuestion != nil {
islandPanel.makeKey()
@@ -445,6 +475,9 @@ final class IslandWindowController: NSWindowController {
case .desktopToggle:
DesktopMochiController.shared.flyOutOrHome()
case .translate:
TranslatorRun.start(state: state)
case .wardrobeToggle:
if state.mode == .expanded && state.view == .wardrobe {
collapse()
@@ -514,8 +547,9 @@ final class IslandWindowController: NSWindowController {
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
if !consumed && state.mode == .expanded && !state.isPinned {
collapse()
let canCollapse = !state.isPinned || state.pendingApproval != nil
if !consumed && state.mode == .expanded && canCollapse {
collapse(allowPendingApproval: true)
}
return true
}
@@ -569,7 +603,7 @@ final class IslandWindowController: NSWindowController {
NSWorkspace.shared.open(
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
}
collapse()
collapse(allowPendingApproval: true)
}
private func performAttachFrontWindow() {
@@ -594,6 +628,8 @@ final class IslandWindowController: NSWindowController {
Task { @MainActor in
guard let self = self else { return }
if event.keyCode == 53 { // Escape
// Escape typed in another app (Claude Code's own interrupt, an editor…)
// never folds a pending approval away: only Escape in the notch does.
if self.state.mode == .expanded && !self.state.isPinned {
self.collapse()
}
@@ -1166,6 +1202,10 @@ func islandSize(mode: IslandMode, view: IslandView,
let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат).
if view.isTall { return (AppState.shared.chatWidth, layout.height) }
// The question card grows to fit the full question and option descriptions.
if view == .question, let h = QuestionLayout.height {
return (IslandConst.expandedWidth, h)
}
return (IslandConst.expandedWidth, layout.height)
}
}
+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("acceptEdits")
Text("Только план").tag("plan")
Text("Без проверок (опасно)").tag("bypassPermissions")
}
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
+4 -1
View File
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
case translate = "translate" // ⌥` — translate the selected text (BroV)
// MARK: UserDefaults keys
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .muteToggle: return "Выключить / включить звук"
case .desktopToggle: return "BroV на рабочий стол / обратно"
case .wardrobeToggle: return "Открыть / закрыть гардероб"
case .translate: return "Перевести выделенный текст"
}
}
@@ -104,6 +106,7 @@ enum ShortcutLogic {
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
.translate: ShortcutSpec(keyCode: 50, nsFlags: ShortcutSpec.optBit), // ⌥` (key left of 1)
]
// 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",
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",
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:"⎋",
123:"←", 124:"→", 125:"↓", 126:"↑",
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
}
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 history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
@@ -73,6 +82,7 @@ final class ChatTabs {
clis[id]?.cancel()
clis.removeValue(forKey: id)
histories.removeValue(forKey: id)
seeds.removeValue(forKey: id)
}
}
@@ -260,6 +270,7 @@ struct TabChip: View {
}
.padding(14)
.frame(width: 260)
.notchPopoverStyle()
}
}
}
@@ -293,6 +304,7 @@ struct NewTabButton: View {
}
}
.padding(10)
.notchPopoverStyle()
}
}
@@ -321,3 +333,15 @@ struct NewTabButton: View {
.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.
// 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
extension Color {
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 val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b)
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
}
}
// MARK: - CGColor from hex string
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: "#"))
guard let val = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((val >> 16) & 0xFF) / 255
let g = CGFloat((val >> 8) & 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 {
+12 -6
View File
@@ -6,7 +6,8 @@ options:
xcodeVersion: "27.0"
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:
Debug: debug
Release: release
@@ -14,14 +15,19 @@ configs:
settings:
base:
SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: NO
# Hardened runtime, no get-task-allow: other processes can't inject code into BroV
# and borrow its Accessibility / Apple Events permissions.
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
CODE_SIGN_ENTITLEMENTS: Resources/BroV.entitlements
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
# Ad-hoc signature: runs locally, no Apple Developer account needed.
# 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_IDENTITY: "-"
CODE_SIGNING_REQUIRED: NO
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: ""
DEVELOPMENT_TEAM: V3NLZK45Q4
# The only third-party dependency: a real terminal emulator for the term.macOS tabs.
packages:
+4 -2
View File
@@ -6,10 +6,12 @@ cd "$(dirname "$0")/../NotchBuddy"
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
xcodegen -q
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 -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
# restart the old binary mid-copy.
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