Compare commits

...

21 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
maksarsanjeev d8a64ab5b5 Up-arrow always visible in the header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:34:10 +03:00
maksarsanjeev a92bf02477 Up-arrow (and toggle hotkey) folds chat/terminal back into the notch even while held open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:28:42 +03:00
maksarsanjeev 56367ef9cd Tabs: '+' opens brov-chat or term.macOS (max 6, split around the camera); real zsh terminals via SwiftTerm; per-tab chat history and claude session; hover 3 s to close with confirmation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:27:02 +03:00
maksarsanjeev 0d40b78b26 Resilience: LaunchAgent restarts BroV on crash; crashed Claude Code/Codex sessions get a resume button (optional auto); login and waiting status in the Claude/ChatGPT pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:56:39 +03:00
maksarsanjeev 37c01f991a Danger warning on approval cards (red card, reasons, no Always); file shelf in the top-left slot with 'На полку' after a drop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:51:35 +03:00
maksarsanjeev 3857776111 Header: collapse button folds the island (and chat size) back into the notch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:29:48 +03:00
maksarsanjeev ae394370bf Limits move into the Claude/ChatGPT pills (header ran under the camera); balanced home margins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:27:26 +03:00
maksarsanjeev a4d5032959 Chat: drag handle with detents (fit, 1/3, 1/2, full screen), chat width setting; tall transparent panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:19:27 +03:00
maksarsanjeev fbf90fd4fb Memoji character (11 expressions) on the engine's motion; Claude limit shows time to reset; ChatGPT weekly limit from Codex logs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:53 +03:00
maksarsanjeev 8a8dd98e06 Home: Claude and ChatGPT on the bottom floor, empty slots on top
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:08:29 +03:00
51 changed files with 4151 additions and 262 deletions
+9
View File
@@ -9,3 +9,12 @@ DerivedData/
# Local only # Local only
.claude/ .claude/
# Secrets never live in the repo (root core folder, ~/.brov-secrets)
*.conf
guide-*.md
brov-secrets/
*.vpnkey
api.secret
netcore.json
config.yaml
+9 -2
View File
@@ -19,10 +19,17 @@ DerivedData must stay outside ~/Documents (iCloud adds Finder attributes and cod
## Rules ## Rules
- Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV. - Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV.
- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`). - Swift 6, SwiftUI + AppKit. One dependency: SwiftTerm (term.macOS tabs). The character is 11 Memoji images (`Resources/memoji`) moved by `BotEngine` at 30 fps.
- Secrets live in the Keychain, never on disk or in git. - Secrets live in the Keychain or the root-only network core folder, never in git.
- Never block Claude Code: if the app doesn't answer, the hook exits immediately. - Never block Claude Code: if the app doesn't answer, the hook exits immediately.
- Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms. - Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
- Never approve a Claude Code permission without an explicit click. - Never approve a Claude Code permission without an explicit click.
- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin. - When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
- Pill IDs are stable contract values: never rename an existing pill ID. - Pill IDs are stable contract values: never rename an existing pill ID.
## Network core (globe 🌐 in the header)
- mihomo runs as root: LaunchDaemon `local.maksar.brov.netd`, binary + config + keys + API secret in `/Library/Application Support/BroV/` (root, 0700). Nothing user-writable is read by root.
- BroV never sees the config or the API secret: it talks to the narrow root helper `scripts/netcore/netctl.py` (LaunchDaemon `local.maksar.brov.netctl`, socket `/var/run/brov-netctl.sock`, only the installing user) — commands: state, select, delay, tun, add_key, remove_key.
- `scripts/netcore/gen.py` builds the config (Amnezia keys → provider `keys/amnezia.yaml`, WireGuard clients, VLESS subscription). Install/update: `sudo sh scripts/netcore/install.sh` (verifies the mihomo SHA256, migrates keys). Never commit keys, configs or the guide (`~/.brov-secrets`).
- Reloading the whole core config drops every connection (including this chat); group switches and provider reloads don't.
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime: BroV drives Terminal, Music and Mail through Apple Events. -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+30
View File
@@ -16,13 +16,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Warm up Keychain cache on main thread BEFORE any poller or view touches it // Warm up Keychain cache on main thread BEFORE any poller or view touches it
_ = KeychainStore.shared _ = KeychainStore.shared
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
// One BroV at a time (launchd may start one while another is still quitting).
let me = ProcessInfo.processInfo.processIdentifier
if NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
.contains(where: { $0.processIdentifier != me }) {
exit(0)
}
BroVLaunchAgent.ensure()
Self.pruneInbox()
setupMenuBarItem() setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
AgentAuth.shared.start()
setupIsland() setupIsland()
#if PHONE_LINK #if PHONE_LINK
CloudProbe.shared.startIfEnabled() CloudProbe.shared.startIfEnabled()
#endif #endif
} }
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
openIsland()
return true
}
/// Copies of dropped files (HookServer.supportDir/inbox) are kept 7 days, then removed.
private static func pruneInbox() {
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-7 * 86400)
for f in files {
let d = (try? f.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantFuture
if d < cutoff { try? fm.removeItem(at: f) }
}
try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inbox.path)
}
// MARK: - Menu bar // MARK: - Menu bar
private func setupMenuBarItem() { private func setupMenuBarItem() {
@@ -46,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Actions // MARK: - Actions
@objc private func openIsland() { @objc private func openIsland() {
islandController?.fsm.openedExternally()
islandController?.expand(to: .overview) islandController?.expand(to: .overview)
} }
+134 -21
View File
@@ -3,8 +3,8 @@ import AppKit
// MARK: - BroV home: two floors of flat app pills // MARK: - BroV home: two floors of flat app pills
// //
// Top floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). // Top floor: two empty slots, to be filled later.
// Bottom floor: two empty slots, to be filled later. The bot keeps its spot on the left. // Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left.
struct DesktopAppSlot: Identifiable { struct DesktopAppSlot: Identifiable {
let id: String let id: String
@@ -27,23 +27,86 @@ enum DesktopApps {
struct AppGridView: View { struct AppGridView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var showSystem = false
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? {
guard state.planRelayInstalled, let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return nil }
var text = "\(Int(pct.rounded()))%"
if let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() {
text += " · сброс через \(PlanTime.untilReset(w.resetsAt))"
}
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
/// What Claude Code / Codex need from us, most urgent first:
/// a session fell over → login needed → waiting for an answer → working.
private func alert(for kind: AgentKind) -> PillAlert? {
if let crash = state.crashedSessions.last(where: { $0.kind == kind }) {
return PillAlert(text: "упала · \(crash.project) · поднять", color: "#F4505E") {
AgentWatch.shared.revive(crash)
}
}
if kind == .claude ? state.claudeNeedsLogin : state.codexNeedsLogin {
return PillAlert(text: "нужен вход · войти", color: "#F4505E") {
AgentAuth.shared.login(kind)
}
}
let pillIds: Set<String> = kind == .claude ? ["integration_claude", "agent_cursor"] : ["agent_codex"]
let tasks = state.tasks.filter { pillIds.contains($0.id) }
if let t = tasks.first(where: { $0.state == .approval || $0.state == .question }) {
let isApproval = t.state == .approval
return PillAlert(text: isApproval ? "ждёт разрешения · открыть" : "задал вопрос · открыть",
color: "#F5A524") {
state.setFocus(t.id)
state.view = isApproval ? .approval : .question
}
}
if let t = tasks.first(where: { [.working, .thinking, .searching].contains($0.state) }) {
return PillAlert(text: "работает · \(t.name)", color: "#3B9EFF")
}
if tasks.contains(where: { $0.state == .ratelimit }) {
return PillAlert(text: "упёрся в лимит", color: "#FB923C")
}
return nil
}
/// "23% · сброс сб 15:22" — ChatGPT weekly limit from the Codex logs.
private var chatGPTLimit: PillLimit? {
guard let w = state.codexWeekly else { return nil }
let pct = ClaudePlanGauge.effectivePct(w)
var text = "\(Int(pct.rounded()))%"
if w.resetsAt > Date() { text += " · сброс \(PlanTime.resetMoment(w.resetsAt))" }
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
var body: some View { var body: some View {
ZStack { ZStack {
VStack(spacing: 8) { VStack(spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
AppPill(app: DesktopApps.claude) ShelfPill()
AppPill(app: DesktopApps.chatGPT) FuncPill(state: state, showSystem: $showSystem)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
EmptyPill() AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
EmptyPill() AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex))
} }
} }
.padding(.leading, 108) .padding(.leading, 104)
.padding(.trailing, 14) .padding(.trailing, 20)
.padding(.vertical, 6) .padding(.vertical, 6)
if let t = state.translation {
TranslationCard(state: state, result: t)
.transition(.opacity)
}
if showSystem {
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
.transition(.opacity)
}
#if !APPSTORE #if !APPSTORE
if state.showingPlanDetail { if state.showingPlanDetail {
CardBackground(wash: nil) CardBackground(wash: nil)
@@ -62,8 +125,22 @@ struct AppGridView: View {
// MARK: - App pill // MARK: - App pill
struct PillLimit {
let text: String
let color: String
}
/// Something the agent needs from us, shown instead of the limit line.
struct PillAlert {
let text: String
let color: String
var action: (() -> Void)? = nil
}
struct AppPill: View { struct AppPill: View {
let app: DesktopAppSlot let app: DesktopAppSlot
var limit: PillLimit? = nil
var alert: PillAlert? = nil
@State private var appURL: URL? @State private var appURL: URL?
@State private var isRunning = false @State private var isRunning = false
@State private var isHovered = false @State private var isHovered = false
@@ -74,28 +151,44 @@ struct AppPill: View {
} }
var body: some View { var body: some View {
Button(action: open) {
HStack(spacing: 10) { HStack(spacing: 10) {
icon icon
.frame(width: 26, height: 26) .frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(app.name) Text(app.name)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8")) .foregroundColor(Color(hex: "#F5F6F8"))
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
}
}
Spacer(minLength: 4) Spacer(minLength: 4)
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D")) .foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
} }
if let alert {
alertRow(alert)
} else {
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
// With a limit shown, the green dot alone says "running".
if limit == nil || appURL == nil {
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
}
if let limit, appURL != nil {
Text(limit.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: limit.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
}
}
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background( .background(
@@ -107,8 +200,7 @@ struct AppPill: View {
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1) .stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
} .onTapGesture(perform: open)
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.02 : 1) .scaleEffect(isHovered ? 1.02 : 1)
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } } .onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
.onAppear(perform: refresh) .onAppear(perform: refresh)
@@ -116,6 +208,27 @@ struct AppPill: View {
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() } .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
} }
/// Status that needs attention: pulsing dot + text; tappable when it has an action.
@ViewBuilder private func alertRow(_ alert: PillAlert) -> some View {
let row = HStack(spacing: 5) {
Circle()
.fill(Color(hex: alert.color))
.frame(width: 6, height: 6)
.shadow(color: Color(hex: alert.color), radius: 3)
Text(alert.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: alert.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
if let action = alert.action {
Button(action: action) { row.contentShape(Rectangle()) }
.buttonStyle(.plain)
} else {
row
}
}
@ViewBuilder private var icon: some View { @ViewBuilder private var icon: some View {
if let appURL { if let appURL {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
+118 -4
View File
@@ -9,7 +9,26 @@ final class AppState: ObservableObject {
// Island state // Island state
@Published var mode: IslandMode = .hidden @Published var mode: IslandMode = .hidden
@Published var view: IslandView = .overview @Published var view: IslandView = .overview {
didSet {
// Any way into the chat (drop → "Задать вопрос", shortcuts…) lands in a brov-chat tab.
if view == .prompt, tabs.first(where: { $0.id == activeTabId })?.kind != .chat {
if let chat = tabs.first(where: { $0.kind == .chat }) {
if let cur = activeTabId, tabs.first(where: { $0.id == cur })?.kind == .chat {
ChatTabs.shared.saveHistory(chatHistory, for: cur)
}
activeTabId = chat.id
chatHistory = ChatTabs.shared.history(for: chat.id)
} else if tabs.count < Self.maxTabs {
let n = (1...Self.maxTabs).first { k in !tabs.contains { $0.kind == .chat && $0.number == k } } ?? 1
let t = BroTab(kind: .chat, number: n)
tabs.append(t)
activeTabId = t.id
chatHistory = []
}
}
}
}
// Tasks // Tasks
@Published var tasks: [AgentTask] = [] @Published var tasks: [AgentTask] = []
@@ -243,6 +262,16 @@ final class AppState: ObservableObject {
// Dropped file (set during upload flow) // Dropped file (set during upload flow)
@Published var droppedFile: DroppedFile? = nil @Published var droppedFile: DroppedFile? = nil
/// Original URLs of the last drop (all of them), for "На полку".
var lastDroppedURLs: [URL] = []
/// Puts the last dropped files on the shelf and goes back home.
func shelveLastDrop() {
FileShelf.shared.add(lastDroppedURLs)
SoundEngine.shared.play("pop")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
view = .overview
}
// Short note message (shown in NoteView) // Short note message (shown in NoteView)
@Published var noteMessage: String? = nil @Published var noteMessage: String? = nil
@@ -349,7 +378,9 @@ final class AppState: ObservableObject {
@Published var pendingApproval: ApprovalInfo? = nil @Published var pendingApproval: ApprovalInfo? = nil
// Pending AskUserQuestion from Claude Code hook // Pending AskUserQuestion from Claude Code hook
@Published var pendingQuestion: AskQuestion? = nil @Published var pendingQuestion: AskQuestion? = nil {
didSet { QuestionLayout.height = pendingQuestion?.estimatedIslandHeight }
}
// Per-pill flat list of FileDiffs, in order of reception. // Per-pill flat list of FileDiffs, in order of reception.
// Not @Published — steps[] changes already trigger redraws. // Not @Published — steps[] changes already trigger redraws.
@@ -382,11 +413,13 @@ final class AppState: ObservableObject {
private func resetSessionDiffTimer(for pillId: String) { private func resetSessionDiffTimer(for pillId: String) {
sessionDiffTimers[pillId]?.cancel() sessionDiffTimers[pillId]?.cancel()
// The closure is MainActor-isolated (AppState is @MainActor): it must run on the main
// queue. Scheduled on a global queue, Swift 6's isolation check traps and the app quits.
let work = DispatchWorkItem { [weak self] in let work = DispatchWorkItem { [weak self] in
DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) } self?.clearSessionDiffs(for: pillId)
} }
sessionDiffTimers[pillId] = work sessionDiffTimers[pillId] = work
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work) DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
} }
#if !APPSTORE #if !APPSTORE
@@ -395,6 +428,85 @@ final class AppState: ObservableObject {
#endif #endif
// Claude plan gauge (from statusline hook) // Claude plan gauge (from statusline hook)
// MARK: Chat panel size (BroV)
// Detents: 0 = fits the messages (as before), 1 = ⅓ screen, 2 = ½ screen, 3 = full height.
@Published var chatDetent: Int = 0
/// Live height while the user drags the handle; nil otherwise.
@Published var chatDragHeight: CGFloat? = nil
@Published var chatWidth: CGFloat = 720 {
didSet { UserDefaults.standard.set(Double(chatWidth), forKey: "chatWidth") }
}
static let chatWidthOptions: [CGFloat] = [640, 720, 820, 960]
var screenHeight: CGFloat {
(IslandWindowController.notchScreen() ?? NSScreen.main)?.frame.height ?? 900
}
func chatDetentHeight(_ d: Int) -> CGFloat {
let auto = view == .terminal ? 380 : min(300, 240 + CGFloat(chatHistory.count) * 40)
switch d {
case 1: return max(320, screenHeight / 3)
case 2: return max(340, screenHeight / 2)
case 3: return screenHeight - 6
default: return auto
}
}
/// Current chat island height (drag, else detent).
var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) }
// MARK: Tabs (BroV): brov-chat dialogs and term.macOS terminals
static let maxTabs = 6
@Published var tabs: [BroTab] = [BroTab(kind: .chat, number: 1)]
@Published var activeTabId: UUID? = nil
func openTab(_ kind: TabKind) {
guard tabs.count < Self.maxTabs else { return }
let used = Set(tabs.filter { $0.kind == kind }.map(\.number))
let n = (1...Self.maxTabs).first { !used.contains($0) } ?? tabs.count + 1
let tab = BroTab(kind: kind, number: n)
tabs.append(tab)
SoundEngine.shared.play("pop")
activateTab(tab.id)
}
func activateTab(_ id: UUID) {
guard let tab = tabs.first(where: { $0.id == id }) else { return }
if let cur = activeTabId, cur != id, tabs.first(where: { $0.id == cur })?.kind == .chat {
ChatTabs.shared.saveHistory(chatHistory, for: cur)
}
if activeTabId != id {
activeTabId = id
if tab.kind == .chat { chatHistory = ChatTabs.shared.history(for: id) }
}
view = tab.kind == .chat ? .prompt : .terminal
}
func closeTab(_ id: UUID) {
guard let i = tabs.firstIndex(where: { $0.id == id }) else { return }
let tab = tabs.remove(at: i)
if tab.kind == .chat { ChatTabs.shared.close(id) } else { TerminalStore.shared.close(id) }
SoundEngine.shared.play("close")
guard activeTabId == id else { return }
activeTabId = nil
if tab.kind == .chat { chatHistory = [] }
if let next = tabs.indices.contains(i) ? tabs[i] : tabs.last {
activateTab(next.id)
} else {
view = .overview
}
}
/// Func → Переводчик result shown on the home view.
@Published var translation: TranslationResult? = nil
// MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false
@Published var codexNeedsLogin = false
/// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor.
@Published var codexWeekly: PlanWindow? = nil
@Published var claudePlanUsage: PlanUsage? = nil { @Published var claudePlanUsage: PlanUsage? = nil {
didSet { didSet {
if let u = claudePlanUsage, if let u = claudePlanUsage,
@@ -422,6 +534,7 @@ final class AppState: ObservableObject {
// MARK: - Init (loads persisted settings) // MARK: - Init (loads persisted settings)
private init() { private init() {
activeTabId = tabs.first?.id
let ud = UserDefaults.standard let ud = UserDefaults.standard
if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v } if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v }
@@ -431,6 +544,7 @@ final class AppState: ObservableObject {
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } !v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v }
if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v } if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v }
if let v = ud.object(forKey: "chatWidth") as? Double, v >= 560 { chatWidth = CGFloat(v) }
if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v } if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v } if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v } if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
+32
View File
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
var multiSelect: Bool var multiSelect: Bool
} }
/// Island height of the pending question, readable from the nonisolated `islandSize`. Written on the main actor only.
enum QuestionLayout {
nonisolated(unsafe) static var height: CGFloat?
}
extension AskQuestionItem {
/// True when at least one option carries a description: the card then lists options vertically.
var hasDescriptions: Bool { options.contains { !$0.description.isEmpty } }
}
struct AskQuestion: Equatable { struct AskQuestion: Equatable {
var questions: [AskQuestionItem] // 1–4 questions var questions: [AskQuestionItem] // 1–4 questions
/// Island height that fits the tallest question without truncation (rough estimate, text wraps at ~500 pt).
var estimatedIslandHeight: CGFloat {
func lines(_ text: String, charWidth: CGFloat) -> CGFloat {
max(1, (CGFloat(text.count) * charWidth / 500).rounded(.up))
}
let tallest = questions.map { item -> CGFloat in
var h: CGFloat = 20 + lines(item.question, charWidth: 7) * 17 + 64
if !item.header.isEmpty { h += 14 }
if item.hasDescriptions {
for opt in item.options {
h += 34 + (opt.description.isEmpty ? 0 : lines(opt.description, charWidth: 6.4) * 14)
}
h += 40 // "Другое…" row
} else {
h += item.options.count >= 3 ? 74 : 40
}
if item.multiSelect { h += 34 }
return h
}.max() ?? 160
return min(max(tallest, 160), 560)
}
// MARK: - Parse from tool_input dict // MARK: - Parse from tool_input dict
// Returns nil if the payload is malformed (fallback → Allow/Deny card). // Returns nil if the payload is malformed (fallback → Allow/Deny card).
static func parse(toolInput: [String: Any]) -> AskQuestion? { static func parse(toolInput: [String: Any]) -> AskQuestion? {
+14 -23
View File
@@ -11,9 +11,11 @@ struct BotCanvasView: View {
// One engine per view instance (main bot) // One engine per view instance (main bot)
@StateObject private var engine = BotEngine() @StateObject private var engine = BotEngine()
@State private var fader = MemojiFader()
var body: some View { var body: some View {
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in // 30 fps is plenty for the character (the display would drive 120 on ProMotion).
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: state.mode == .hidden)) { timeline in
Canvas { context, size in Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate let now = timeline.date.timeIntervalSinceReferenceDate
let dtRaw = min(0.05, now - engine.lastTime) let dtRaw = min(0.05, now - engine.lastTime)
@@ -69,26 +71,15 @@ struct BotCanvasView: View {
engine.update(dt: dt) engine.update(dt: dt)
var ctx = context var ctx = context
engine.applyDance(&ctx, size: size) engine.applyDance(&ctx, size: size)
// Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling, // BroV: the Memoji is the character. The engine's box (file upload morph)
// rotate the entire body+accessories context around the body center so the // fades in only while morphing; hands and outfits are not drawn.
// whole character genuinely turns. Particles/badge (drawHandsAndExtras) are if engine.morph > 0.02 {
// drawn outside the rotated context and do not spin. var boxCtx = ctx
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { boxCtx.opacity = Double(min(1, engine.morph * 2))
let center = engine.bodyCenter(size: size) engine.draw(context: boxCtx, size: size)
var rigidCtx = ctx
rigidCtx.translateBy(x: center.x, y: center.y)
rigidCtx.rotate(by: .radians(engine.roll))
rigidCtx.translateBy(x: -center.x, y: -center.y)
engine.drawHandsBehind(context: rigidCtx, size: size)
engine.drawOutfitBehind(context: rigidCtx, size: size)
engine.draw(context: rigidCtx, size: size)
engine.drawOutfitFront(context: rigidCtx, size: size)
} else {
engine.drawHandsBehind(context: ctx, size: size)
engine.drawOutfitBehind(context: ctx, size: size)
engine.draw(context: ctx, size: size)
engine.drawOutfitFront(context: ctx, size: size)
} }
engine.drawMemoji(context: ctx, size: size, fader: fader,
opacity: Double(max(0, 1 - engine.morph * 2)))
engine.drawHandsAndExtras(context: ctx, size: size) engine.drawHandsAndExtras(context: ctx, size: size)
} }
} }
@@ -172,8 +163,8 @@ struct BotCanvasView: View {
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) let actualH: CGFloat = (state.mode == .expanded && state.view.isTall)
? min(300, 240 + CGFloat(state.chatHistory.count) * 40) ? state.chatIslandHeight
: islandH : islandH
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
islandW: islandW, islandH: actualH, islandW: islandW, islandH: actualH,
@@ -201,7 +192,7 @@ struct MiniBotCanvasView: View {
} }
var body: some View { var body: some View {
TimelineView(.animation) { timeline in TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
Canvas { context, size in Canvas { context, size in
let now = timeline.date.timeIntervalSinceReferenceDate let now = timeline.date.timeIntervalSinceReferenceDate
let dt = min(0.05, now - engine.lastTime) let dt = min(0.05, now - engine.lastTime)
+4 -1
View File
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
"--append-system-prompt", Self.notchPrompt] "--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] } if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] } if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] } // Never bypass: the chat gets untrusted input (dropped files, window titles, URLs).
let allowedModes: Set<String> = ["acceptEdits", "plan"]
if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
let p = Process() let p = Process()
p.executableURL = URL(fileURLWithPath: binary) p.executableURL = URL(fileURLWithPath: binary)
@@ -149,6 +151,7 @@ final class ClaudeCodeCLI {
if let error = parser.errorMessage { if let error = parser.errorMessage {
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") { if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
AgentAuth.shared.reportAuthError(.claude)
throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.") throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.")
} }
// A resume that points at a gone session: start fresh next time. // A resume that points at a gone session: start fresh next time.
@@ -64,6 +64,16 @@ enum ClaudePlanGauge {
} }
} }
/// The window with the higher effective percentage (its reset is the one that matters).
static func dominantWindow(_ usage: PlanUsage) -> PlanWindow? {
switch (usage.fiveHour, usage.sevenDay) {
case (nil, nil): return nil
case (let a?, nil): return a
case (nil, let b?): return b
case (let a?, let b?): return effectivePct(b) > effectivePct(a) ? b : a
}
}
/// Hex color for a given percentage (nil → grey). /// Hex color for a given percentage (nil → grey).
static func color(for pct: Double?) -> String { static func color(for pct: Double?) -> String {
guard let p = pct else { return "#6B7079" } guard let p = pct else { return "#6B7079" }
+19 -6
View File
@@ -271,7 +271,11 @@ final class ClaudeService {
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key) // MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async { func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
let cli = ClaudeCodeCLI.shared // Each brov-chat tab has its own claude conversation and history.
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 var prompt = query
// Context goes with the first message of a conversation, like the API chat. // Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context { if cli.sessionID == nil, let context {
@@ -290,9 +294,11 @@ final class ClaudeService {
} }
} }
if let seed { prompt = seed + "\n\n" + prompt }
let placeholder = ChatMessage(role: .assistant, content: "") let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id let msgId = placeholder.id
state.chatHistory.append(placeholder) if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
state.stateOverride = .thinking state.stateOverride = .thinking
do { do {
@@ -306,25 +312,32 @@ final class ClaudeService {
if !visible.isEmpty, state.stateOverride == .thinking { if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text state.stateOverride = nil // hide typing dots on first visible text
} }
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = visible }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible state.chatHistory[idx].content = visible
} }
} }
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
if trimmed.isEmpty { ChatTabs.shared.remove(tab: tab, message: msgId) }
else { ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = trimmed } }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) } if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed } else { state.chatHistory[idx].content = trimmed }
} }
state.stateOverride = nil state.stateOverride = nil
state.view = .prompt if isOnScreen() && state.view != .terminal { state.view = .prompt }
state.claudeNeedsLogin = false
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError { } catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty } state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil state.stateOverride = nil
} catch { } catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty } state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
state.stateOverride = nil state.stateOverride = nil
await showError(error.localizedDescription, state: state) if isOnScreen() { await showError(error.localizedDescription, state: state) }
} }
} }
@@ -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
}
}
+109
View File
@@ -0,0 +1,109 @@
import Foundation
// MARK: - ChatGPT (Codex) plan limits
//
// The ChatGPT/Codex app writes its plan limits into every session log
// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) as
// "rate_limits":{"primary":{"used_percent":23.0,"window_minutes":10080,"resets_at":1791634947},"secondary":…}
// We read the newest logs once a minute and keep the weekly window (10080 min).
// Nothing is sent anywhere; the numbers are only as fresh as the last Codex turn.
enum CodexUsage {
static let weekMinutes = 10080
/// Weekly window from the most recent `rate_limits` found in the newest session logs.
static func readWeekly(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> PlanWindow? {
let root = home.appendingPathComponent(".codex/sessions")
for file in newestLogs(in: root, limit: 4) {
if let w = lastWeekly(in: file) { return w }
}
return nil
}
/// Newest *.jsonl files under the date folders, newest first.
static func newestLogs(in root: URL, limit: Int) -> [URL] {
let fm = FileManager.default
guard let e = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]) else { return [] }
var files: [(URL, Date)] = []
for case let url as URL in e where url.pathExtension == "jsonl" {
let d = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
files.append((url, d))
}
return files.sorted { $0.1 > $1.1 }.prefix(limit).map(\.0)
}
/// Reads the tail of a log and returns the weekly window of its last `rate_limits`.
static func lastWeekly(in file: URL) -> PlanWindow? {
guard let h = try? FileHandle(forReadingFrom: file) else { return nil }
defer { try? h.close() }
let size = (try? h.seekToEnd()) ?? 0
let chunk: UInt64 = 512 * 1024
try? h.seek(toOffset: size > chunk ? size - chunk : 0)
guard let data = try? h.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil }
for line in text.split(separator: "\n").reversed() where line.contains("\"rate_limits\"") {
if let w = parseLine(String(line)) { return w }
}
return nil
}
static func parseLine(_ line: String) -> PlanWindow? {
guard let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)),
let limits = findRateLimits(obj) else { return nil }
let windows = [limits["primary"], limits["secondary"]].compactMap { $0 as? [String: Any] }
let weekly = windows.first { ($0["window_minutes"] as? NSNumber)?.intValue == weekMinutes }
guard let w = weekly,
let pct = (w["used_percent"] as? NSNumber)?.doubleValue,
let resets = (w["resets_at"] as? NSNumber)?.doubleValue, resets > 0 else { return nil }
return PlanWindow(usedPct: min(100, max(0, pct)), resetsAt: Date(timeIntervalSince1970: resets))
}
/// `rate_limits` can sit at any depth of the event (payload.info…), so search for it.
private static func findRateLimits(_ obj: Any) -> [String: Any]? {
if let d = obj as? [String: Any] {
if let r = d["rate_limits"] as? [String: Any] { return r }
for v in d.values { if let r = findRateLimits(v) { return r } }
} else if let a = obj as? [Any] {
for v in a { if let r = findRateLimits(v) { return r } }
}
return nil
}
}
@MainActor
final class CodexUsageMonitor {
static let shared = CodexUsageMonitor()
private var task: Task<Void, Never>?
func start() {
guard task == nil else { return }
task = Task {
while !Task.isCancelled {
let w = await Task.detached(priority: .utility) { CodexUsage.readWeekly() }.value
AppState.shared.codexWeekly = w
try? await Task.sleep(for: .seconds(60))
}
}
}
}
// MARK: - Reset time wording
enum PlanTime {
/// "3 ч 20 мин", "2 д 5 ч", "12 мин", "<1 мин".
static func untilReset(_ date: Date, now: Date = Date()) -> String {
let s = max(0, Int(date.timeIntervalSince(now)))
let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60
if d > 0 { return h > 0 ? "\(d) д \(h) ч" : "\(d) д" }
if h > 0 { return m > 0 ? "\(h) ч \(m) мин" : "\(h) ч" }
return m > 0 ? "\(m) мин" : "<1 мин"
}
/// "пт 14:00" within a week, "12 окт 14:00" beyond.
static func resetMoment(_ date: Date, now: Date = Date()) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "ru_RU")
f.dateFormat = date.timeIntervalSince(now) < 6 * 86400 ? "EE HH:mm" : "d MMM HH:mm"
return f.string(from: date).replacingOccurrences(of: ".", with: "").lowercased()
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
// MARK: - Destructive command warning
//
// Looks at a permission request (tool + tool_input) and returns short Russian reasons
// when it can destroy data or history, e.g. ["удаляет файлы рекурсивно"]. The approval
// card shows them in red and hides "Всегда" so a dangerous rule is never saved by one click.
// Heuristics only: an empty result does not mean the command is safe.
enum DangerCheck {
struct Rule {
let pattern: String
let reason: String
}
/// Shell command rules (case-insensitive regex, matched against the whole command).
static let shellRules: [Rule] = [
Rule(pattern: #"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\b.*-f\b|-f\b.*-r\b|--recursive|-r\b|-R\b)"#,
reason: "удаляет файлы рекурсивно"),
Rule(pattern: #"\brm\s+.*(\s|^)(/|~|\$HOME|\*)(\s|$)"#, reason: "удаляет корень, домашнюю папку или всё подряд"),
Rule(pattern: #"\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s\+\S)"#, reason: "перезаписывает историю на сервере (force push)"),
Rule(pattern: #"\bgit\s+reset\s+--hard\b"#, reason: "выбрасывает несохранённые изменения (reset --hard)"),
Rule(pattern: #"\bgit\s+clean\s+-[a-z]*f"#, reason: "удаляет неотслеживаемые файлы (git clean)"),
Rule(pattern: #"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)"#, reason: "откатывает все изменения в рабочей папке"),
Rule(pattern: #"\bgit\s+branch\s+-D\b"#, reason: "удаляет ветку без проверки слияния"),
Rule(pattern: #"\bgit\s+(rebase|filter-branch|filter-repo)\b"#, reason: "переписывает историю git"),
Rule(pattern: #"\bdrop\s+(table|database|schema|index|view)\b"#, reason: "удаляет таблицу или базу (DROP)"),
Rule(pattern: #"\btruncate\s+(table\s+)?\w"#, reason: "очищает таблицу (TRUNCATE)"),
Rule(pattern: #"\bdelete\s+from\s+\w+(\s*;|\s*$|\s*"|\s*')"#, reason: "удаляет все строки (DELETE без WHERE)"),
Rule(pattern: #"\bupdate\s+\w+\s+set\b(?!.*\bwhere\b)"#, reason: "меняет все строки (UPDATE без WHERE)"),
Rule(pattern: #"\b(mkfs|diskutil\s+(erase|zero|reformat|partition)|fdisk|newfs)"#, reason: "форматирует диск"),
Rule(pattern: #"\bdd\s+.*\bof="#, reason: "пишет напрямую на устройство (dd)"),
Rule(pattern: #"\bchmod\s+(-R\s+)?(0?777|a\+rwx)"#, reason: "открывает файлы всем на запись"),
Rule(pattern: #"\b(chmod|chown)\s+-R\b.*(\s/|\s~)"#, reason: "меняет права рекурсивно в системной или домашней папке"),
Rule(pattern: #"\bsudo\b"#, reason: "выполняется с правами администратора (sudo)"),
Rule(pattern: #"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b"#, reason: "скачивает и сразу запускает скрипт из интернета"),
Rule(pattern: #":\(\)\s*\{\s*:\|:&\s*\};:"#, reason: "fork-бомба"),
Rule(pattern: #"\b(kill|pkill|killall)\s+(-9\s+)?(-1\b|1\b|Finder|Dock|WindowServer|loginwindow)"#, reason: "убивает системные процессы"),
Rule(pattern: #"\b(shutdown|reboot|halt)\b"#, reason: "выключает или перезагружает компьютер"),
Rule(pattern: #"\bdocker\s+(system\s+prune|volume\s+(rm|prune)|rm\s+-f)"#, reason: "удаляет контейнеры или тома Docker"),
Rule(pattern: #"\bkubectl\s+delete\b"#, reason: "удаляет ресурсы в Kubernetes"),
Rule(pattern: #"\bterraform\s+(destroy|apply\s+.*-auto-approve)"#, reason: "меняет или уничтожает инфраструктуру"),
Rule(pattern: #"\b(npm|pnpm|yarn)\s+publish\b|\bgh\s+release\s+create\b"#, reason: "публикует наружу"),
Rule(pattern: #"\bgh\s+repo\s+(delete|edit\s+.*--visibility\s+public)"#, reason: "удаляет репозиторий или делает его публичным"),
Rule(pattern: #">\s*(/etc/|/dev/sd|/dev/disk|~/\.ssh/|~/\.zshrc|~/\.bashrc)"#, reason: "перезаписывает системный или личный конфиг"),
Rule(pattern: #"\bfind\b.*\s-delete\b|\bfind\b.*-exec\s+rm\b"#, reason: "удаляет найденные файлы пачкой"),
Rule(pattern: #"\b(security\s+delete-|defaults\s+delete\b|launchctl\s+(unload|remove|bootout))"#, reason: "меняет системные настройки macOS"),
]
/// Paths that are dangerous to write or edit.
static let sensitivePaths: [(pattern: String, reason: String)] = [
(#"(^|/)\.env(\.|$)"#, "правит секреты (.env)"),
(#"/\.ssh/"#, "правит SSH-ключи или конфиг"),
(#"^/(etc|System|Library|usr|bin|sbin)/"#, "пишет в системную папку"),
(#"/\.claude/settings(\.local)?\.json$"#, "меняет настройки Claude Code"),
(#"/\.(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 + shellPathRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason)
}
}
let fileTools: Set<String> = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
if fileTools.contains(tool) {
let path = (input["file_path"] as? String ?? input["notebook_path"] as? String ?? "")
let expanded = (path as NSString).expandingTildeInPath
for (pattern, reason) in sensitivePaths where matches(pattern, expanded) && !out.contains(reason) {
out.append(reason)
}
}
return out
}
static func matches(_ pattern: String, _ text: String) -> Bool {
text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
/// What the approval card shows for non-shell tools: "Write · ~/proj/.env".
static func describe(tool: String, input: [String: Any]) -> String {
if let command = input["command"] as? String { return command }
if let path = (input["file_path"] ?? input["notebook_path"] ?? input["path"]) as? String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let short = path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
return "\(tool) · \(short)"
}
if let url = input["url"] as? String { return "\(tool) · \(url)" }
return tool
}
}
+9 -16
View File
@@ -24,6 +24,7 @@ struct DesktopBotView: View {
@ObservedObject var appState: AppState @ObservedObject var appState: AppState
/// Engine owned by DesktopMochiController; controller calls methods on it directly. /// Engine owned by DesktopMochiController; controller calls methods on it directly.
let engine: BotEngine let engine: BotEngine
@State private var fader = MemojiFader()
@ObservedObject var viewState: DesktopBotViewState @ObservedObject var viewState: DesktopBotViewState
var body: some View { var body: some View {
@@ -59,23 +60,15 @@ struct DesktopBotView: View {
var c = ctx var c = ctx
engine.applyDance(&c, size: size) engine.applyDance(&c, size: size)
// Rigid roll when outfit is present (matches BotCanvasView) // BroV: the Memoji is the character. The engine's box (file upload morph)
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { // fades in only while morphing; hands and outfits are not drawn.
let center = engine.bodyCenter(size: size) if engine.morph > 0.02 {
var rigidCtx = c var boxCtx = c
rigidCtx.translateBy(x: center.x, y: center.y) boxCtx.opacity = Double(min(1, engine.morph * 2))
rigidCtx.rotate(by: .radians(engine.roll)) engine.draw(context: boxCtx, size: size)
rigidCtx.translateBy(x: -center.x, y: -center.y)
engine.drawHandsBehind(context: rigidCtx, size: size)
engine.drawOutfitBehind(context: rigidCtx, size: size)
engine.draw(context: rigidCtx, size: size)
engine.drawOutfitFront(context: rigidCtx, size: size)
} else {
engine.drawHandsBehind(context: c, size: size)
engine.drawOutfitBehind(context: c, size: size)
engine.draw(context: c, size: size)
engine.drawOutfitFront(context: c, size: size)
} }
engine.drawMemoji(context: c, size: size, fader: fader,
opacity: Double(max(0, 1 - engine.morph * 2)))
engine.drawHandsAndExtras(context: c, size: size) engine.drawHandsAndExtras(context: c, size: size)
} }
} }
@@ -47,6 +47,7 @@ enum FileDropHandler {
static func handle(urls: [URL], state: AppState) async { static func handle(urls: [URL], state: AppState) async {
guard let url = urls.first else { return } guard let url = urls.first else { return }
let name = url.lastPathComponent let name = url.lastPathComponent
state.lastDroppedURLs = urls
// Start animation immediately — do NOT block on file copy. // Start animation immediately — do NOT block on file copy.
// Use original URL first; swap to inbox copy once background copy finishes. // Use original URL first; swap to inbox copy once background copy finishes.
+141
View File
@@ -0,0 +1,141 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
// MARK: - File shelf
//
// Drop files on the notch → "На полку": they wait in the top-left slot of the home
// view. Drag an icon out to put the file anywhere, click to open it, right-click to
// reveal or remove. The shelf keeps references (paths), not copies, and survives restarts;
// files that no longer exist are dropped silently.
struct ShelfItem: Identifiable, Codable, Equatable {
var id = UUID()
var path: String
var addedAt = Date()
var url: URL { URL(fileURLWithPath: path) }
var name: String { url.lastPathComponent }
}
@MainActor
final class FileShelf: ObservableObject {
static let shared = FileShelf()
static let maxItems = 12
private let key = "fileShelf"
@Published private(set) var items: [ShelfItem] = []
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) {
items = saved.filter { FileManager.default.fileExists(atPath: $0.path) }
}
}
func add(_ urls: [URL]) {
for url in urls where !items.contains(where: { $0.path == url.path }) {
items.insert(ShelfItem(path: url.path), at: 0)
}
if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) }
save()
}
func remove(_ item: ShelfItem) {
items.removeAll { $0.id == item.id }
save()
}
func clear() {
items = []
save()
}
/// Drops entries whose file was moved or deleted.
func prune() {
let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) }
if alive != items { items = alive; save() }
}
private func save() {
if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: key)
}
}
}
// MARK: - Shelf pill (home view, top-left slot)
struct ShelfPill: View {
@ObservedObject private var shelf = FileShelf.shared
var body: some View {
ZStack {
if shelf.items.isEmpty {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
Text("полка · брось файл на чёлку")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#4A4E55"))
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(hex: "#0E0F11"))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
HStack(spacing: 6) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(shelf.items) { item in
ShelfIcon(item: item)
}
}
.padding(.horizontal, 2)
}
Button {
shelf.clear()
SoundEngine.shared.play("close")
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#5F646D"))
.frame(width: 16, height: 16)
.background(Circle().fill(Color.white.opacity(0.06)))
}
.buttonStyle(.plain)
.help("Очистить полку")
}
.padding(.horizontal, 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { shelf.prune() }
}
}
struct ShelfIcon: View {
let item: ShelfItem
@State private var isHovered = false
var body: some View {
Image(nsImage: NSWorkspace.shared.icon(forFile: item.path))
.resizable()
.interpolation(.high)
.frame(width: 28, height: 28)
.padding(2)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0)))
.scaleEffect(isHovered ? 1.08 : 1)
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } }
.help(item.name)
.onTapGesture { NSWorkspace.shared.open(item.url) }
.onDrag {
SoundEngine.shared.play("pop")
return NSItemProvider(contentsOf: item.url) ?? NSItemProvider()
}
.contextMenu {
Button("Открыть") { NSWorkspace.shared.open(item.url) }
Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) }
Divider()
Button("Убрать с полки") { FileShelf.shared.remove(item) }
}
}
}
+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)
}
}
}
}
@@ -444,56 +444,15 @@ private func drawMochi(_ ctx: CGContext, p: GreetPose) {
ctx.rotate(by: CGFloat(p.tilt)) ctx.rotate(by: CGFloat(p.tilt))
ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy)) ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy))
drawHandL(ctx, hw: hw, hh: hh, p: p) // BroV: the Memoji replaces the drawn body, eyes and hands.
drawHandR(ctx, hw: hw, hh: hh, p: p) let face: MemojiExpression = {
if p.eye == .happy || p.eye == .content { return .happy }
let mpath = mochiPath(hw: hw, hh: hh) if p.open < 0.35 { return .blink }
whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh) if p.open < 0.8 { return .halfblink }
return .neutral
if p.tint > 0 { }()
let cs = CGColorSpaceCreateDeviceRGB() MemojiCG.draw(face, in: ctx, center: CGPoint(x: CGFloat(p.lookX) * hw * 0.12, y: 0),
let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint)) side: hh * 2 * 1.45)
let c1 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: 0)
if let g = CGGradient(colorsSpace: cs, colors: [c0,c1] as CFArray, locations: [0,1]) {
ctx.saveGState()
ctx.addPath(mpath); ctx.clip()
ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: hh),
end: CGPoint(x: 0, y: -hh*0.1), options: [])
ctx.restoreGState()
}
}
ctx.saveGState()
ctx.addPath(mpath); ctx.clip()
ctx.setFillColor(gHex("#16171A"))
ctx.setStrokeColor(gHex("#16171A"))
let er = CGFloat(p.hb*0.06)
let sp = CGFloat(p.hb*0.19)
let lx = CGFloat(p.lookX)*hw*0.42
let ly = CGFloat(p.lookY)*hh*0.28 + hh*0.12 + CGFloat(p.eyeRoll)*hh*1.25
for sd: CGFloat in [-1, 1] {
ctx.saveGState()
ctx.translateBy(x: sd*sp+lx, y: ly)
if p.eye == .happy {
ctx.setLineWidth(er*0.95); ctx.setLineCap(.round)
ctx.beginPath()
ctx.addArc(center: CGPoint(x: 0, y: er*0.6), radius: er*1.25,
startAngle: .pi*1.15, endAngle: .pi*1.85, clockwise: false)
ctx.strokePath()
} else if p.eye == .content {
ctx.setLineWidth(er*0.95); ctx.setLineCap(.round)
ctx.beginPath()
ctx.addArc(center: CGPoint(x: 0, y: -er*0.5), radius: er*1.25,
startAngle: .pi*0.15, endAngle: .pi*0.85, clockwise: false)
ctx.strokePath()
} else {
ctx.scaleBy(x: 1, y: max(0.12, CGFloat(p.open)))
ctx.addEllipse(in: CGRect(x: -er, y: -er, width: er*2, height: er*2))
ctx.fillPath()
}
ctx.restoreGState()
}
ctx.restoreGState()
// Activity badge // Activity badge
if p.badge > 0.01 { if p.badge > 0.01 {
+86 -59
View File
@@ -414,6 +414,20 @@ final class HookServer: @unchecked Sendable {
// Approval dismissed — fall through so the resolving event updates state normally. // Approval dismissed — fall through so the resolving event updates state normally.
} }
// BroV: crash watch + login status.
if let pid = (payload["brov_agent_pid"] as? NSNumber)?.intValue {
AgentWatch.shared.register(sessionId: sessionId, pid: pid,
kind: payload["brov_agent_kind"] as? String ?? "",
cwd: cwd, pillId: agentId)
}
if name == "SessionEnd" { AgentWatch.shared.ended(sessionId: sessionId) }
if name == "Notification" || name == "StopFailure" {
let text = (payload["message"] as? String ?? "") + " " + (payload["error"] as? String ?? "")
if AgentAuth.looksLikeAuthError(text) {
AgentAuth.shared.reportAuthError(isCodexEvent ? .codex : .claude)
}
}
switch name { switch name {
case "SessionStart": case "SessionStart":
@@ -672,7 +686,8 @@ final class HookServer: @unchecked Sendable {
return return
} }
let command = toolInput["command"] as? String ?? tool let command = DangerCheck.describe(tool: tool, input: toolInput)
let danger = DangerCheck.reasons(tool: tool, input: toolInput)
if pendingApprovalFD >= 0 { if pendingApprovalFD >= 0 {
// Displace the previous request: write "ask" then cancel its source. // Displace the previous request: write "ask" then cancel its source.
@@ -692,7 +707,9 @@ final class HookServer: @unchecked Sendable {
upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd)
state.updateTask(id: pillId, state: .approval) state.updateTask(id: pillId, state: .approval)
state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool,
command: command, inputKey: inputKey, pillId: pillId) command: command, inputKey: inputKey, pillId: pillId,
danger: danger)
if !danger.isEmpty { NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) }
state.isPinned = true state.isPinned = true
SoundEngine.shared.play("approval") SoundEngine.shared.play("approval")
@@ -1139,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
// MARK: - Claude Code settings.json hook installer // MARK: - Claude Code settings.json hook installer
private var _pendingHooksData: Data? private var _pendingHooksData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingHooksOriginal: Data?
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm. /// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
func previewClaudeHooks() throws -> String { func previewClaudeHooks() throws -> String {
let data = try buildHooksData() let (data, original) = try buildHooksData()
_pendingHooksData = data _pendingHooksData = data
_pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
/// Writes the hooks to disk (call after user confirms preview). /// Writes the hooks to disk (call after user confirms preview).
/// Refused if settings.json changed since the preview, or cannot be backed up.
func writeClaudeHooks() throws { func writeClaudeHooks() throws {
guard let data = _pendingHooksData else { return } guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
// Backup first try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
_pendingHooksData = nil _pendingHooksData = nil
_pendingHooksOriginal = nil
} }
private func buildHooksData() throws -> Data { private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { var settings = snapshot.object
settings = parsed
}
let hookPath = Self.hookScriptPath let hookPath = Self.hookScriptPath
#if APPSTORE #if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag // Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1189,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10), ("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10), ("SubagentStart", 10), ("SubagentStop", 10),
] ]
var hooks = settings["hooks"] as? [String: Any] ?? [:] // "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events { for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? [] var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false } existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing hooks[event] = existing
@@ -1204,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
]) ])
hooks["PreToolUse"] = preToolUse hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
} }
func uninstallClaudeHooks() throws { func uninstallClaudeHooks() throws {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var settings = snapshot.object
var hooks = settings["hooks"] as? [String: Any] else { return } guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys { for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] { if var matchers = hooks[key] as? [[String: Any]] {
@@ -1228,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
} }
settings["hooks"] = hooks settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
} }
// MARK: - Claude plan status line installer // MARK: - Claude plan status line installer
@@ -1249,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
} }
private var _pendingStatusLineData: Data? private var _pendingStatusLineData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingStatusLineOriginal: Data?
private var _pendingPreviousData: Data? private var _pendingPreviousData: Data?
private var _pendingDeletePrevious: Bool = false private var _pendingDeletePrevious: Bool = false
@@ -1256,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String { func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let d = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] { let settings = snapshot.object
settings = parsed
}
let hookPath = Self.hookScriptPath let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline" let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1329,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings, let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data _pendingStatusLineData = data
_pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after // Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String { func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1346,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return } guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json") .appendingPathComponent(".claude/settings.json")
let formatter = DateFormatter() try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
// Commit side effects only after successful write // Commit side effects only after successful write
if let prevData = _pendingPreviousData { if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic) try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1363,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL) try? FileManager.default.removeItem(at: statusLinePreviousURL)
} }
_pendingStatusLineData = nil _pendingStatusLineData = nil
_pendingStatusLineOriginal = nil
_pendingPreviousData = nil _pendingPreviousData = nil
_pendingDeletePrevious = false _pendingDeletePrevious = false
} }
@@ -1373,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// Writes nb-hook script and updates settings.json in one shot. /// Writes nb-hook script and updates settings.json in one shot.
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed). /// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws { func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
let data = try buildHooksData(claudeURL: claudeURL) let (data, original) = try buildHooksData(claudeURL: claudeURL)
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/ // Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
let coucouDir = claudeURL.appendingPathComponent("coucou") let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1387,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup) // Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
let formatter = DateFormatter() try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
formatter.dateFormat = "yyyyMMdd-HHmm"
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try data.write(to: settingsURL, options: .atomic)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled") UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
} }
func uninstallClaudeHooksAppStore(claudeURL: URL) throws { func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
guard let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], var settings = snapshot.object
var hooks = settings["hooks"] as? [String: Any] else { return } guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys { for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] { if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in matchers.removeAll { matcher in
@@ -1414,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
} }
settings["hooks"] = hooks settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled") UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
} }
private func buildHooksData(claudeURL: URL) throws -> Data { private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json") let settingsURL = claudeURL.appendingPathComponent("settings.json")
var settings: [String: Any] = [:] // Unreadable or invalid settings must stop here, never count as empty.
if let data = try? Data(contentsOf: settingsURL), let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { var settings = snapshot.object
settings = parsed
}
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container) // Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1437,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10), ("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10), ("SubagentStart", 10), ("SubagentStop", 10),
] ]
var hooks = settings["hooks"] as? [String: Any] ?? [:] // "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events { for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? [] var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("coucou") == true || ($0["command"] as? String)?.contains("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true ($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1455,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
]) ])
hooks["PreToolUse"] = preToolUse hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
} }
#endif #endif
@@ -2048,6 +2051,30 @@ def main():
if agent: if agent:
payload.setdefault('coucou_agent', agent) payload.setdefault('coucou_agent', agent)
# BroV crash watch: on session start / prompt, report the agent process (claude/codex)
# found by walking up the parent chain, so BroV can notice when it dies mid-turn.
try:
ev0 = payload.get('hook_event_name', '') or arg_event
if ev0 in ('SessionStart', 'UserPromptSubmit', 'session_start', 'user_prompt_submit'):
import subprocess
pid = os.getppid()
for _ in range(8):
out = subprocess.run(['ps', '-o', 'ppid=,comm=', '-p', str(pid)],
capture_output=True, text=True, timeout=1).stdout.strip()
if not out:
break
ppid_s, _, comm = out.partition(' ')
name = os.path.basename(comm.strip())
if name in ('claude', 'codex'):
payload['brov_agent_pid'] = pid
payload['brov_agent_kind'] = name
break
pid = int(ppid_s)
if pid <= 1:
break
except Exception:
pass
# Enrich with terminal context # Enrich with terminal context
env = os.environ env = os.environ
payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) payload.setdefault('term_program', env.get('TERM_PROGRAM', ''))
+137 -29
View File
@@ -32,11 +32,7 @@ struct IslandContainer: View {
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72) private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72)
private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34) private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
private var chatPromptHeight: CGFloat { private var chatPromptHeight: CGFloat { state.chatIslandHeight }
let base: CGFloat = 240
let perMsg: CGFloat = 40
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
}
/// Pixels the content must be pushed down to clear the concave ear transparent area. /// Pixels the content must be pushed down to clear the concave ear transparent area.
/// = 0 in expanded mode (no ears), = earRadius in compact/notch mode. /// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
@@ -101,11 +97,18 @@ struct IslandContainer: View {
Rectangle().frame(width: islandWidth, Rectangle().frame(width: islandWidth,
height: state.mode == .expanded ? 320 : islandHeight) height: state.mode == .expanded ? 320 : islandHeight)
} }
.opacity(uploadActive || greetingActive ? 0 : 1) .opacity(uploadActive || greetingActive || (state.mode == .expanded && state.view == .terminal) ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive) .animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
CountdownBar(state: state, islandW: islandWidth) CountdownBar(state: state, islandW: islandWidth)
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) {
ChatResizeHandle(state: state)
.position(x: islandWidth / 2, y: islandHeight - 7)
.transition(.opacity)
}
Group { Group {
if state.mode == .compact { if state.mode == .compact {
CompactMiniGrid(state: state) CompactMiniGrid(state: state)
@@ -127,7 +130,7 @@ struct IslandContainer: View {
let tr: CGFloat = 0 let tr: CGFloat = 0
withAnimation(anim) { withAnimation(anim) {
islandWidth = w islandWidth = w
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
cornerRadius = cr cornerRadius = cr
islandTopRadius = tr islandTopRadius = tr
} }
@@ -144,19 +147,31 @@ struct IslandContainer: View {
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
withAnimation(openSpring) { withAnimation(openSpring) {
islandWidth = w islandWidth = w
islandHeight = newView == .prompt ? chatPromptHeight : h islandHeight = newView.isTall ? chatPromptHeight : h
} }
} }
.onChange(of: state.chatHistory.count) { _, _ in .onChange(of: state.chatHistory.count) { _, _ in
guard state.mode == .expanded, state.view == .prompt else { return } guard state.mode == .expanded, state.view == .prompt else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight } withAnimation(openSpring) { islandHeight = chatPromptHeight }
} }
.onChange(of: state.chatDragHeight) { _, h in
guard state.mode == .expanded, state.view.isTall, let h else { return }
islandHeight = h // follow the pointer, no spring
}
.onChange(of: state.chatDetent) { _, _ in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatWidth) { _, w in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandWidth = w }
}
.onAppear { .onAppear {
let (w, h) = islandSize(mode: state.mode, view: state.view, let (w, h) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
islandWidth = w islandWidth = w
islandHeight = state.view == .prompt ? chatPromptHeight : h islandHeight = state.view.isTall ? chatPromptHeight : h
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
islandTopRadius = 0 islandTopRadius = 0
} }
@@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
let cy: CGFloat let cy: CGFloat
if let fixedY = layout.botY { if let fixedY = layout.botY {
cy = fixedY cy = fixedY
} else if view == .prompt {
// Tall chat: keep the character near the top instead of mid-screen.
let headerBottom: CGFloat = 42
let cardH: CGFloat = 84
cy = min(171, headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2)
} else { } else {
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42) // Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
let headerBottom: CGFloat = 42 let headerBottom: CGFloat = 42
@@ -376,6 +396,52 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
} }
} }
// MARK: - Chat resize handle
/// Thin pill at the bottom of the island. Drag down: the chat follows the pointer and
/// snaps to the nearest detent (fits messages, ⅓, ½, full screen). From the home view
/// it opens the chat first.
struct ChatResizeHandle: View {
@ObservedObject var state: AppState
@State private var startHeight: CGFloat? = nil
@State private var isHovered = false
var body: some View {
Capsule()
.fill(Color.white.opacity(isHovered || startHeight != nil ? 0.55 : 0.22))
.frame(width: isHovered ? 54 : 40, height: 4)
.frame(width: 160, height: 14)
.contentShape(Rectangle())
.onHover { h in
withAnimation(.easeOut(duration: 0.15)) { isHovered = h }
if h { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
.gesture(
DragGesture(minimumDistance: 2, coordinateSpace: .global)
.onChanged { g in
if startHeight == nil {
if state.view != .prompt {
state.chatDetent = 0
state.view = .prompt
}
startHeight = state.chatDetentHeight(state.chatDetent)
}
let h = (startHeight ?? 0) + g.translation.height
state.chatDragHeight = min(state.chatDetentHeight(3), max(state.chatDetentHeight(0), h))
}
.onEnded { g in
let target = (startHeight ?? 0) + g.predictedEndTranslation.height
let best = (0...3).min { abs(state.chatDetentHeight($0) - target) < abs(state.chatDetentHeight($1) - target) } ?? 0
state.chatDetent = best
state.chatDragHeight = nil
startHeight = nil
SoundEngine.shared.play("tick")
}
)
.help("Потяни, чтобы развернуть чат")
}
}
// MARK: - Countdown bar // MARK: - Countdown bar
struct CountdownBar: View { struct CountdownBar: View {
@@ -432,12 +498,14 @@ struct IslandContentView: View {
.animation(.easeInOut(duration: 0.2), value: state.view == .confused) .animation(.easeInOut(duration: 0.2), value: state.view == .confused)
ZStack { ZStack {
ForEach(IslandView.allCases, id: \.self) { v in // Only the visible screen is built (plus the chat, to keep its draft and
// scroll): hidden screens used to keep their animations and timers running.
ForEach(IslandView.allCases.filter { $0 == state.view || $0 == .prompt }, id: \.self) { v in
let active = state.view == v let active = state.view == v
// Views that fill available height instead of the fixed 98pt content frame: // Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so // chat (prompt) is always flexible; mail is flexible only when active so
// it doesn't push the ZStack taller when inactive. // it doesn't push the ZStack taller when inactive.
let isTall = v == .prompt || (v == .mail && active) let isTall = v == .prompt || ((v == .mail || v == .terminal) && active)
let anim: Animation = active let anim: Animation = active
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16) ? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
: .easeIn(duration: 0.16) : .easeIn(duration: 0.16)
@@ -448,6 +516,7 @@ struct IslandContentView: View {
.opacity(active ? 1 : 0) .opacity(active ? 1 : 0)
.scaleEffect(active ? 1 : 0.97) .scaleEffect(active ? 1 : 0.97)
.allowsHitTesting(active) .allowsHitTesting(active)
.transition(.opacity)
.animation(anim, value: state.view) .animation(anim, value: state.view)
} }
} }
@@ -464,20 +533,18 @@ struct IslandContentView: View {
struct IslandHeader: View { struct IslandHeader: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
private var leftTabs: [BroTab] { Array(state.tabs.prefix(3)) }
private var rightTabs: [BroTab] { Array(state.tabs.dropFirst(3)) }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Left: tab capsules // Left: tab capsules
// Tabs are split around the camera: three to its left, the rest to its right,
// with "+" right after the last tab.
HStack(spacing: 5) { HStack(spacing: 5) {
TabButton(icon: "house.fill", view: .overview, state: state) TabButton(icon: "house.fill", view: .overview, state: state)
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: { ForEach(leftTabs) { TabChip(tab: $0, state: state) }
#if !APPSTORE if rightTabs.isEmpty { NewTabButton(state: state) }
if state.promptContext == nil {
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
}
#endif
})
TabButton(icon: "plus", view: .upload, state: state)
} }
.padding(.leading, 14) .padding(.leading, 14)
@@ -485,12 +552,30 @@ struct IslandHeader: View {
// Right: plan pill (GitHub build, home view only) + action icons // Right: plan pill (GitHub build, home view only) + action icons
HStack(spacing: 8) { HStack(spacing: 8) {
#if !APPSTORE if !rightTabs.isEmpty {
if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled { HStack(spacing: 5) {
ClaudePlanHeaderPill(state: state) ForEach(rightTabs) { TabChip(tab: $0, state: state) }
NewTabButton(state: state)
} }
#endif }
// Plan limits live inside the Claude / ChatGPT pills (AppGridView):
// here they ran under the camera.
HStack(spacing: 14) { HStack(spacing: 14) {
// Fold everything back into the notch (and the chat to its normal size).
Button(action: {
state.chatDragHeight = nil
state.chatDetent = 0
NotificationCenter.default.post(name: .islandCollapse, object: "user")
}) {
Image(systemName: "chevron.up")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 22, height: 22)
.background(Circle().fill(Color.white.opacity(state.chatDetent > 0 && state.view == .prompt ? 0.10 : 0)))
}
.buttonStyle(.plain)
.help("Свернуть в чёлку")
Button(action: { Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
state.view = .settings state.view = .settings
@@ -502,12 +587,8 @@ struct IslandHeader: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: { state.soundEnabled.toggle() }) { // Networks (sound lives in Settings and on ⌃⌥M).
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") NetGlobeButton()
.font(.system(size: 14))
.foregroundColor(Color(hex: "#8E939C"))
}
.buttonStyle(.plain)
} }
} }
.padding(.trailing, 16) .padding(.trailing, 16)
@@ -564,8 +645,11 @@ struct ClaudePlanHeaderPill: View {
private var label: String { private var label: String {
guard let usage = state.claudePlanUsage, guard let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" } let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
guard let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() else {
return "Claude \(Int(pct.rounded()))%" return "Claude \(Int(pct.rounded()))%"
} }
return "Claude \(Int(pct.rounded()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))"
}
private var isActive: Bool { state.showingPlanDetail || isHovered } private var isActive: Bool { state.showingPlanDetail || isHovered }
@@ -608,6 +692,30 @@ struct ClaudePlanHeaderPill: View {
} }
#endif #endif
/// ChatGPT weekly limit, from the Codex logs: "ChatGPT 23% (сброс пт 14:00)".
struct ChatGPTLimitPill: View {
let window: PlanWindow
var body: some View {
let pct = ClaudePlanGauge.effectivePct(window)
let color = ClaudePlanGauge.color(for: pct)
let reset = window.resetsAt > Date() ? " (сброс \(PlanTime.resetMoment(window.resetsAt)))" : ""
HStack(spacing: 4) {
Circle().fill(Color(hex: color)).frame(width: 6, height: 6)
Text("ChatGPT \(Int(pct.rounded()))%\(reset)")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079"))
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color(hex: "#0E0F11")))
.overlay(Capsule().stroke(Color(hex: color).opacity(0.14), lineWidth: 1))
.help("Недельный лимит ChatGPT (по логам Codex)")
}
}
// MARK: - Compact mini mochi grid (2×2 to the right of the notch) // MARK: - Compact mini mochi grid (2×2 to the right of the notch)
struct CompactMiniGrid: View { struct CompactMiniGrid: View {
@@ -20,8 +20,14 @@ final class IslandStateMachine {
/// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island. /// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island.
var isHeldOpen: (() -> Bool)? var isHeldOpen: (() -> Bool)?
/// home → petit delay (seconds). Override for debug. /// home → petit delay (seconds), kept in sync with the auto-close preference.
var homeToPetitDelay: TimeInterval = 15 var homeToPetitDelay: TimeInterval = 15 {
didSet {
guard homeToPetitDelay != oldValue,
state == .home, homeCollapseWork != nil else { return }
scheduleHomeCollapse()
}
}
/// petit → hidden delay (seconds). Override for debug. /// petit → hidden delay (seconds). Override for debug.
var petitToHiddenDelay: TimeInterval = 60 var petitToHiddenDelay: TimeInterval = 60
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse. /// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
+59 -5
View File
@@ -27,6 +27,7 @@ struct IslandViewContent: View {
case .settings: SettingsIslandView(state: state) case .settings: SettingsIslandView(state: state)
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
case .wardrobe: WardrobeView(state: state) case .wardrobe: WardrobeView(state: state)
case .terminal: TerminalScreen(state: state)
} }
} }
} }
@@ -298,11 +299,25 @@ struct ApprovalView: View {
var approval: ApprovalInfo? { state.pendingApproval } var approval: ApprovalInfo? { state.pendingApproval }
var body: some View { var body: some View {
let danger = approval?.danger ?? []
ZStack { ZStack {
CardBackground(wash: .amber) CardBackground(wash: danger.isEmpty ? .amber : .red)
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
AgentWho(task: state.focusTask, label: "просит разрешение") AgentWho(task: state.focusTask, label: "просит разрешение")
CodeBlock(text: approval?.command ?? approval?.tool ?? "…") CodeBlock(text: approval?.command ?? approval?.tool ?? "…")
if !danger.isEmpty {
HStack(alignment: .top, spacing: 5) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10, weight: .bold))
Text("Опасно: " + danger.joined(separator: "; "))
.font(.system(size: 10.5, weight: .semibold))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(Color(hex: "#FF6B76"))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 6).fill(Color(hex: "#F4505E").opacity(0.14)))
}
HStack(spacing: 8) { HStack(spacing: 8) {
SecondaryButton("Запретить") { SecondaryButton("Запретить") {
HookServer.shared.sendApprovalDecision("deny") HookServer.shared.sendApprovalDecision("deny")
@@ -310,8 +325,9 @@ struct ApprovalView: View {
PrimaryButton("Разрешить") { PrimaryButton("Разрешить") {
HookServer.shared.sendApprovalDecision("allow") HookServer.shared.sendApprovalDecision("allow")
} }
// Codex rejects updatedPermissions, so "Always" is not offered // Codex rejects updatedPermissions, so "Always" is not offered.
if approval?.pillId != "agent_codex" { // Never for a dangerous command: one click must not save a destructive rule.
if approval?.pillId != "agent_codex" && danger.isEmpty {
SecondaryButton("Всегда") { SecondaryButton("Всегда") {
HookServer.shared.sendApprovalDecision("always") HookServer.shared.sendApprovalDecision("always")
} }
@@ -379,7 +395,7 @@ struct QuestionView: View {
Text(item.question) Text(item.question)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8")) .foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(2) .fixedSize(horizontal: false, vertical: true)
// Options (wrapping) or "Other…" compact inline row // Options (wrapping) or "Other…" compact inline row
if curOther { if curOther {
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -413,6 +429,43 @@ struct QuestionView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(Color(hex: "#6B7079")) .foregroundColor(Color(hex: "#6B7079"))
} }
} else if item.hasDescriptions {
// Options with descriptions: a vertical list, label + description underneath.
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
let isSelected = curSel.contains(opt.label)
Button {
if isMulti {
toggleSelection(qi: qi, label: opt.label)
} else {
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(opt.label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#F5F6F8"))
if !opt.description.isEmpty {
Text(opt.description)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#9AA0A8"))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
}
SecondaryButton("Другое…") {
if qi < showOther.count { showOther[qi] = true }
}
}
} else { } else {
ChipFlowLayout(spacing: 6) { ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
@@ -922,6 +975,7 @@ struct ChooseView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
PrimaryButton("Задать вопрос") { state.view = .prompt } PrimaryButton("Задать вопрос") { state.view = .prompt }
SecondaryButton("Отправить почтой") { state.view = .mail } SecondaryButton("Отправить почтой") { state.view = .mail }
SecondaryButton("На полку") { state.shelveLastDrop() }
} }
} }
.padding(.leading, 98) .padding(.leading, 98)
@@ -1244,7 +1298,7 @@ struct PromptView: View {
text = "" text = ""
state.chatHistory = [] state.chatHistory = []
ClaudeService.shared.clearConversation() ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
focused = true focused = true
} }
} }
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
private var frameTimer: Timer? private var frameTimer: Timer?
private var keyMonitor: Any? private var keyMonitor: Any?
private var viewSubscription: AnyCancellable? private var viewSubscription: AnyCancellable?
private var autoCloseSubscription: AnyCancellable?
// Confused recovery timer (set by handleDizzy) // Confused recovery timer (set by handleDizzy)
private var confusedRecoveryTimer: DispatchWorkItem? private var confusedRecoveryTimer: DispatchWorkItem?
@@ -56,9 +57,11 @@ final class IslandWindowController: NSWindowController {
let nW = geometry.width let nW = geometry.width
let nH = geometry.height let nH = geometry.height
let panelW: CGFloat = 720 // Tall, transparent panel: the chat can grow down to the full screen height
let panelH: CGFloat = 320 // and widen; clicks outside the island still pass through (pollFrame).
let sf = screen.frame let sf = screen.frame
let panelW: CGFloat = 1100
let panelH: CGFloat = sf.height
let panel = IslandPanel( let panel = IslandPanel(
contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH, contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH,
width: panelW, height: panelH), width: panelW, height: panelH),
@@ -161,6 +164,11 @@ final class IslandWindowController: NSWindowController {
// MARK: - FSM wiring // MARK: - FSM wiring
private func wireFSM() { private func wireFSM() {
// Apply the persisted auto-close preference immediately and keep live edits in sync.
autoCloseSubscription = state.$autoCloseInterval.sink { [weak self] delay in
self?.fsm.homeToPetitDelay = delay
}
fsm.onTransition = { [weak self] from, to in fsm.onTransition = { [weak self] from, to in
guard let self else { return } guard let self else { return }
switch to { switch to {
@@ -204,21 +212,44 @@ final class IslandWindowController: NSWindowController {
self?.fsm.greetComplete() self?.fsm.greetComplete()
} }
fsm.isHeldOpen = { AppState.shared.pendingApproval != nil } // Held open: a pending approval, or a chat/terminal tab on screen (typing with
// the pointer elsewhere must not fold it away; ⌃ in the header closes it).
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 // MARK: - 60 Hz polling loop
private func startPolling() { private var pollInterval: TimeInterval = 0
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
/// Mouse polling rate follows what's on screen: 60 Hz only while dragging (ghost,
/// chat handle), 30 Hz with the island open (the character draws at 30 fps),
/// 10 Hz when folded (hover still reacts within 0.1 s).
private var desiredPollInterval: TimeInterval {
if inAttachDrag || attachDragStart != nil || state.chatDragHeight != nil { return 1.0 / 60.0 }
return state.mode == .expanded ? 1.0 / 30.0 : 1.0 / 10.0
}
private func startPolling(interval: TimeInterval = 1.0 / 10.0) {
frameTimer?.invalidate()
pollInterval = interval
let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in self.pollFrame() } Task { @MainActor in self.pollFrame() }
} }
RunLoop.main.add(frameTimer!, forMode: .common) // Let macOS coalesce wakeups with other timers.
t.tolerance = interval * 0.2
RunLoop.main.add(t, forMode: .common)
frameTimer = t
} }
private func pollFrame() { private func pollFrame() {
guard let panel = window as? IslandPanel else { return } guard let panel = window as? IslandPanel else { return }
let want = desiredPollInterval
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
let mouse = NSEvent.mouseLocation let mouse = NSEvent.mouseLocation
@@ -232,10 +263,12 @@ final class IslandWindowController: NSWindowController {
// in the app window immediately below the menu bar. // in the app window immediately below the menu bar.
let hoverRect = !hasNotch && state.mode != .expanded let hoverRect = !hasNotch && state.mode != .expanded
? islandRect : islandRect.insetBy(dx: -6, dy: -6) ? islandRect : islandRect.insetBy(dx: -6, dy: -6)
let inIsland = hoverRect.contains(local) // While the chat handle is dragged, count the pointer as inside (no collapse).
let inIsland = hoverRect.contains(local) || state.chatDragHeight != nil
// Toggle click-through // Toggle click-through
let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil
|| state.chatDragHeight != nil
if panel.ignoresMouseEvents == shouldAcceptMouse { if panel.ignoresMouseEvents == shouldAcceptMouse {
panel.ignoresMouseEvents = !shouldAcceptMouse panel.ignoresMouseEvents = !shouldAcceptMouse
if shouldAcceptMouse, let cv = panel.contentView { if shouldAcceptMouse, let cv = panel.contentView {
@@ -360,9 +393,19 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now state.lastActivity = .now
} }
func collapse() { /// `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 } guard fsm.isHeldOpen?() != true else { return }
state.isPinned = false }
if !keepsApprovalPending { state.isPinned = false }
finishedPinTimer?.cancel() finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now). // Keep the FSM in step with what is on screen (home/coucou → petit now).
fsm.collapse() fsm.collapse()
@@ -382,9 +425,10 @@ final class IslandWindowController: NSWindowController {
switch action { switch action {
case .toggleIsland: case .toggleIsland:
if state.mode == .expanded { if state.mode == .expanded {
collapse() collapse(byUser: true)
} else { } else {
islandPanel.makeKey() islandPanel.makeKey()
fsm.openedExternally()
expand(to: defaultView()) expand(to: defaultView())
} }
@@ -395,6 +439,7 @@ final class IslandWindowController: NSWindowController {
case .goToAlert: case .goToAlert:
if state.pendingApproval != nil { if state.pendingApproval != nil {
islandPanel.makeKey() islandPanel.makeKey()
fsm.openedExternally()
expand(to: .approval) expand(to: .approval)
} else if state.pendingQuestion != nil { } else if state.pendingQuestion != nil {
islandPanel.makeKey() islandPanel.makeKey()
@@ -430,6 +475,9 @@ final class IslandWindowController: NSWindowController {
case .desktopToggle: case .desktopToggle:
DesktopMochiController.shared.flyOutOrHome() DesktopMochiController.shared.flyOutOrHome()
case .translate:
TranslatorRun.start(state: state)
case .wardrobeToggle: case .wardrobeToggle:
if state.mode == .expanded && state.view == .wardrobe { if state.mode == .expanded && state.view == .wardrobe {
collapse() collapse()
@@ -494,11 +542,14 @@ final class IslandWindowController: NSWindowController {
if cmd, let n = digitCodes[event.keyCode] { if cmd, let n = digitCodes[event.keyCode] {
switchToPill(number: n); return true switchToPill(number: n); return true
} }
// ⎋ Escape — belongs to the shell in a term.macOS tab (vim, claude, less…).
if event.keyCode == 53 && state.view == .terminal { return false }
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse // ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty { if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil) let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
if !consumed && state.mode == .expanded && !state.isPinned { let canCollapse = !state.isPinned || state.pendingApproval != nil
collapse() if !consumed && state.mode == .expanded && canCollapse {
collapse(allowPendingApproval: true)
} }
return true return true
} }
@@ -552,7 +603,7 @@ final class IslandWindowController: NSWindowController {
NSWorkspace.shared.open( NSWorkspace.shared.open(
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")) URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
} }
collapse() collapse(allowPendingApproval: true)
} }
private func performAttachFrontWindow() { private func performAttachFrontWindow() {
@@ -577,6 +628,8 @@ final class IslandWindowController: NSWindowController {
Task { @MainActor in Task { @MainActor in
guard let self = self else { return } guard let self = self else { return }
if event.keyCode == 53 { // Escape if event.keyCode == 53 { // Escape
// Escape typed in another app (Claude Code's own interrupt, an editor…)
// never folds a pending approval away: only Escape in the notch does.
if self.state.mode == .expanded && !self.state.isPinned { if self.state.mode == .expanded && !self.state.isPinned {
self.collapse() self.collapse()
} }
@@ -606,8 +659,9 @@ final class IslandWindowController: NSWindowController {
} }
// Collapse requests from views (OK button, etc.) // Collapse requests from views (OK button, etc.)
NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] _ in NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] note in
self?.collapse() let byUser = (note.object as? String) == "user"
MainActor.assumeIsolated { self?.collapse(byUser: byUser) }
} }
// Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand) // Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand)
@@ -1015,12 +1069,10 @@ final class IslandWindowController: NSWindowController {
let panelW = window?.frame.width ?? 720 let panelW = window?.frame.width ?? 720
let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view, let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: notchW, nh: notchH) progress: s.uploadProgress, nw: notchW, nh: notchH)
// Chat view resizes dynamically — must match IslandContainer.chatPromptHeight // Chat view resizes dynamically — AppState.chatIslandHeight
let islandH: CGFloat let islandH: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
let base: CGFloat = 240 islandH = s.chatIslandHeight
let perMsg: CGFloat = 40
islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
islandH = fixedH islandH = fixedH
} }
@@ -1082,10 +1134,8 @@ final class IslandPanel: NSPanel {
let (w, fixedH) = islandSize(mode: s.mode, view: s.view, let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: nw, nh: nh) progress: s.uploadProgress, nw: nw, nh: nh)
let h: CGFloat let h: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
let base: CGFloat = 240 h = s.chatIslandHeight
let perMsg: CGFloat = 40
h = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
h = fixedH h = fixedH
} }
@@ -1140,6 +1190,7 @@ extension Notification.Name {
// MARK: - islandSize (takes real notch dimensions) // MARK: - islandSize (takes real notch dimensions)
@MainActor
func islandSize(mode: IslandMode, view: IslandView, func islandSize(mode: IslandMode, view: IslandView,
progress: Double = 0, progress: Double = 0,
nw: CGFloat = IslandConst.notchWidth, nw: CGFloat = IslandConst.notchWidth,
@@ -1149,6 +1200,12 @@ func islandSize(mode: IslandMode, view: IslandView,
case .compact: return (nw + 160, nh) case .compact: return (nw + 160, nh)
case .expanded: case .expanded:
let layout = IslandConst.viewLayouts[view]! 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) return (IslandConst.expandedWidth, layout.height)
} }
} }
+159
View File
@@ -0,0 +1,159 @@
import SwiftUI
import AppKit
// MARK: - Memoji character (BroV)
//
// The character is 11 pre-rendered expressions (Resources/memoji/*.png, 256×256 RGBA).
// BotEngine still runs every animation (gaze, blink, squash, bounce, roll, emotes,
// particles); here we only pick the expression for the engine's state and draw the
// picture with the engine's transforms. Expression changes cross-fade in 220 ms;
// blinks swap neutral → halfblink → blink → halfblink → neutral instantly.
enum MemojiExpression: String, CaseIterable {
case neutral, blink, halfblink, happy, thinking, question, concern, wink, speaking, focused, surprised
}
enum MemojiAssets {
nonisolated(unsafe) private static var cache: [MemojiExpression: CGImage] = [:]
private static let lock = NSLock()
static func image(_ e: MemojiExpression) -> CGImage? {
lock.lock(); defer { lock.unlock() }
if let img = cache[e] { return img }
guard let url = Bundle.main.url(forResource: e.rawValue, withExtension: "png", subdirectory: "memoji"),
let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let img = CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
else { return nil }
cache[e] = img
return img
}
/// Expression for an agent state (states.json from the kit).
static func forState(_ s: BotState) -> MemojiExpression {
switch s {
case .idle: return .neutral
case .working: return .focused
case .thinking, .searching: return .thinking
case .approval, .question: return .question
case .error: return .concern
case .finished: return .happy
case .ratelimit, .dizzy: return .halfblink
case .sleeping: return .blink
}
}
/// Expression for an emote / transient eye override from the engine.
static func forEye(_ e: EyeShape) -> MemojiExpression? {
switch e {
case .happy, .heart, .star: return .happy
case .dot, .cup: return .surprised
case .wink: return .wink
case .line, .flat: return .concern
case .closed: return .blink
case .tired, .spiral: return .halfblink
case .pill, .wide: return nil
}
}
/// Faces whose eyes may be blinked by swapping in the neutral blink frames.
static let blinkable: Set<MemojiExpression> = [.neutral, .focused, .thinking, .question, .concern, .speaking]
}
/// Cross-fade bookkeeping, one per drawn character.
@MainActor
final class MemojiFader {
private(set) var current: MemojiExpression = .neutral
private(set) var previous: MemojiExpression?
private var changedAt: Double = 0
static let fade: Double = 0.22
/// Returns (previous, current, progress 0…1).
func step(to e: MemojiExpression, now: Double) -> (MemojiExpression?, MemojiExpression, Double) {
if e != current {
previous = current
current = e
changedAt = now
}
let p = min(1, (now - changedAt) / Self.fade)
if p >= 1 { previous = nil }
return (previous, current, p)
}
}
extension BotEngine {
/// The face to show right now, before blinking.
var memojiBase: MemojiExpression {
if let eye = eyeOverride, let e = MemojiAssets.forEye(eye) { return e }
if isDancing && dancingLevel > 0.15 { return .happy }
return MemojiAssets.forState(state)
}
/// Draws the Memoji with the engine's current pose. Call after `update(dt:)`.
func drawMemoji(context: GraphicsContext, size: CGSize, fader: MemojiFader, opacity: Double = 1) {
guard opacity > 0.01 else { return }
let W = size.width
let R = W * 0.3
let center = bodyCenter(size: size)
// Expression + blink frames.
var face = memojiBase
if MemojiAssets.blinkable.contains(face) {
if open < 0.35 { face = .blink } else if open < 0.8 { face = .halfblink }
}
let isBlinkFrame = face == .blink || face == .halfblink
let now = CACurrentMediaTime()
// Blinks don't cross-fade (they'd look like a dissolve, not a blink).
let (prev, cur, p) = isBlinkFrame && MemojiAssets.blinkable.contains(memojiBase)
? (nil, face, 1.0)
: fader.step(to: face, now: now)
var c = context
c.opacity = opacity
c.translateBy(x: center.x, y: center.y)
// Head turn: slide toward the gaze and narrow a little, like turning a sphere.
c.translateBy(x: yaw * R * 0.18, y: -pitch * R * 0.12)
c.rotate(by: .radians(tilt + roll))
c.scaleBy(x: sx * (1 - abs(yaw) * 0.10), y: sy * (1 - abs(pitch) * 0.06))
// Status tint as a soft glow behind the head.
if tint > 0.02 {
let glow = Color(red: col.0, green: col.1, blue: col.2)
let gr = R * 1.45
c.fill(Path(ellipseIn: CGRect(x: -gr, y: -gr * 0.9, width: gr * 2, height: gr * 2)),
with: .radialGradient(Gradient(colors: [glow.opacity(0.45 * Double(tint)), glow.opacity(0)]),
center: .zero, startRadius: 0, endRadius: gr))
}
let side = R * 2.9 * es.clamped(0.9, 1.3)
let rect = CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side)
if let prev, p < 1, let img = MemojiAssets.image(prev) {
var pc = c
pc.opacity = opacity * (1 - p)
pc.draw(Image(decorative: img, scale: 1), in: rect)
}
if let img = MemojiAssets.image(cur) {
var cc = c
cc.opacity = opacity * (prev == nil ? 1 : p)
cc.draw(Image(decorative: img, scale: 1), in: rect)
}
}
}
private extension CGFloat {
func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(hi, Swift.max(lo, self)) }
}
// MARK: - Plain CGContext drawing (greeting scene)
enum MemojiCG {
/// Draws an expression centred at `center` with height `side` into a y-down CGContext.
static func draw(_ e: MemojiExpression, in ctx: CGContext, center: CGPoint, side: CGFloat,
tilt: CGFloat = 0, sx: CGFloat = 1, sy: CGFloat = 1, alpha: CGFloat = 1) {
guard let img = MemojiAssets.image(e), alpha > 0.01 else { return }
ctx.saveGState()
ctx.setAlpha(alpha)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: tilt)
ctx.scaleBy(x: sx, y: -sy) // flip: CGImage draws y-up
ctx.draw(img, in: CGRect(x: -side / 2, y: -side * 0.48, width: side, height: side))
ctx.restoreGState()
}
}
+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)
}
}
+300
View File
@@ -0,0 +1,300 @@
import Foundation
import AppKit
// MARK: - Keeping BroV and the agents alive
//
// 1. BroVLaunchAgent — a user LaunchAgent restarts BroV if it crashes (KeepAlive on a
// non-zero exit) and starts it at login. Quitting from the menu (exit 0) stays quit.
// 2. AgentWatch — the hook relay reports the claude/codex process of each session. When
// that process disappears mid-turn (working, thinking, waiting for us) without a
// SessionEnd, the session is marked "упала"; one click (or the auto option) reopens
// it in Terminal with `claude --resume <id>` / `codex resume <id>`.
// 3. AgentAuth — `claude auth status` / `codex login status` every 5 minutes, plus auth
// errors seen in the chat or in hook messages, drive the "нужен вход" status.
// MARK: - LaunchAgent for BroV
enum BroVLaunchAgent {
static let label = "local.maksar.brov"
static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static var installedAppPath: String {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications/BroV.app").path
}
static let enabledKey = "brovKeepAlive"
static var isInstalled: Bool { FileManager.default.fileExists(atPath: plistURL.path) }
/// Started by launchd (the job's process)?
static var isManaged: Bool { ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == label }
static var plist: String {
let exe = installedAppPath + "/Contents/MacOS/BroV"
return """
<?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>
<key>Label</key><string>\(label)</string>
<key>ProgramArguments</key><array><string>\(exe)</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>10</integer>
<key>ProcessType</key><string>Interactive</string>
</dict>
</plist>
"""
}
/// Called at launch: installs on first run (setting on by default) and hands this
/// instance over to launchd so the running copy is the protected one.
@MainActor
static func ensure() {
let ud = UserDefaults.standard
if ud.object(forKey: enabledKey) == nil { ud.set(true, forKey: enabledKey) }
guard ud.bool(forKey: enabledKey) else { return }
// Only the installed copy is managed (not a build run from Xcode / DerivedData).
guard Bundle.main.bundlePath == installedAppPath else { return }
guard !isManaged else { return }
// Loop guard: if launchd's instance isn't recognised as managed, never hand over
// again within a minute (otherwise every start would quit and restart).
let last = ud.double(forKey: "brovHandoverAt")
let now = Date().timeIntervalSince1970
guard now - last > 60 else { return }
ud.set(now, forKey: "brovHandoverAt")
// Opened by hand (or first run): hand this instance over to launchd. No other
// BroV is running here (AppDelegate exits duplicates before calling us).
install(handOver: true)
}
@MainActor
static func install(handOver: Bool) {
try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? plist.write(to: plistURL, atomically: true, encoding: .utf8)
// Bootstrap after we quit, so launchd starts the only instance.
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; launchctl bootstrap gui/\(getuid()) '\(plistURL.path)'")
if handOver { NSApp.terminate(nil) }
}
@MainActor
static func uninstall() {
// bootout ends this very process; reopen it unmanaged afterwards.
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; rm -f '\(plistURL.path)'; open '\(installedAppPath)'")
}
static func isLoaded() -> Bool {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
p.arguments = ["print", "gui/\(getuid())/\(label)"]
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
try? p.run()
p.waitUntilExit()
return p.terminationStatus == 0
}
private static func detached(_ script: String) {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/sh")
p.arguments = ["-c", "(\(script)) >/dev/null 2>&1 &"]
try? p.run()
}
}
// MARK: - Watching agent processes
enum AgentKind: String, Codable {
case claude, codex
var title: String { self == .claude ? "Claude Code" : "Codex" }
}
struct CrashedSession: Identifiable, Equatable {
let id: String // session id
let kind: AgentKind
let cwd: String
let at: Date
var project: String { (cwd as NSString).lastPathComponent }
}
@MainActor
final class AgentWatch {
static let shared = AgentWatch()
static let autoKey = "agentAutoRestart"
private struct Watched {
let pid: pid_t
let kind: AgentKind
let cwd: String
let pillId: String
}
private var watched: [String: Watched] = [:] // by session id
private var restarts: [String: [Date]] = [:] // crash-loop guard
private var timer: Timer?
func start() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { @MainActor in AgentWatch.shared.check() }
}
}
/// From the hook server, for every event carrying `brov_agent_pid`.
func register(sessionId: String, pid: Int, kind: String, cwd: String, pillId: String) {
guard !sessionId.isEmpty, pid > 1, let k = AgentKind(rawValue: kind) else { return }
watched[sessionId] = Watched(pid: pid_t(pid), kind: k, cwd: cwd, pillId: pillId)
AppState.shared.crashedSessions.removeAll { $0.id == sessionId }
}
func ended(sessionId: String) {
watched.removeValue(forKey: sessionId)
}
private func check() {
let state = AppState.shared
for (sid, w) in watched where kill(w.pid, 0) != 0 && errno == ESRCH {
watched.removeValue(forKey: sid)
// Gone while idle = the user closed it. Gone mid-turn = it fell over.
let busy: Set<BotState> = [.working, .thinking, .searching, .approval, .question]
let taskState = state.tasks.first(where: { $0.id == w.pillId })?.state ?? .idle
guard busy.contains(taskState) else { continue }
let crash = CrashedSession(id: sid, kind: w.kind, cwd: w.cwd, at: Date())
state.crashedSessions.removeAll { $0.id == sid }
state.crashedSessions.append(crash)
state.updateTask(id: w.pillId, state: .error)
SoundEngine.shared.play("error")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
appendAppLog("watch.log", "\(w.kind.rawValue) \(sid.prefix(8)) died mid-turn in \(w.cwd)")
if UserDefaults.standard.bool(forKey: Self.autoKey) { revive(crash, auto: true) }
}
}
/// Reopens the session in Terminal. Auto mode allows at most 2 restarts per 10 minutes.
func revive(_ crash: CrashedSession, auto: Bool = false) {
let now = Date()
var times = (restarts[crash.id] ?? []).filter { now.timeIntervalSince($0) < 600 }
if auto && times.count >= 2 {
appendAppLog("watch.log", "auto-restart of \(crash.id.prefix(8)) skipped: crash loop")
return
}
times.append(now)
restarts[crash.id] = times
let cmd = crash.kind == .claude
? "claude --resume \(shellQuote(crash.id))"
: "codex resume \(shellQuote(crash.id))"
TerminalLauncher.run("cd \(shellQuote(crash.cwd)) && \(cmd)", name: "brov-resume")
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
}
func dismiss(_ crash: CrashedSession) {
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
}
}
// MARK: - Login status
@MainActor
final class AgentAuth {
static let shared = AgentAuth()
private var task: Task<Void, Never>?
func start() {
guard task == nil else { return }
task = Task {
while !Task.isCancelled {
await refresh()
try? await Task.sleep(for: .seconds(300))
}
}
}
func refresh() async {
async let c = Self.claudeLoggedIn()
async let x = Self.codexLoggedIn()
let (claude, codex) = await (c, x)
let s = AppState.shared
// nil = CLI not installed / unknown: keep what we had.
if let claude { s.claudeNeedsLogin = !claude }
if let codex { s.codexNeedsLogin = !codex }
}
/// Marks a CLI as logged out after an auth error seen elsewhere (chat, hook message).
func reportAuthError(_ kind: AgentKind) {
if kind == .claude { AppState.shared.claudeNeedsLogin = true }
else { AppState.shared.codexNeedsLogin = true }
}
static func looksLikeAuthError(_ text: String) -> Bool {
let t = text.lowercased()
return ["authenticate", "not logged in", "/login", "oauth", "401", "unauthorized",
"invalid api key", "please log in", "login required"].contains { t.contains($0) }
}
func login(_ kind: AgentKind) {
TerminalLauncher.run(kind == .claude ? "claude /login" : "codex login", name: "brov-login")
}
private nonisolated static func claudeLoggedIn() async -> Bool? {
guard let bin = ClaudeCodeCLI.resolveBinary(custom: "") else { return nil }
guard let out = await run(bin, ["auth", "status"]) else { return nil }
if let data = out.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ok = json["loggedIn"] as? Bool { return ok }
return nil
}
private nonisolated static func codexLoggedIn() async -> Bool? {
let fm = FileManager.default
let candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex",
fm.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/codex").path,
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"]
guard let bin = candidates.first(where: { fm.isExecutableFile(atPath: $0) }) else { return nil }
guard let out = await run(bin, ["login", "status"]) else { return nil }
let t = out.lowercased()
if t.contains("not logged in") { return false }
if t.contains("logged in") { return true }
return nil
}
/// Runs a CLI briefly (20 s cap), stdin closed, without the nested-session markers.
private nonisolated static func run(_ bin: String, _ args: [String]) async -> String? {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .utility).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: bin)
p.arguments = args
p.environment = ClaudeCodeCLI.childEnvironment()
p.standardInput = FileHandle.nullDevice
let pipe = Pipe()
p.standardOutput = pipe
p.standardError = pipe
guard (try? p.run()) != nil else { cont.resume(returning: nil); return }
let deadline = DispatchTime.now() + 20
DispatchQueue.global().asyncAfter(deadline: deadline) { if p.isRunning { p.terminate() } }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8))
}
}
}
}
// MARK: - Opening a command in Terminal
enum TerminalLauncher {
/// Opens Terminal running `command` in a login shell, via a .command file
/// (no Apple Events permission needed).
static func run(_ command: String, name: String) {
let dir = HookServer.supportDir.appendingPathComponent("commands")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("\(name)-\(Int(Date().timeIntervalSince1970)).command")
let script = "#!/bin/zsh -l\nunset CLAUDECODE\n\(command)\n"
try? script.write(to: url, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
NSWorkspace.shared.open(url)
}
}
func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
+27 -2
View File
@@ -228,6 +228,26 @@ struct SettingsView: View {
// MARK: - General section // MARK: - General section
@ViewBuilder private var generalSection: some View { @ViewBuilder private var generalSection: some View {
GroupBox("Надёжность") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Поднимать BroV, если он упал, и запускать при входе в систему", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: BroVLaunchAgent.enabledKey) },
set: { on in
UserDefaults.standard.set(on, forKey: BroVLaunchAgent.enabledKey)
if on { BroVLaunchAgent.install(handOver: !BroVLaunchAgent.isManaged) }
else if BroVLaunchAgent.isInstalled { BroVLaunchAgent.uninstall() }
}))
Text("Если выйти через меню, BroV не вернётся. Работает для копии в ~/Applications.")
.font(.system(size: 11)).foregroundColor(.secondary)
Toggle("Сразу поднимать упавшие сессии Claude Code и Codex", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: AgentWatch.autoKey) },
set: { UserDefaults.standard.set($0, forKey: AgentWatch.autoKey) }))
Text("Сессия считается упавшей, если её процесс пропал посреди работы. Без галочки в пилюле появится кнопка «поднять». Не чаще 2 раз за 10 минут.")
.font(.system(size: 11)).foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Звук") { GroupBox("Звук") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Toggle("Включить звуки", isOn: $state.soundEnabled) Toggle("Включить звуки", isOn: $state.soundEnabled)
@@ -617,10 +637,16 @@ struct SettingsView: View {
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
} }
} }
} }
Picker("Ширина чата", selection: $state.chatWidth) {
ForEach(AppState.chatWidthOptions, id: \.self) { w in
Text(w == 640 ? "640 (как главный экран)" : "\(Int(w))").tag(w)
}
}
.pickerStyle(.segmented)
Picker("Модель", selection: $state.claudeCodeModel) { Picker("Модель", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id) Text(m.label).tag(m.id)
@@ -630,7 +656,6 @@ struct SettingsView: View {
Text("Спрашивать в вырезе").tag("default") Text("Спрашивать в вырезе").tag("default")
Text("Принимать правки").tag("acceptEdits") Text("Принимать правки").tag("acceptEdits")
Text("Только план").tag("plan") Text("Только план").tag("plan")
Text("Без проверок (опасно)").tag("bypassPermissions")
} }
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath) TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
+4 -1
View File
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
case translate = "translate" // ⌥` — translate the selected text (BroV)
// MARK: UserDefaults keys // MARK: UserDefaults keys
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .muteToggle: return "Выключить / включить звук" case .muteToggle: return "Выключить / включить звук"
case .desktopToggle: return "BroV на рабочий стол / обратно" case .desktopToggle: return "BroV на рабочий стол / обратно"
case .wardrobeToggle: return "Открыть / закрыть гардероб" case .wardrobeToggle: return "Открыть / закрыть гардероб"
case .translate: return "Перевести выделенный текст"
} }
} }
@@ -104,6 +106,7 @@ enum ShortcutLogic {
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M .muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D .desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G .wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
.translate: ShortcutSpec(keyCode: 50, nsFlags: ShortcutSpec.optBit), // ⌥` (key left of 1)
] ]
// MARK: - Load / save (UserDefaults) // MARK: - Load / save (UserDefaults)
@@ -196,7 +199,7 @@ enum ShortcutLogic {
0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X", 0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X",
8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y", 8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y",
17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J", 17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J",
40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", 40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", 50:"`", 27:"-", 29:"0",
36:"↩", 51:"⌫", 53:"⎋", 36:"↩", 51:"⌫", 53:"⎋",
123:"←", 124:"→", 125:"↓", 126:"↑", 123:"←", 124:"→", 125:"↓", 126:"↑",
18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9", 18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9",
+347
View File
@@ -0,0 +1,347 @@
import SwiftUI
import AppKit
import SwiftTerm
// MARK: - Tabs: brov-chat dialogs and term.macOS terminals
//
// "+" in the header opens a new tab of either kind (6 at most). Each brov-chat tab has its
// own history and its own `claude` conversation; each term.macOS tab is a real login
// shell in a pseudo-terminal (SwiftTerm) that keeps running while you look elsewhere.
// Tabs are split around the camera: the first three to its left, the rest to its right.
enum TabKind: String {
case chat, terminal
var baseTitle: String { self == .chat ? "brov-chat" : "term.macOS" }
var symbol: String { self == .chat ? "bubble.left.fill" : "terminal.fill" }
var closeQuestion: String { self == .chat ? "Закрыть диалог с BroV?" : "Закрыть терминал?" }
}
struct BroTab: Identifiable, Equatable {
let id = UUID()
let kind: TabKind
let number: Int
var title: String { "\(kind.baseTitle) \(number)" }
}
extension IslandView {
/// Views that use the resizable tall panel (chat and terminal).
var isTall: Bool { self == .prompt || self == .terminal }
}
// MARK: - Per-tab chat state
@MainActor
final class ChatTabs {
static let shared = ChatTabs()
private var clis: [UUID: ClaudeCodeCLI] = [:]
private var histories: [UUID: [ChatMessage]] = [:]
func cli(for id: UUID?) -> ClaudeCodeCLI {
guard let id else { return ClaudeCodeCLI.shared }
if let c = clis[id] { return c }
let c = ClaudeCodeCLI()
clis[id] = c
return c
}
private var seeds: [UUID: String] = [:]
/// Context prepended to the first message sent from a tab (e.g. the picked colour).
func seed(_ id: UUID, _ text: String) { seeds[id] = text }
func takeSeed(_ id: UUID?) -> String? {
guard let id else { return nil }
return seeds.removeValue(forKey: id)
}
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
/// Updates a message of a tab, whether it's on screen or not.
func update(tab: UUID, message: UUID, _ change: (inout ChatMessage) -> Void) {
let state = AppState.shared
if state.activeTabId == tab {
if let i = state.chatHistory.firstIndex(where: { $0.id == message }) { change(&state.chatHistory[i]) }
} else if var h = histories[tab], let i = h.firstIndex(where: { $0.id == message }) {
change(&h[i]); histories[tab] = h
}
}
func append(tab: UUID, _ m: ChatMessage) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.append(m) } else { histories[tab, default: []].append(m) }
}
func remove(tab: UUID, message: UUID) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.removeAll { $0.id == message } }
else { histories[tab]?.removeAll { $0.id == message } }
}
func close(_ id: UUID) {
clis[id]?.cancel()
clis.removeValue(forKey: id)
histories.removeValue(forKey: id)
seeds.removeValue(forKey: id)
}
}
// MARK: - Terminals
@MainActor
final class TerminalStore: NSObject, LocalProcessTerminalViewDelegate {
static let shared = TerminalStore()
private var views: [UUID: LocalProcessTerminalView] = [:]
func view(for id: UUID) -> LocalProcessTerminalView {
if let v = views[id] { return v }
let v = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
v.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
v.nativeBackgroundColor = NSColor(calibratedRed: 0.055, green: 0.059, blue: 0.067, alpha: 1)
v.nativeForegroundColor = NSColor(calibratedWhite: 0.92, alpha: 1)
v.caretColor = NSColor(calibratedRed: 0.85, green: 0.47, blue: 0.34, alpha: 1)
v.processDelegate = self
var env = ProcessInfo.processInfo.environment
env.removeValue(forKey: "CLAUDECODE")
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
if env["LANG"] == nil { env["LANG"] = "ru_RU.UTF-8" }
env["TERM_PROGRAM"] = "BroV"
let shell = env["SHELL"] ?? "/bin/zsh"
let home = FileManager.default.homeDirectoryForCurrentUser.path
v.startProcess(executable: shell, args: ["-l"],
environment: env.map { "\($0.key)=\($0.value)" },
execName: "-" + (shell as NSString).lastPathComponent,
currentDirectory: home)
views[id] = v
return v
}
func close(_ id: UUID) {
if let v = views.removeValue(forKey: id) {
v.processDelegate = nil
v.terminate()
v.removeFromSuperview()
}
}
private func tabId(of source: TerminalView) -> UUID? {
views.first(where: { $0.value === source })?.key
}
// LocalProcessTerminalViewDelegate
nonisolated func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
nonisolated func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
nonisolated func processTerminated(source: TerminalView, exitCode: Int32?) {
// The shell exited (e.g. `exit`): close its tab.
DispatchQueue.main.async {
MainActor.assumeIsolated {
if let id = TerminalStore.shared.tabId(of: source) {
AppState.shared.closeTab(id)
}
}
}
}
}
/// Hosts the live terminal of a tab; switching tabs moves the right terminal view in.
struct TerminalTabView: NSViewRepresentable {
let tabId: UUID
func makeNSView(context: Context) -> NSView {
let container = NSView()
container.wantsLayer = true
return container
}
func updateNSView(_ container: NSView, context: Context) {
let term = TerminalStore.shared.view(for: tabId)
if term.superview !== container {
container.subviews.forEach { $0.removeFromSuperview() }
term.frame = container.bounds
term.autoresizingMask = [.width, .height]
container.addSubview(term)
}
DispatchQueue.main.async {
container.window?.makeFirstResponder(term)
}
}
}
struct TerminalScreen: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(red: 0.055, green: 0.059, blue: 0.067))
// Only while on screen: a hidden terminal must not grab the keyboard from the chat.
if state.view == .terminal, state.mode == .expanded,
let id = state.activeTabId, state.tabs.first(where: { $0.id == id })?.kind == .terminal {
TerminalTabView(tabId: id)
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
}
.padding(.top, 4)
.padding(.bottom, 12) // room for the resize handle
}
}
// MARK: - Header: tab chips and the "+" menu
/// One tab in the header. Hover 3 s → × appears; × asks before closing.
struct TabChip: View {
let tab: BroTab
@ObservedObject var state: AppState
@State private var hovered = false
@State private var showClose = false
@State private var confirm = false
@State private var hoverTask: Task<Void, Never>?
private var isActive: Bool {
state.activeTabId == tab.id && state.view.isTall && state.mode == .expanded
}
var body: some View {
HStack(spacing: 4) {
Image(systemName: tab.kind.symbol)
.font(.system(size: 10))
Text("\(tab.number)")
.font(.system(size: 10.5, weight: .semibold))
.monospacedDigit()
if showClose {
Button { confirm = true } label: {
Image(systemName: "xmark")
.font(.system(size: 7.5, weight: .bold))
.frame(width: 13, height: 13)
.background(Circle().fill(Color.white.opacity(0.14)))
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.foregroundColor(isActive ? Color(hex: "#F5F6F8") : Color(hex: hovered ? "#B0B5BE" : "#8E939C"))
.padding(.horizontal, 8)
.frame(height: 24)
.background(Capsule().fill(Color.white.opacity(isActive ? 0.12 : (hovered ? 0.06 : 0))))
.overlay(Capsule().stroke(tab.kind == .terminal ? Color(hex: "#34D399").opacity(isActive ? 0.45 : 0.15)
: Color(hex: "#D97757").opacity(isActive ? 0.45 : 0.15),
lineWidth: 1))
.contentShape(Capsule())
.onTapGesture { state.activateTab(tab.id) }
.help(tab.title)
.onHover { h in
withAnimation(.easeOut(duration: 0.12)) { hovered = h }
hoverTask?.cancel()
if h {
hoverTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
withAnimation(.spring(response: 0.25, dampingFraction: 0.75)) { showClose = true }
}
} else if !confirm {
withAnimation(.easeOut(duration: 0.15)) { showClose = false }
}
}
.popover(isPresented: $confirm, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 10) {
Text(tab.kind.closeQuestion)
.font(.system(size: 13, weight: .semibold))
Text(tab.kind == .terminal
? "«\(tab.title)» и всё, что в нём запущено, будет остановлено."
: "История «\(tab.title)» пропадёт из BroV.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Отмена") { confirm = false; showClose = false }
.keyboardShortcut(.cancelAction)
Button("Закрыть") {
confirm = false
state.closeTab(tab.id)
}
.keyboardShortcut(.defaultAction)
}
}
.padding(14)
.frame(width: 260)
.notchPopoverStyle()
}
}
}
/// "+" → brov-chat or term.macOS.
struct NewTabButton: View {
@ObservedObject var state: AppState
@State private var open = false
@State private var hovered = false
var body: some View {
Button { open.toggle() } label: {
Image(systemName: "plus")
.font(.system(size: 13))
.foregroundColor(Color(hex: hovered || open ? "#F5F6F8" : "#8E939C"))
.frame(width: 26, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
.popover(isPresented: $open, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
newTabRow(.chat, subtitle: "новый диалог с BroV (claude)")
newTabRow(.terminal, subtitle: "новое окно терминала macOS")
if state.tabs.count >= AppState.maxTabs {
Text("Открыто максимум вкладок (\(AppState.maxTabs)). Закрой одну: наведи на неё на 3 секунды.")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
.frame(width: 230, alignment: .leading)
.padding(.top, 4)
}
}
.padding(10)
.notchPopoverStyle()
}
}
private func newTabRow(_ kind: TabKind, subtitle: String) -> some View {
Button {
open = false
state.openTab(kind)
} label: {
HStack(spacing: 10) {
Image(systemName: kind.symbol)
.font(.system(size: 13))
.foregroundColor(kind == .terminal ? Color(hex: "#34D399") : Color(hex: "#D97757"))
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(kind.baseTitle).font(.system(size: 13, weight: .semibold, design: .monospaced))
Text(subtitle).font(.system(size: 11)).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(width: 240)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.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)
}
}
}
@@ -80,6 +80,21 @@ struct UploadCanvasView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.frame(width: 120, height: 26) .frame(width: 120, height: 26)
.position(x: 290 + 60, y: 113 + 13) // center = (350, 126) .position(x: 290 + 60, y: 113 + 13) // center = (350, 126)
// Tertiary: put on the shelf
Button {
withAnimation(.easeInOut(duration: 0.22)) { state.shelveLastDrop() }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
UploadSequenceEngine.shared.deactivate()
}
} label: {
Color.clear
.frame(width: 96, height: 26)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: 96, height: 26)
.position(x: 418 + 48, y: 113 + 13) // center = (466, 126)
} }
.opacity(f.chooseAlpha) .opacity(f.chooseAlpha)
.allowsHitTesting(f.chooseAlpha > 0.5) .allowsHitTesting(f.chooseAlpha > 0.5)
@@ -288,6 +303,14 @@ struct UploadCanvasView: View {
.font(.system(size:12.5, weight:.medium)) .font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#F1F2F4")) .foregroundColor(Color(hex:"#F1F2F4"))
cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center) cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center)
// Tertiary button: shelf
cCtx.fill(roundedRect(CGRect(x:418,y:113,width:96,height:26), r:13),
with: .color(Color.white.opacity(0.09)))
let btn3 = Text("На полку")
.font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#F1F2F4"))
cCtx.draw(btn3, at: CGPoint(x:466, y:126), anchor: .center)
} }
// MARK: - Mochi (superellipse body + eyes + mouth) // MARK: - Mochi (superellipse body + eyes + mouth)
@@ -302,6 +325,17 @@ struct UploadCanvasView: View {
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt))) c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy))) c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy)))
// BroV: the Memoji while it's a character; the mailbox fades in as it morphs.
if mc < 0.5, let img = MemojiAssets.image(f.eye == .content ? .happy : (f.eye == .cup ? .surprised : .neutral)) {
var mc2 = c
mc2.opacity = 1 - mc * 2
let side = CGFloat(R) * 2.9
mc2.draw(Image(decorative: img, scale: 1),
in: CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side))
}
guard mc > 0.02 else { return }
c.opacity = min(1, mc * 2)
let (bp, rx, ry) = usBodyPath(m: m, R: R) let (bp, rx, ry) = usBodyPath(m: m, R: R)
// ── Body gradient ────────────────────────────────────────── // ── Body gradient ──────────────────────────────────────────
+17 -1
View File
@@ -3,28 +3,44 @@ import CoreGraphics
// Hex color helpers shared by the Mac app and the iPhone app. // Hex color helpers shared by the Mac app and the iPhone app.
// Views ask for the same few dozen hex colours on every frame: parse each string once.
private enum HexCache {
nonisolated(unsafe) static var colors: [String: Color] = [:]
nonisolated(unsafe) static var cgColors: [String: CGColor] = [:]
static let lock = NSLock()
}
// MARK: - Color from hex string // MARK: - Color from hex string
extension Color { extension Color {
init(hex: String) { init(hex: String) {
HexCache.lock.lock()
if let c = HexCache.colors[hex] { HexCache.lock.unlock(); self = c; return }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0 let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255 let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255 let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255 let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b) self.init(red: r, green: g, blue: b)
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
} }
} }
// MARK: - CGColor from hex string // MARK: - CGColor from hex string
func cgColorFromHex(_ hex: String) -> CGColor? { func cgColorFromHex(_ hex: String) -> CGColor? {
HexCache.lock.lock()
if let c = HexCache.cgColors[hex] { HexCache.lock.unlock(); return c }
HexCache.lock.unlock()
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard let val = UInt64(h, radix: 16) else { return nil } guard let val = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((val >> 16) & 0xFF) / 255 let r = CGFloat((val >> 16) & 0xFF) / 255
let g = CGFloat((val >> 8) & 0xFF) / 255 let g = CGFloat((val >> 8) & 0xFF) / 255
let b = CGFloat( val & 0xFF) / 255 let b = CGFloat( val & 0xFF) / 255
return CGColor(red: r, green: g, blue: b, alpha: 1) let c = CGColor(red: r, green: g, blue: b, alpha: 1)
HexCache.lock.lock(); HexCache.cgColors[hex] = c; HexCache.lock.unlock()
return c
} }
extension CGColor { extension CGColor {
@@ -12,6 +12,7 @@ enum IslandView: String, CaseIterable {
case overview, empty, approval, question, error, finished case overview, empty, approval, question, error, finished
case confused, upload, uploading, choose, mail, prompt case confused, upload, uploading, choose, mail, prompt
case searching, result, note, settings, greeting, wardrobe case searching, result, note, settings, greeting, wardrobe
case terminal // BroV term.macOS tab
} }
// MARK: - Bot State // MARK: - Bot State
@@ -38,6 +39,8 @@ struct ApprovalInfo: Sendable {
var inputKey: String var inputKey: String
/// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex". /// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex".
var pillId: String var pillId: String
/// Why this request looks destructive (DangerCheck), empty when nothing matched.
var danger: [String] = []
} }
// MARK: - Pill badge (shown on pill edge when non-focused task has an alert) // MARK: - Pill badge (shown on pill edge when non-focused task has an alert)
@@ -176,9 +179,9 @@ enum IslandConst {
static let viewLayouts: [IslandView: ViewLayout] = [ static let viewLayouts: [IslandView: ViewLayout] = [
// Home is the reference: height 150 // Home is the reference: height 150
.overview: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .pills), .overview: ViewLayout(height: 160, botX: 58, botY: nil, botDiameter: 58, agentMode: .pills),
// All non-chat views match home height (150) — law // All non-chat views match home height (150) — law
.empty: ViewLayout(height: 160, botX: 70, botY: nil, botDiameter: 62, agentMode: .none), .empty: ViewLayout(height: 160, botX: 58, botY: nil, botDiameter: 58, agentMode: .none),
.approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column),
@@ -196,6 +199,8 @@ enum IslandConst {
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed // Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none), .greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
.wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none), .wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none),
// Terminal tab: full-width shell, the character is hidden.
.terminal: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .none),
] ]
// Project colors — keyed by lowercase display name or slug // Project colors — keyed by lowercase display name or slug
+23 -6
View File
@@ -6,7 +6,8 @@ options:
xcodeVersion: "27.0" xcodeVersion: "27.0"
createIntermediateGroups: true createIntermediateGroups: true
# BroV — personal fork of Coucou (MIT). Mac app only, local unsigned builds. # BroV — personal fork of Coucou (MIT). Mac app only, signed with the personal
# Apple Development certificate (stable identity: TCC grants survive rebuilds).
configs: configs:
Debug: debug Debug: debug
Release: release Release: release
@@ -14,14 +15,25 @@ configs:
settings: settings:
base: base:
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: NO # Hardened runtime, no get-task-allow: other processes can't inject code into BroV
# and borrow its Accessibility / Apple Events permissions.
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
CODE_SIGN_ENTITLEMENTS: Resources/BroV.entitlements
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete" OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
# Ad-hoc signature: runs locally, no Apple Developer account needed. # Personal Team certificate (free Apple ID in Xcode). With ad-hoc signing macOS
# forgot Accessibility after every build; a real identity keeps it.
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGNING_REQUIRED: NO CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: V3NLZK45Q4
# The only third-party dependency: a real terminal emulator for the term.macOS tabs.
packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm
from: 1.20.0
schemes: schemes:
BroV: BroV:
@@ -45,6 +57,11 @@ targets:
- path: Resources/sounds - path: Resources/sounds
buildPhase: resources buildPhase: resources
type: folder type: folder
- path: Resources/memoji
buildPhase: resources
type: folder
dependencies:
- package: SwiftTerm
info: info:
path: Resources/Info.plist path: Resources/Info.plist
properties: properties:
+17 -4
View File
@@ -5,12 +5,25 @@ set -e
cd "$(dirname "$0")/../NotchBuddy" cd "$(dirname "$0")/../NotchBuddy"
D="$HOME/Library/Developer/Xcode/DerivedData/BroV" D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
xcodegen -q xcodegen -q
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build | grep -E 'error:|BUILD (SUCCEEDED|FAILED)' || true LOG=$(mktemp)
APP="$D/Build/Products/${1:-Debug}/BroV.app" # Release by default: optimised code (Debug is several times heavier on CPU).
[ -d "$APP" ] || { echo "build failed"; exit 1; } 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/$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"
launchctl bootout "gui/$(id -u)/local.maksar.brov" 2>/dev/null || true
pkill -x BroV 2>/dev/null || true pkill -x BroV 2>/dev/null || true
sleep 0.5
mkdir -p "$HOME/Applications" mkdir -p "$HOME/Applications"
rm -rf "$HOME/Applications/BroV.app" rm -rf "$HOME/Applications/BroV.app"
cp -R "$APP" "$HOME/Applications/BroV.app" cp -R "$APP" "$HOME/Applications/BroV.app"
open "$HOME/Applications/BroV.app" if [ -f "$PLIST" ]; then
launchctl bootstrap "gui/$(id -u)" "$PLIST"
else
open "$HOME/Applications/BroV.app"
fi
echo "BroV installed to ~/Applications/BroV.app and launched" echo "BroV installed to ~/Applications/BroV.app and launched"
+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