Compare commits
21 Commits
00dd73e33c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e02402555 | |||
| a7a0195d34 | |||
| 0d5c68f83b | |||
| efb3bac8fc | |||
| 52e1e8288b | |||
| dc47247340 | |||
| 91878fa2e3 | |||
| 5a119d01a1 | |||
| 14114d1a58 | |||
| e11d06c73e | |||
| 9570dc190f | |||
| d8a64ab5b5 | |||
| a92bf02477 | |||
| 56367ef9cd | |||
| 0d40b78b26 | |||
| 37c01f991a | |||
| 3857776111 | |||
| ae394370bf | |||
| a4d5032959 | |||
| fbf90fd4fb | |||
| 8a8dd98e06 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', ''))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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."
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||