Compare commits
11 Commits
d8a64ab5b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e02402555 | |||
| a7a0195d34 | |||
| 0d5c68f83b | |||
| efb3bac8fc | |||
| 52e1e8288b | |||
| dc47247340 | |||
| 91878fa2e3 | |||
| 5a119d01a1 | |||
| 14114d1a58 | |||
| e11d06c73e | |||
| 9570dc190f |
@@ -9,3 +9,12 @@ DerivedData/
|
||||
|
||||
# Local only
|
||||
.claude/
|
||||
|
||||
# Secrets never live in the repo (root core folder, ~/.brov-secrets)
|
||||
*.conf
|
||||
guide-*.md
|
||||
brov-secrets/
|
||||
*.vpnkey
|
||||
api.secret
|
||||
netcore.json
|
||||
config.yaml
|
||||
|
||||
@@ -19,10 +19,17 @@ DerivedData must stay outside ~/Documents (iCloud adds Finder attributes and cod
|
||||
|
||||
## Rules
|
||||
- Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV.
|
||||
- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`).
|
||||
- Secrets live in the Keychain, never on disk or in git.
|
||||
- Swift 6, SwiftUI + AppKit. One dependency: SwiftTerm (term.macOS tabs). The character is 11 Memoji images (`Resources/memoji`) moved by `BotEngine` at 30 fps.
|
||||
- Secrets live in the Keychain or the root-only network core folder, never in git.
|
||||
- Never block Claude Code: if the app doesn't answer, the hook exits immediately.
|
||||
- Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
|
||||
- Never approve a Claude Code permission without an explicit click.
|
||||
- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
|
||||
- Pill IDs are stable contract values: never rename an existing pill ID.
|
||||
|
||||
## Network core (globe 🌐 in the header)
|
||||
- mihomo runs as root: LaunchDaemon `local.maksar.brov.netd`, binary + config + keys + API secret in `/Library/Application Support/BroV/` (root, 0700). Nothing user-writable is read by root.
|
||||
- BroV never sees the config or the API secret: it talks to the narrow root helper `scripts/netcore/netctl.py` (LaunchDaemon `local.maksar.brov.netctl`, socket `/var/run/brov-netctl.sock`, only the installing user) — commands: state, select, delay, tun, add_key, remove_key.
|
||||
- `scripts/netcore/gen.py` builds the config (Amnezia keys → provider `keys/amnezia.yaml`, WireGuard clients, VLESS subscription). Install/update: `sudo sh scripts/netcore/install.sh` (verifies the mihomo SHA256, migrates keys). Never commit keys, configs or the guide (`~/.brov-secrets`).
|
||||
- Reloading the whole core config drops every connection (including this chat); group switches and provider reloads don't.
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
exit(0)
|
||||
}
|
||||
BroVLaunchAgent.ensure()
|
||||
Self.pruneInbox()
|
||||
setupMenuBarItem()
|
||||
CodexUsageMonitor.shared.start()
|
||||
AgentWatch.shared.start()
|
||||
@@ -33,6 +34,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
#endif
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
openIsland()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Copies of dropped files (HookServer.supportDir/inbox) are kept 7 days, then removed.
|
||||
private static func pruneInbox() {
|
||||
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
|
||||
let fm = FileManager.default
|
||||
guard let files = try? fm.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
|
||||
let cutoff = Date().addingTimeInterval(-7 * 86400)
|
||||
for f in files {
|
||||
let d = (try? f.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantFuture
|
||||
if d < cutoff { try? fm.removeItem(at: f) }
|
||||
}
|
||||
try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inbox.path)
|
||||
}
|
||||
|
||||
// MARK: - Menu bar
|
||||
|
||||
private func setupMenuBarItem() {
|
||||
@@ -56,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func openIsland() {
|
||||
islandController?.fsm.openedExternally()
|
||||
islandController?.expand(to: .overview)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ enum DesktopApps {
|
||||
|
||||
struct AppGridView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var showSystem = false
|
||||
|
||||
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
|
||||
private var claudeLimit: PillLimit? {
|
||||
@@ -85,7 +86,7 @@ struct AppGridView: View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
ShelfPill()
|
||||
EmptyPill()
|
||||
FuncPill(state: state, showSystem: $showSystem)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
|
||||
@@ -96,6 +97,16 @@ struct AppGridView: View {
|
||||
.padding(.trailing, 20)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
if let t = state.translation {
|
||||
TranslationCard(state: state, result: t)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSystem {
|
||||
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
#if !APPSTORE
|
||||
if state.showingPlanDetail {
|
||||
CardBackground(wash: nil)
|
||||
|
||||
@@ -378,7 +378,9 @@ final class AppState: ObservableObject {
|
||||
@Published var pendingApproval: ApprovalInfo? = nil
|
||||
|
||||
// Pending AskUserQuestion from Claude Code hook
|
||||
@Published var pendingQuestion: AskQuestion? = nil
|
||||
@Published var pendingQuestion: AskQuestion? = nil {
|
||||
didSet { QuestionLayout.height = pendingQuestion?.estimatedIslandHeight }
|
||||
}
|
||||
|
||||
// Per-pill flat list of FileDiffs, in order of reception.
|
||||
// Not @Published — steps[] changes already trigger redraws.
|
||||
@@ -411,11 +413,13 @@ final class AppState: ObservableObject {
|
||||
|
||||
private func resetSessionDiffTimer(for pillId: String) {
|
||||
sessionDiffTimers[pillId]?.cancel()
|
||||
// The closure is MainActor-isolated (AppState is @MainActor): it must run on the main
|
||||
// queue. Scheduled on a global queue, Swift 6's isolation check traps and the app quits.
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) }
|
||||
self?.clearSessionDiffs(for: pillId)
|
||||
}
|
||||
sessionDiffTimers[pillId] = work
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
|
||||
}
|
||||
|
||||
#if !APPSTORE
|
||||
@@ -493,6 +497,9 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Func → Переводчик result shown on the home view.
|
||||
@Published var translation: TranslationResult? = nil
|
||||
|
||||
// MARK: Agent health (BroV)
|
||||
@Published var crashedSessions: [CrashedSession] = []
|
||||
@Published var claudeNeedsLogin = false
|
||||
|
||||
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
|
||||
var multiSelect: Bool
|
||||
}
|
||||
|
||||
/// Island height of the pending question, readable from the nonisolated `islandSize`. Written on the main actor only.
|
||||
enum QuestionLayout {
|
||||
nonisolated(unsafe) static var height: CGFloat?
|
||||
}
|
||||
|
||||
extension AskQuestionItem {
|
||||
/// True when at least one option carries a description: the card then lists options vertically.
|
||||
var hasDescriptions: Bool { options.contains { !$0.description.isEmpty } }
|
||||
}
|
||||
|
||||
struct AskQuestion: Equatable {
|
||||
var questions: [AskQuestionItem] // 1–4 questions
|
||||
|
||||
/// Island height that fits the tallest question without truncation (rough estimate, text wraps at ~500 pt).
|
||||
var estimatedIslandHeight: CGFloat {
|
||||
func lines(_ text: String, charWidth: CGFloat) -> CGFloat {
|
||||
max(1, (CGFloat(text.count) * charWidth / 500).rounded(.up))
|
||||
}
|
||||
let tallest = questions.map { item -> CGFloat in
|
||||
var h: CGFloat = 20 + lines(item.question, charWidth: 7) * 17 + 64
|
||||
if !item.header.isEmpty { h += 14 }
|
||||
if item.hasDescriptions {
|
||||
for opt in item.options {
|
||||
h += 34 + (opt.description.isEmpty ? 0 : lines(opt.description, charWidth: 6.4) * 14)
|
||||
}
|
||||
h += 40 // "Другое…" row
|
||||
} else {
|
||||
h += item.options.count >= 3 ? 74 : 40
|
||||
}
|
||||
if item.multiSelect { h += 34 }
|
||||
return h
|
||||
}.max() ?? 160
|
||||
return min(max(tallest, 160), 560)
|
||||
}
|
||||
|
||||
// MARK: - Parse from tool_input dict
|
||||
// Returns nil if the payload is malformed (fallback → Allow/Deny card).
|
||||
static func parse(toolInput: [String: Any]) -> AskQuestion? {
|
||||
|
||||
@@ -14,7 +14,8 @@ struct BotCanvasView: View {
|
||||
@State private var fader = MemojiFader()
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
|
||||
// 30 fps is plenty for the character (the display would drive 120 on ProMotion).
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: state.mode == .hidden)) { timeline in
|
||||
Canvas { context, size in
|
||||
let now = timeline.date.timeIntervalSinceReferenceDate
|
||||
let dtRaw = min(0.05, now - engine.lastTime)
|
||||
@@ -191,7 +192,7 @@ struct MiniBotCanvasView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||
Canvas { context, size in
|
||||
let now = timeline.date.timeIntervalSinceReferenceDate
|
||||
let dt = min(0.05, now - engine.lastTime)
|
||||
|
||||
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
|
||||
"--append-system-prompt", Self.notchPrompt]
|
||||
if let sessionID { args += ["--resume", sessionID] }
|
||||
if !model.isEmpty { args += ["--model", model] }
|
||||
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
|
||||
// Never bypass: the chat gets untrusted input (dropped files, window titles, URLs).
|
||||
let allowedModes: Set<String> = ["acceptEdits", "plan"]
|
||||
if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: binary)
|
||||
|
||||
@@ -275,6 +275,7 @@ final class ClaudeService {
|
||||
let tab = state.activeTabId
|
||||
let cli = ChatTabs.shared.cli(for: tab)
|
||||
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
|
||||
let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil
|
||||
var prompt = query
|
||||
// Context goes with the first message of a conversation, like the API chat.
|
||||
if cli.sessionID == nil, let context {
|
||||
@@ -293,6 +294,8 @@ final class ClaudeService {
|
||||
}
|
||||
}
|
||||
|
||||
if let seed { prompt = seed + "\n\n" + prompt }
|
||||
|
||||
let placeholder = ChatMessage(role: .assistant, content: "")
|
||||
let msgId = placeholder.id
|
||||
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ClaudeSettingsFile
|
||||
// Reads and rewrites a settings file BroV does not own (~/.claude/settings.json).
|
||||
// Never start from an empty object when the file is there but unusable, always
|
||||
// take a backup, and only ever write over the exact bytes the user was shown.
|
||||
|
||||
enum ClaudeSettingsFile {
|
||||
|
||||
enum Failure: LocalizedError, Equatable {
|
||||
case unreadable(String)
|
||||
case invalid(String)
|
||||
case changed(String)
|
||||
case backupFailed(String)
|
||||
case writeFailed(String)
|
||||
case unexpectedHooks(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unreadable(let name):
|
||||
return "Не удалось прочитать \(name) — BroV его не трогал."
|
||||
case .invalid(let name):
|
||||
return "\(name) — некорректный JSON, BroV его не трогал."
|
||||
case .changed(let name):
|
||||
return "\(name) изменился после предпросмотра. Ничего не записано — откройте предпросмотр заново."
|
||||
case .backupFailed(let name):
|
||||
return "Не удалось сделать резервную копию \(name). Ничего не записано."
|
||||
case .writeFailed(let name):
|
||||
return "Не удалось записать \(name). Оригинал не тронут."
|
||||
case .unexpectedHooks(let name):
|
||||
return "\(name): \"hooks\" имеет неожиданный тип — BroV его не трогал."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "hooks" object of a settings file. Absent → empty.
|
||||
/// Present but not an object → throws, so it is never replaced.
|
||||
static func hooks(in settings: [String: Any], name: String) throws -> [String: Any] {
|
||||
guard let value = settings["hooks"] else { return [:] }
|
||||
guard let hooks = value as? [String: Any] else { throw Failure.unexpectedHooks(name) }
|
||||
return hooks
|
||||
}
|
||||
|
||||
/// The hook groups already declared for one event. Absent → empty.
|
||||
/// Present but not a list of objects → throws, so it is never replaced.
|
||||
static func hookGroups(in hooks: [String: Any], event: String, name: String) throws -> [[String: Any]] {
|
||||
guard let value = hooks[event] else { return [] }
|
||||
guard let groups = value as? [[String: Any]] else { throw Failure.unexpectedHooks(name) }
|
||||
return groups
|
||||
}
|
||||
|
||||
/// The settings object and the bytes it was parsed from.
|
||||
/// Absent file → empty object and nil bytes. An empty file is an empty object.
|
||||
/// Present but unreadable, or anything that is not a JSON object → throws:
|
||||
/// not knowing what is in there is not the same as empty.
|
||||
static func read(at url: URL) throws -> (object: [String: Any], bytes: Data?) {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return ([:], nil) }
|
||||
let name = url.lastPathComponent
|
||||
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
|
||||
if bytes.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D }) {
|
||||
return ([:], bytes)
|
||||
}
|
||||
guard let object = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] else {
|
||||
throw Failure.invalid(name)
|
||||
}
|
||||
return (object, bytes)
|
||||
}
|
||||
|
||||
/// Replaces the file with `data`, after a dated backup.
|
||||
///
|
||||
/// `original` is what `read` returned when `data` was computed. If the file
|
||||
/// holds anything else by now — another tool, the user's own editor — nothing
|
||||
/// is written. Returns the backup, or nil when there was no file to back up.
|
||||
@discardableResult
|
||||
static func write(_ data: Data, to url: URL, expecting original: Data?) throws -> URL? {
|
||||
let fm = FileManager.default
|
||||
let name = url.lastPathComponent
|
||||
let exists = fm.fileExists(atPath: url.path)
|
||||
|
||||
var current: Data? = nil
|
||||
if exists {
|
||||
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
|
||||
current = bytes
|
||||
}
|
||||
guard current == original else { throw Failure.changed(name) }
|
||||
|
||||
// A dotfiles setup often makes settings.json a symlink: write to the file
|
||||
// it points at, so the link survives the rename below.
|
||||
let target = url.resolvingSymlinksInPath()
|
||||
|
||||
var backupURL: URL? = nil
|
||||
// settings.json can hold API keys in its `env` block: a new file is ours
|
||||
// only, and a rewrite keeps the permissions the original had.
|
||||
var mode = 0o600
|
||||
if exists {
|
||||
let backup = freeBackupURL(for: url)
|
||||
do { try fm.copyItem(at: target, to: backup) } catch { throw Failure.backupFailed(name) }
|
||||
backupURL = backup
|
||||
if let found = (try? fm.attributesOfItem(atPath: target.path))?[.posixPermissions] as? NSNumber {
|
||||
mode = found.intValue & 0o777
|
||||
}
|
||||
} else {
|
||||
try? fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// Written beside the target and renamed over it: a crash or a full disk
|
||||
// leaves the original intact rather than half a file.
|
||||
let temp = target.deletingLastPathComponent()
|
||||
.appendingPathComponent("\(target.lastPathComponent).brov-\(ProcessInfo.processInfo.processIdentifier)")
|
||||
try? fm.removeItem(at: temp)
|
||||
guard fm.createFile(atPath: temp.path, contents: data,
|
||||
attributes: [.posixPermissions: NSNumber(value: 0o600)]) else {
|
||||
throw Failure.writeFailed(name)
|
||||
}
|
||||
do {
|
||||
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: temp.path)
|
||||
} catch {
|
||||
try? fm.removeItem(at: temp)
|
||||
throw Failure.writeFailed(name)
|
||||
}
|
||||
guard rename(temp.path, target.path) == 0 else {
|
||||
try? fm.removeItem(at: temp)
|
||||
throw Failure.writeFailed(name)
|
||||
}
|
||||
return backupURL
|
||||
}
|
||||
|
||||
/// Down to the second, and never an existing name: installing then
|
||||
/// uninstalling in the same second must not lose the first backup.
|
||||
private static func freeBackupURL(for url: URL) -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
||||
let base = "\(url.lastPathComponent).bak-\(formatter.string(from: Date()))"
|
||||
let dir = url.deletingLastPathComponent()
|
||||
var candidate = dir.appendingPathComponent(base)
|
||||
var n = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
candidate = dir.appendingPathComponent("\(base)-\(n)")
|
||||
n += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,25 @@ enum DangerCheck {
|
||||
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
|
||||
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
|
||||
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
|
||||
(#"/(NotchBuddy|BroV)/netcore(/|$)|\.vpnkey$"#, "меняет сетевое ядро BroV или его ключи"),
|
||||
(#"/Library/Launch(Agents|Daemons)/"#, "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
|
||||
(#"/NotchBuddy/nb-hook"#, "меняет хуки BroV"),
|
||||
]
|
||||
|
||||
/// The same sensitive places when a shell command writes, moves or deletes there.
|
||||
static let shellPathRules: [Rule] = [
|
||||
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\bchmod\b|\bchown\b|sed\s+-i|\bpython3?\b|\bperl\b).*(NotchBuddy/netcore|BroV/netcore|\.vpnkey)"#,
|
||||
reason: "меняет сетевое ядро BroV или его ключи"),
|
||||
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\blaunchctl\b|\bplutil\b).*Library/Launch(Agents|Daemons)"#,
|
||||
reason: "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
|
||||
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|sed\s+-i).*(\.claude/settings(\.local)?\.json|NotchBuddy/nb-hook)"#,
|
||||
reason: "меняет настройки или хуки Claude Code"),
|
||||
]
|
||||
|
||||
static func reasons(tool: String, input: [String: Any]) -> [String] {
|
||||
var out: [String] = []
|
||||
if let command = input["command"] as? String {
|
||||
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
|
||||
for rule in shellRules + shellPathRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
|
||||
out.append(rule.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1156,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
|
||||
// MARK: - Claude Code settings.json hook installer
|
||||
|
||||
private var _pendingHooksData: Data?
|
||||
/// The bytes of settings.json the pending preview was computed from.
|
||||
private var _pendingHooksOriginal: Data?
|
||||
|
||||
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
|
||||
func previewClaudeHooks() throws -> String {
|
||||
let data = try buildHooksData()
|
||||
let (data, original) = try buildHooksData()
|
||||
_pendingHooksData = data
|
||||
_pendingHooksOriginal = original
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
/// Writes the hooks to disk (call after user confirms preview).
|
||||
/// Refused if settings.json changed since the preview, or cannot be backed up.
|
||||
func writeClaudeHooks() throws {
|
||||
guard let data = _pendingHooksData else { return }
|
||||
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".claude/settings.json")
|
||||
// Backup first
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd-HHmm"
|
||||
let stamp = formatter.string(from: Date())
|
||||
let backupURL = settingsURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("settings.json.bak-\(stamp)")
|
||||
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
|
||||
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: settingsURL, options: .atomic)
|
||||
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
|
||||
_pendingHooksData = nil
|
||||
_pendingHooksOriginal = nil
|
||||
}
|
||||
|
||||
private func buildHooksData() throws -> Data {
|
||||
private func buildHooksData() throws -> (data: Data, original: Data?) {
|
||||
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".claude/settings.json")
|
||||
var settings: [String: Any] = [:]
|
||||
if let data = try? Data(contentsOf: settingsURL),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
settings = parsed
|
||||
}
|
||||
// Unreadable or invalid settings must stop here, never count as empty.
|
||||
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
|
||||
var settings = snapshot.object
|
||||
let hookPath = Self.hookScriptPath
|
||||
#if APPSTORE
|
||||
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
|
||||
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
|
||||
("Stop", 10), ("StopFailure", 10),
|
||||
("SubagentStart", 10), ("SubagentStop", 10),
|
||||
]
|
||||
var hooks = settings["hooks"] as? [String: Any] ?? [:]
|
||||
// "hooks" in a shape we do not know is refused, never replaced.
|
||||
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
|
||||
for (event, timeout) in events {
|
||||
var existing = hooks[event] as? [[String: Any]] ?? []
|
||||
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
|
||||
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
|
||||
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
|
||||
hooks[event] = existing
|
||||
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
|
||||
])
|
||||
hooks["PreToolUse"] = preToolUse
|
||||
settings["hooks"] = hooks
|
||||
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
return (data, snapshot.bytes)
|
||||
}
|
||||
|
||||
func uninstallClaudeHooks() throws {
|
||||
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".claude/settings.json")
|
||||
guard let data = try? Data(contentsOf: settingsURL),
|
||||
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
var hooks = settings["hooks"] as? [String: Any] else { return }
|
||||
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
|
||||
var settings = snapshot.object
|
||||
guard var hooks = settings["hooks"] as? [String: Any] else { return }
|
||||
|
||||
for key in hooks.keys {
|
||||
if var matchers = hooks[key] as? [[String: Any]] {
|
||||
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
|
||||
}
|
||||
settings["hooks"] = hooks
|
||||
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
try newData.write(to: settingsURL, options: .atomic)
|
||||
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
|
||||
}
|
||||
|
||||
// MARK: - Claude plan status line installer
|
||||
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private var _pendingStatusLineData: Data?
|
||||
/// The bytes of settings.json the pending preview was computed from.
|
||||
private var _pendingStatusLineOriginal: Data?
|
||||
private var _pendingPreviousData: Data?
|
||||
private var _pendingDeletePrevious: Bool = false
|
||||
|
||||
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
|
||||
func previewStatusLine(install: Bool) throws -> String {
|
||||
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".claude/settings.json")
|
||||
var settings: [String: Any] = [:]
|
||||
if let d = try? Data(contentsOf: settingsURL),
|
||||
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
|
||||
settings = parsed
|
||||
}
|
||||
// Unreadable or invalid settings must stop here, never count as empty.
|
||||
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
|
||||
let settings = snapshot.object
|
||||
let hookPath = Self.hookScriptPath
|
||||
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
let quotedCmd = "\"\(quotedPath)\" --statusline"
|
||||
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
|
||||
let data = try JSONSerialization.data(withJSONObject: newSettings,
|
||||
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
|
||||
_pendingStatusLineData = data
|
||||
_pendingStatusLineOriginal = snapshot.bytes
|
||||
|
||||
// Build a compact diff: show only the statusLine key before → after
|
||||
func slJSON(_ val: [String: Any]?) throws -> String {
|
||||
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
|
||||
guard let data = _pendingStatusLineData else { return }
|
||||
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".claude/settings.json")
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd-HHmm"
|
||||
let stamp = formatter.string(from: Date())
|
||||
let backupURL = settingsURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("settings.json.bak-\(stamp)")
|
||||
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
|
||||
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: settingsURL, options: .atomic)
|
||||
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
|
||||
// Commit side effects only after successful write
|
||||
if let prevData = _pendingPreviousData {
|
||||
try? prevData.write(to: statusLinePreviousURL, options: .atomic)
|
||||
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
|
||||
try? FileManager.default.removeItem(at: statusLinePreviousURL)
|
||||
}
|
||||
_pendingStatusLineData = nil
|
||||
_pendingStatusLineOriginal = nil
|
||||
_pendingPreviousData = nil
|
||||
_pendingDeletePrevious = false
|
||||
}
|
||||
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
|
||||
/// Writes nb-hook script and updates settings.json in one shot.
|
||||
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
|
||||
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
|
||||
let data = try buildHooksData(claudeURL: claudeURL)
|
||||
let (data, original) = try buildHooksData(claudeURL: claudeURL)
|
||||
|
||||
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
|
||||
let coucouDir = claudeURL.appendingPathComponent("coucou")
|
||||
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
|
||||
|
||||
// Write settings.json (with backup)
|
||||
let settingsURL = claudeURL.appendingPathComponent("settings.json")
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd-HHmm"
|
||||
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
|
||||
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
|
||||
try data.write(to: settingsURL, options: .atomic)
|
||||
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
|
||||
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
|
||||
}
|
||||
|
||||
func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
|
||||
let settingsURL = claudeURL.appendingPathComponent("settings.json")
|
||||
guard let data = try? Data(contentsOf: settingsURL),
|
||||
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
var hooks = settings["hooks"] as? [String: Any] else { return }
|
||||
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
|
||||
var settings = snapshot.object
|
||||
guard var hooks = settings["hooks"] as? [String: Any] else { return }
|
||||
for key in hooks.keys {
|
||||
if var matchers = hooks[key] as? [[String: Any]] {
|
||||
matchers.removeAll { matcher in
|
||||
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
|
||||
}
|
||||
settings["hooks"] = hooks
|
||||
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
try newData.write(to: settingsURL, options: .atomic)
|
||||
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
|
||||
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
|
||||
}
|
||||
|
||||
private func buildHooksData(claudeURL: URL) throws -> Data {
|
||||
private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
|
||||
let settingsURL = claudeURL.appendingPathComponent("settings.json")
|
||||
var settings: [String: Any] = [:]
|
||||
if let data = try? Data(contentsOf: settingsURL),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
settings = parsed
|
||||
}
|
||||
// Unreadable or invalid settings must stop here, never count as empty.
|
||||
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
|
||||
var settings = snapshot.object
|
||||
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
|
||||
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
|
||||
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
|
||||
("Stop", 10), ("StopFailure", 10),
|
||||
("SubagentStart", 10), ("SubagentStop", 10),
|
||||
]
|
||||
var hooks = settings["hooks"] as? [String: Any] ?? [:]
|
||||
// "hooks" in a shape we do not know is refused, never replaced.
|
||||
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
|
||||
for (event, timeout) in events {
|
||||
var existing = hooks[event] as? [[String: Any]] ?? []
|
||||
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
|
||||
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
|
||||
($0["command"] as? String)?.contains("coucou") == true ||
|
||||
($0["command"] as? String)?.contains("NotchBuddy") == true
|
||||
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
|
||||
])
|
||||
hooks["PreToolUse"] = preToolUse
|
||||
settings["hooks"] = hooks
|
||||
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
|
||||
return (data, snapshot.bytes)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -498,7 +498,9 @@ struct IslandContentView: View {
|
||||
.animation(.easeInOut(duration: 0.2), value: state.view == .confused)
|
||||
|
||||
ZStack {
|
||||
ForEach(IslandView.allCases, id: \.self) { v in
|
||||
// Only the visible screen is built (plus the chat, to keep its draft and
|
||||
// scroll): hidden screens used to keep their animations and timers running.
|
||||
ForEach(IslandView.allCases.filter { $0 == state.view || $0 == .prompt }, id: \.self) { v in
|
||||
let active = state.view == v
|
||||
// Views that fill available height instead of the fixed 98pt content frame:
|
||||
// chat (prompt) is always flexible; mail is flexible only when active so
|
||||
@@ -514,6 +516,7 @@ struct IslandContentView: View {
|
||||
.opacity(active ? 1 : 0)
|
||||
.scaleEffect(active ? 1 : 0.97)
|
||||
.allowsHitTesting(active)
|
||||
.transition(.opacity)
|
||||
.animation(anim, value: state.view)
|
||||
}
|
||||
}
|
||||
@@ -584,12 +587,8 @@ struct IslandHeader: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: { state.soundEnabled.toggle() }) {
|
||||
Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: "#8E939C"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Networks (sound lives in Settings and on ⌃⌥M).
|
||||
NetGlobeButton()
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
|
||||
@@ -20,8 +20,14 @@ final class IslandStateMachine {
|
||||
/// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island.
|
||||
var isHeldOpen: (() -> Bool)?
|
||||
|
||||
/// home → petit delay (seconds). Override for debug.
|
||||
var homeToPetitDelay: TimeInterval = 15
|
||||
/// home → petit delay (seconds), kept in sync with the auto-close preference.
|
||||
var homeToPetitDelay: TimeInterval = 15 {
|
||||
didSet {
|
||||
guard homeToPetitDelay != oldValue,
|
||||
state == .home, homeCollapseWork != nil else { return }
|
||||
scheduleHomeCollapse()
|
||||
}
|
||||
}
|
||||
/// petit → hidden delay (seconds). Override for debug.
|
||||
var petitToHiddenDelay: TimeInterval = 60
|
||||
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
|
||||
|
||||
@@ -395,7 +395,7 @@ struct QuestionView: View {
|
||||
Text(item.question)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
// Options (wrapping) or "Other…" compact inline row
|
||||
if curOther {
|
||||
HStack(spacing: 6) {
|
||||
@@ -429,6 +429,43 @@ struct QuestionView: View {
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(Color(hex: "#6B7079"))
|
||||
}
|
||||
} else if item.hasDescriptions {
|
||||
// Options with descriptions: a vertical list, label + description underneath.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
|
||||
let isSelected = curSel.contains(opt.label)
|
||||
Button {
|
||||
if isMulti {
|
||||
toggleSelection(qi: qi, label: opt.label)
|
||||
} else {
|
||||
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(opt.label)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#F5F6F8"))
|
||||
if !opt.description.isEmpty {
|
||||
Text(opt.description)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(Color(hex: "#9AA0A8"))
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
|
||||
}
|
||||
SecondaryButton("Другое…") {
|
||||
if qi < showOther.count { showOther[qi] = true }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChipFlowLayout(spacing: 6) {
|
||||
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
|
||||
|
||||
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
|
||||
private var frameTimer: Timer?
|
||||
private var keyMonitor: Any?
|
||||
private var viewSubscription: AnyCancellable?
|
||||
private var autoCloseSubscription: AnyCancellable?
|
||||
|
||||
// Confused recovery timer (set by handleDizzy)
|
||||
private var confusedRecoveryTimer: DispatchWorkItem?
|
||||
@@ -163,6 +164,11 @@ final class IslandWindowController: NSWindowController {
|
||||
// MARK: - FSM wiring
|
||||
|
||||
private func wireFSM() {
|
||||
// Apply the persisted auto-close preference immediately and keep live edits in sync.
|
||||
autoCloseSubscription = state.$autoCloseInterval.sink { [weak self] delay in
|
||||
self?.fsm.homeToPetitDelay = delay
|
||||
}
|
||||
|
||||
fsm.onTransition = { [weak self] from, to in
|
||||
guard let self else { return }
|
||||
switch to {
|
||||
@@ -211,21 +217,39 @@ final class IslandWindowController: NSWindowController {
|
||||
fsm.isHeldOpen = {
|
||||
let s = AppState.shared
|
||||
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
|
||||
|| (s.mode == .expanded && s.translation != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 60 Hz polling loop
|
||||
|
||||
private func startPolling() {
|
||||
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
private var pollInterval: TimeInterval = 0
|
||||
|
||||
/// Mouse polling rate follows what's on screen: 60 Hz only while dragging (ghost,
|
||||
/// chat handle), 30 Hz with the island open (the character draws at 30 fps),
|
||||
/// 10 Hz when folded (hover still reacts within 0.1 s).
|
||||
private var desiredPollInterval: TimeInterval {
|
||||
if inAttachDrag || attachDragStart != nil || state.chatDragHeight != nil { return 1.0 / 60.0 }
|
||||
return state.mode == .expanded ? 1.0 / 30.0 : 1.0 / 10.0
|
||||
}
|
||||
|
||||
private func startPolling(interval: TimeInterval = 1.0 / 10.0) {
|
||||
frameTimer?.invalidate()
|
||||
pollInterval = interval
|
||||
let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.pollFrame() }
|
||||
}
|
||||
RunLoop.main.add(frameTimer!, forMode: .common)
|
||||
// Let macOS coalesce wakeups with other timers.
|
||||
t.tolerance = interval * 0.2
|
||||
RunLoop.main.add(t, forMode: .common)
|
||||
frameTimer = t
|
||||
}
|
||||
|
||||
private func pollFrame() {
|
||||
guard let panel = window as? IslandPanel else { return }
|
||||
let want = desiredPollInterval
|
||||
if abs(want - pollInterval) > 0.001 { startPolling(interval: want) }
|
||||
|
||||
let mouse = NSEvent.mouseLocation
|
||||
|
||||
@@ -369,15 +393,19 @@ final class IslandWindowController: NSWindowController {
|
||||
state.lastActivity = .now
|
||||
}
|
||||
|
||||
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
|
||||
/// only a pending approval still keeps the island open.
|
||||
func collapse(byUser: Bool = false) {
|
||||
if byUser {
|
||||
guard state.pendingApproval == nil else { return }
|
||||
} else {
|
||||
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too.
|
||||
/// `allowPendingApproval`: the notch's own Escape, jump-to-terminal — may fold the
|
||||
/// approval card (but not a chat/terminal tab).
|
||||
/// Folding a pending approval never answers it: the request stays pending, the island
|
||||
/// stays compact (held open) and a click or ⌃⌥A brings the card back.
|
||||
func collapse(byUser: Bool = false, allowPendingApproval: Bool = false) {
|
||||
let onTallTab = state.mode == .expanded && state.view.isTall
|
||||
let keepsApprovalPending = state.pendingApproval != nil
|
||||
&& (byUser || (allowPendingApproval && !onTallTab))
|
||||
if !byUser && !keepsApprovalPending {
|
||||
guard fsm.isHeldOpen?() != true else { return }
|
||||
}
|
||||
state.isPinned = false
|
||||
if !keepsApprovalPending { state.isPinned = false }
|
||||
finishedPinTimer?.cancel()
|
||||
// Keep the FSM in step with what is on screen (home/coucou → petit now).
|
||||
fsm.collapse()
|
||||
@@ -400,6 +428,7 @@ final class IslandWindowController: NSWindowController {
|
||||
collapse(byUser: true)
|
||||
} else {
|
||||
islandPanel.makeKey()
|
||||
fsm.openedExternally()
|
||||
expand(to: defaultView())
|
||||
}
|
||||
|
||||
@@ -410,6 +439,7 @@ final class IslandWindowController: NSWindowController {
|
||||
case .goToAlert:
|
||||
if state.pendingApproval != nil {
|
||||
islandPanel.makeKey()
|
||||
fsm.openedExternally()
|
||||
expand(to: .approval)
|
||||
} else if state.pendingQuestion != nil {
|
||||
islandPanel.makeKey()
|
||||
@@ -445,6 +475,9 @@ final class IslandWindowController: NSWindowController {
|
||||
case .desktopToggle:
|
||||
DesktopMochiController.shared.flyOutOrHome()
|
||||
|
||||
case .translate:
|
||||
TranslatorRun.start(state: state)
|
||||
|
||||
case .wardrobeToggle:
|
||||
if state.mode == .expanded && state.view == .wardrobe {
|
||||
collapse()
|
||||
@@ -514,8 +547,9 @@ final class IslandWindowController: NSWindowController {
|
||||
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
|
||||
if event.keyCode == 53 && raw.isEmpty {
|
||||
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
|
||||
if !consumed && state.mode == .expanded && !state.isPinned {
|
||||
collapse()
|
||||
let canCollapse = !state.isPinned || state.pendingApproval != nil
|
||||
if !consumed && state.mode == .expanded && canCollapse {
|
||||
collapse(allowPendingApproval: true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -569,7 +603,7 @@ final class IslandWindowController: NSWindowController {
|
||||
NSWorkspace.shared.open(
|
||||
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
|
||||
}
|
||||
collapse()
|
||||
collapse(allowPendingApproval: true)
|
||||
}
|
||||
|
||||
private func performAttachFrontWindow() {
|
||||
@@ -594,6 +628,8 @@ final class IslandWindowController: NSWindowController {
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if event.keyCode == 53 { // Escape
|
||||
// Escape typed in another app (Claude Code's own interrupt, an editor…)
|
||||
// never folds a pending approval away: only Escape in the notch does.
|
||||
if self.state.mode == .expanded && !self.state.isPinned {
|
||||
self.collapse()
|
||||
}
|
||||
@@ -1166,6 +1202,10 @@ func islandSize(mode: IslandMode, view: IslandView,
|
||||
let layout = IslandConst.viewLayouts[view]!
|
||||
// BroV: the chat has its own width (Settings → Чат).
|
||||
if view.isTall { return (AppState.shared.chatWidth, layout.height) }
|
||||
// The question card grows to fit the full question and option descriptions.
|
||||
if view == .question, let h = QuestionLayout.height {
|
||||
return (IslandConst.expandedWidth, h)
|
||||
}
|
||||
return (IslandConst.expandedWidth, layout.height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
// MARK: - Networks (globe in the header)
|
||||
//
|
||||
// BroV is the remote for the network core (mihomo, root). It never touches the core's
|
||||
// config or API secret: every action goes through the narrow root helper netctl.py:
|
||||
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
|
||||
// • right column — client networks: groups "<client>-sw" switched between REJECT and
|
||||
// the client's WireGuard tunnel.
|
||||
// Switching a group never reloads the core, so open connections (this chat) survive.
|
||||
|
||||
struct NetExit: Identifiable, Equatable {
|
||||
var id: String // proxy name in the core
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var delay: Int? // ms, nil = unknown, 0 = dead
|
||||
}
|
||||
|
||||
struct NetClient: Identifiable, Equatable {
|
||||
var id: String // "saga"
|
||||
var title: String
|
||||
var subnet: String
|
||||
var on: Bool
|
||||
var delay: Int?
|
||||
/// What "off" means: REJECT (network unreachable) or DIRECT (home: you're there).
|
||||
var offName: String = "REJECT"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NetCore: ObservableObject {
|
||||
static let shared = NetCore()
|
||||
|
||||
@Published var running = false
|
||||
@Published var exits: [NetExit] = []
|
||||
@Published var currentExit = ""
|
||||
@Published var autoPick = "" // what "auto" chose
|
||||
@Published var clients: [NetClient] = []
|
||||
@Published var busy = false
|
||||
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
|
||||
@Published var tunOn = false
|
||||
@Published var lastError: String?
|
||||
/// Amnezia connections (provider "amnezia-keys"), the group's choice and auto's pick.
|
||||
@Published var amneziaConns: [NetExit] = []
|
||||
@Published var amneziaNow = ""
|
||||
@Published var amneziaAutoPick = ""
|
||||
|
||||
/// Delays measured by the group test (covers the subscription nodes too).
|
||||
private var measured: [String: Int] = [:]
|
||||
|
||||
static let clientInfo: [String: (title: String, subnet: String)] = [
|
||||
"saga": ("Сага", "192.168.8.0/24"),
|
||||
"planet9": ("Planet9", "192.168.68.0/24"),
|
||||
"home": ("Дом", "192.168.10.0/24 · вне дома"),
|
||||
]
|
||||
/// Order in the panel.
|
||||
static let clientOrder = ["home", "saga", "planet9"]
|
||||
|
||||
// MARK: Root helper (netctl.py)
|
||||
//
|
||||
// The core, its config, keys and API secret are root-only. BroV only talks to the
|
||||
// narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
|
||||
// delay, tun, add_key, remove_key — nothing that could rewrite the core config.
|
||||
|
||||
private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
|
||||
|
||||
private func call(_ req: [String: Any], timeout: Int = 12) async -> [String: Any]? {
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: req) else { return nil }
|
||||
// Raw bytes cross threads (Sendable); JSON is parsed back here.
|
||||
let reply: Data? = await withCheckedContinuation { cont in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
cont.resume(returning: Self.callSync(body, timeout: timeout))
|
||||
}
|
||||
}
|
||||
guard let reply else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: reply) as? [String: Any]
|
||||
}
|
||||
|
||||
private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
var tv = timeval(tv_sec: timeout, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
|
||||
}
|
||||
let connected = withUnsafePointer(to: &addr) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard connected == 0 else { return nil }
|
||||
var data = body
|
||||
data.append(0x0A)
|
||||
let sent = data.withUnsafeBytes { send(fd, $0.baseAddress, data.count, 0) }
|
||||
guard sent == data.count else { return nil }
|
||||
var out = Data()
|
||||
var buf = [UInt8](repeating: 0, count: 65536)
|
||||
while !out.contains(0x0A) {
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
if n <= 0 { break }
|
||||
out.append(contentsOf: buf[0..<n])
|
||||
}
|
||||
guard let line = out.split(separator: 0x0A).first else { return nil }
|
||||
return Data(line)
|
||||
}
|
||||
|
||||
/// Reads groups and last known delays.
|
||||
func refresh() async {
|
||||
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
|
||||
let proxies = st["proxies"] as? [String: [String: Any]] else {
|
||||
running = false
|
||||
return
|
||||
}
|
||||
running = true
|
||||
tunOn = st["tun"] as? Bool ?? false
|
||||
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
|
||||
// provider, not in /proxies.
|
||||
var providerDelay: [String: Int] = [:]
|
||||
var awgNames: [String] = []
|
||||
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
|
||||
for (prov, list) in providers {
|
||||
for x in list {
|
||||
guard let n = x["name"] as? String else { continue }
|
||||
if prov == "amnezia-keys" { awgNames.append(n) }
|
||||
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
|
||||
}
|
||||
}
|
||||
awgNames.sort()
|
||||
func lastDelay(_ name: String) -> Int? {
|
||||
if let d = measured[name] { return d }
|
||||
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
|
||||
return providerDelay[name]
|
||||
}
|
||||
amneziaNow = proxies["amnezia"]?["now"] as? String ?? ""
|
||||
amneziaAutoPick = proxies["amnezia-auto"]?["now"] as? String ?? ""
|
||||
let amneziaEffective = amneziaNow == "amnezia-auto" ? amneziaAutoPick : amneziaNow
|
||||
amneziaConns = [NetExit(id: "amnezia-auto", title: "Авто",
|
||||
subtitle: amneziaAutoPick.isEmpty ? "быстрейшее подключение"
|
||||
: "сейчас: \(Self.pretty(amneziaAutoPick).0)",
|
||||
delay: lastDelay(amneziaAutoPick))]
|
||||
+ awgNames.map { NetExit(id: $0, title: Self.pretty($0).0, subtitle: "AmneziaWG", delay: lastDelay($0)) }
|
||||
if let g = proxies["ai-out"], let members = g["all"] as? [String] {
|
||||
currentExit = g["now"] as? String ?? ""
|
||||
autoPick = proxies["auto"]?["now"] as? String ?? ""
|
||||
exits = members.map { name in
|
||||
let (t, sub) = Self.pretty(name)
|
||||
let delay: Int? = name == "auto" ? lastDelay(autoPick)
|
||||
: (name == "amnezia" ? lastDelay(amneziaEffective) : lastDelay(name))
|
||||
return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
|
||||
? "сейчас: \(Self.pretty(autoPick).0)"
|
||||
: (name == "amnezia" && !amneziaEffective.isEmpty
|
||||
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
|
||||
delay: delay)
|
||||
}
|
||||
}
|
||||
clients = Self.clientOrder.compactMap { id in
|
||||
guard let g = proxies["\(id)-sw"], let info = Self.clientInfo[id] else { return nil }
|
||||
let all = g["all"] as? [String] ?? []
|
||||
return NetClient(id: id, title: info.title, subnet: info.subnet,
|
||||
on: (g["now"] as? String) == id, delay: lastDelay(id),
|
||||
offName: all.first ?? "REJECT")
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures every exit and client tunnel (in parallel, inside the core).
|
||||
func measure() async {
|
||||
busy = true
|
||||
if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] as? [String: Int] {
|
||||
measured = m
|
||||
// Members that didn't answer are missing from the map: mark them dead.
|
||||
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
|
||||
}
|
||||
if let m = (await call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] as? [String: Int] {
|
||||
for (k, v) in m { measured[k] = v }
|
||||
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
|
||||
}
|
||||
for c in clients where c.on {
|
||||
if let m = (await call(["cmd": "delay", "proxy": c.id], timeout: 15))?["delays"] as? [String: Int] {
|
||||
for (k, v) in m { measured[k] = v }
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
busy = false
|
||||
}
|
||||
|
||||
func select(exit name: String) async {
|
||||
currentExit = name
|
||||
_ = await call(["cmd": "select", "group": "ai-out", "name": name])
|
||||
SoundEngine.shared.play("blip")
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func set(client id: String, on: Bool) async {
|
||||
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
|
||||
let off = clients.first(where: { $0.id == id })?.offName ?? "REJECT"
|
||||
_ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : off])
|
||||
SoundEngine.shared.play(on ? "pop" : "close")
|
||||
if on, let m = (await call(["cmd": "delay", "proxy": id], timeout: 15))?["delays"] as? [String: Int] {
|
||||
for (k, v) in m { measured[k] = v }
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func select(amnezia name: String) async {
|
||||
amneziaNow = name
|
||||
_ = await call(["cmd": "select", "group": "amnezia", "name": name])
|
||||
if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
|
||||
SoundEngine.shared.play("blip")
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
|
||||
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
|
||||
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
|
||||
guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
|
||||
return (false, "Помощник сетевого ядра не отвечает.")
|
||||
}
|
||||
guard r["ok"] as? Bool == true else {
|
||||
return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
|
||||
}
|
||||
await refresh()
|
||||
let name = r["name"] as? String ?? "?"
|
||||
let server = r["server"] as? String ?? "?"
|
||||
if let d = r["delay"] as? Int, d > 0 {
|
||||
SoundEngine.shared.play("finish")
|
||||
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
|
||||
}
|
||||
return (true, "Ключ «\(name)» сохранён (\(server)), но сервер пока не отвечает. Он будет участвовать в выборе, когда оживёт.")
|
||||
}
|
||||
|
||||
func removeAmnezia(_ proxyName: String) async {
|
||||
_ = await call(["cmd": "remove_key", "name": proxyName])
|
||||
SoundEngine.shared.play("close")
|
||||
await refresh()
|
||||
}
|
||||
|
||||
static var amneziaRunning: Bool {
|
||||
// The app holds routes; its background AmneziaVPN-service doesn't.
|
||||
NSWorkspace.shared.runningApplications.contains { $0.localizedName == "AmneziaVPN" }
|
||||
}
|
||||
|
||||
/// Turns traffic capture on/off without a password (the core keeps running).
|
||||
func setTun(_ on: Bool) async {
|
||||
if on && Self.amneziaRunning {
|
||||
lastError = "Сначала выключи AmneziaVPN — иначе она и ядро подерутся за маршрут."
|
||||
return
|
||||
}
|
||||
lastError = nil
|
||||
tunOn = on
|
||||
_ = await call(["cmd": "tun", "on": on])
|
||||
SoundEngine.shared.play(on ? "pop" : "close")
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await refresh()
|
||||
}
|
||||
|
||||
static func pretty(_ name: String) -> (String, String) {
|
||||
switch name {
|
||||
case "auto": return ("Авто", "самый быстрый выход")
|
||||
case "amnezia": return ("Амнезия", "AmneziaWG")
|
||||
case "amnezia-auto": return ("Авто", "быстрейшее подключение")
|
||||
case let n where n.hasPrefix("AWG "): return (String(n.dropFirst(4)), "AmneziaWG")
|
||||
case let n where n.contains("node3"): return ("node3", "VLESS · Нидерланды")
|
||||
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
|
||||
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
|
||||
case "SkandiFlora": return ("SkandiFlora", "VLESS · Стокгольм")
|
||||
case "Helsinki": return ("Helsinki", "VLESS · Хельсинки")
|
||||
default: return (name, "VLESS")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header button
|
||||
|
||||
struct NetGlobeButton: View {
|
||||
@ObservedObject private var net = NetCore.shared
|
||||
@State private var open = false
|
||||
|
||||
var body: some View {
|
||||
Button { open.toggle() } label: {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(open ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Circle()
|
||||
.fill(net.running ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
|
||||
.frame(width: 5, height: 5)
|
||||
.offset(x: 2, y: -1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Сети")
|
||||
.task { await net.refresh() }
|
||||
.popover(isPresented: $open, arrowEdge: .bottom) {
|
||||
NetPanel(net: net)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
struct NetPanel: View {
|
||||
@ObservedObject var net: NetCore
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "globe").font(.system(size: 13, weight: .semibold))
|
||||
Text("Сети").font(.system(size: 14, weight: .semibold))
|
||||
Circle().fill(net.running ? Color(hex: "#34D399") : Color(hex: "#F4505E")).frame(width: 6, height: 6)
|
||||
Text(net.running ? "ядро работает" : "ядро не запущено")
|
||||
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||||
Spacer()
|
||||
if net.running {
|
||||
Button {
|
||||
Task { await net.measure() }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if net.busy { ProgressView().controlSize(.mini) }
|
||||
else { Image(systemName: "speedometer").font(.system(size: 11)) }
|
||||
Text("Замерить").font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 8).frame(height: 22)
|
||||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(net.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if let err = net.lastError {
|
||||
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
|
||||
.fixedSize(horizontal: false, vertical: true).frame(width: 444, alignment: .leading)
|
||||
}
|
||||
|
||||
if !net.running {
|
||||
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/work/macbookbrov/brov/scripts/netcore/install.sh\nДальше ядро будет запускаться само при включении Мака.")
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundColor(Color(hex: "#B0B5BE"))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(width: 444, alignment: .leading)
|
||||
} else {
|
||||
NetRow(title: "Ядро перехватывает трафик",
|
||||
subtitle: net.tunOn ? "весь трафик Мака идёт через BroV" : "выключено — Мак ходит сам (можно включить AmneziaVPN)",
|
||||
delay: nil, isOn: net.tunOn, accent: "#A78BFA") {
|
||||
Task { await net.setTun(!net.tunOn) }
|
||||
}
|
||||
.frame(width: 444)
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 1)
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
||||
ForEach(net.exits) { e in
|
||||
if e.id == "amnezia" {
|
||||
AmneziaSection(net: net, exit: e)
|
||||
} else {
|
||||
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay,
|
||||
isOn: net.currentExit == e.id, accent: "#D97757") {
|
||||
guard net.currentExit != e.id else { return }
|
||||
Task { await net.select(exit: e.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 230)
|
||||
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("СЕТИ КЛИЕНТОВ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
||||
ForEach(net.clients) { c in
|
||||
NetRow(title: c.title, subtitle: c.subnet, delay: c.on ? c.delay : nil,
|
||||
isOn: c.on, accent: "#34D399") {
|
||||
Task { await net.set(client: c.id, on: !c.on) }
|
||||
}
|
||||
}
|
||||
Text("Выключенная сеть клиента недоступна; «Дом» выключен — значит напрямую (ты дома). Claude и интернет это не трогает.")
|
||||
.font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(width: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.task {
|
||||
await net.refresh()
|
||||
if net.running { await net.measure() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let delay: Int?
|
||||
let isOn: Bool
|
||||
let accent: String
|
||||
let action: () -> Void
|
||||
@State private var hovered = false
|
||||
|
||||
private var delayText: String {
|
||||
guard let d = delay else { return "" }
|
||||
return d == 0 ? "нет ответа" : "\(d) мс"
|
||||
}
|
||||
private var delayColor: String {
|
||||
guard let d = delay else { return "#6B7079" }
|
||||
if d == 0 { return "#F4505E" }
|
||||
return d < 120 ? "#34D399" : (d < 300 ? "#F5A524" : "#F4505E")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).font(.system(size: 12.5, weight: .semibold))
|
||||
Text(subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Text(delayText).font(.system(size: 10.5, weight: .medium)).monospacedDigit()
|
||||
.foregroundColor(Color(hex: delayColor))
|
||||
// Switch look-alike
|
||||
ZStack(alignment: isOn ? .trailing : .leading) {
|
||||
Capsule().fill(isOn ? Color(hex: accent) : Color.white.opacity(0.14))
|
||||
.frame(width: 30, height: 18)
|
||||
Circle().fill(Color.white).frame(width: 14, height: 14).padding(2)
|
||||
}
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isOn)
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(hovered ? 0.06 : 0)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovered = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Amnezia: several connections
|
||||
|
||||
/// "Амнезия" row: the switch picks Amnezia as the exit, the name opens the list of
|
||||
/// connections (Авто / each key) with "+ Добавить подключение" at the end.
|
||||
struct AmneziaSection: View {
|
||||
@ObservedObject var net: NetCore
|
||||
let exit: NetExit
|
||||
@State private var expanded = false
|
||||
@State private var adding = false
|
||||
@State private var confirmRemove: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Button { withAnimation(.easeOut(duration: 0.15)) { expanded.toggle() } } label: {
|
||||
HStack(spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(exit.title).font(.system(size: 12.5, weight: .semibold))
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 8.5, weight: .bold)).foregroundColor(Color(hex: "#8E939C"))
|
||||
}
|
||||
Text(exit.subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
NetRow(title: "", subtitle: "", delay: exit.delay, isOn: net.currentExit == "amnezia", accent: "#D97757") {
|
||||
guard net.currentExit != "amnezia" else { return }
|
||||
Task { await net.select(exit: "amnezia") }
|
||||
}
|
||||
.frame(width: 110)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
|
||||
if expanded {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(net.amneziaConns) { c in
|
||||
NetRow(title: c.title, subtitle: c.subtitle, delay: c.delay,
|
||||
isOn: net.currentExit == "amnezia" && net.amneziaNow == c.id, accent: "#D97757") {
|
||||
Task { await net.select(amnezia: c.id) }
|
||||
}
|
||||
.contextMenu {
|
||||
if c.id != "amnezia-auto" {
|
||||
Button("Удалить подключение…", role: .destructive) { confirmRemove = c.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button { adding = true } label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "plus.circle.fill").font(.system(size: 12))
|
||||
Text("Добавить подключение").font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.foregroundColor(Color(hex: "#D97757"))
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $adding, arrowEdge: .trailing) {
|
||||
AddAmneziaKeyView(net: net) { adding = false }
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1).padding(.leading, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Удалить подключение «\(NetCore.pretty(confirmRemove ?? "").0)»?",
|
||||
isPresented: Binding(get: { confirmRemove != nil }, set: { if !$0 { confirmRemove = nil } })) {
|
||||
Button("Удалить", role: .destructive) {
|
||||
if let n = confirmRemove { Task { await net.removeAmnezia(n) } }
|
||||
confirmRemove = nil
|
||||
}
|
||||
Button("Отмена", role: .cancel) { confirmRemove = nil }
|
||||
} message: {
|
||||
Text("Ключ будет удалён с этого Мака. Вернуть можно, вставив его снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste a vpn:// key → BroV checks it, stores it and tells whether it connected.
|
||||
struct AddAmneziaKeyView: View {
|
||||
@ObservedObject var net: NetCore
|
||||
let onDone: () -> Void
|
||||
@State private var text = ""
|
||||
@State private var working = false
|
||||
@State private var result: (ok: Bool, message: String)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Новое подключение Амнезии").font(.system(size: 13, weight: .semibold))
|
||||
Text("Вставь ключ vpn:// из приложения Amnezia (Поделиться → AmneziaWG или ключ целиком).")
|
||||
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
TextEditor(text: $text)
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(6)
|
||||
.frame(height: 90)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
|
||||
if let r = result {
|
||||
Text(r.message)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.foregroundColor(Color(hex: r.ok ? "#34D399" : "#FB923C"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(result?.ok == true ? "Готово" : "Отмена") { onDone() }
|
||||
Button {
|
||||
working = true
|
||||
Task {
|
||||
result = await net.addAmneziaKey(text)
|
||||
if result?.ok == true { text = "" }
|
||||
working = false
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
if working { ProgressView().controlSize(.small) }
|
||||
Text("Проверить и добавить")
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(working || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
@@ -656,7 +656,6 @@ struct SettingsView: View {
|
||||
Text("Спрашивать в вырезе").tag("default")
|
||||
Text("Принимать правки").tag("acceptEdits")
|
||||
Text("Только план").tag("plan")
|
||||
Text("Без проверок (опасно)").tag("bypassPermissions")
|
||||
}
|
||||
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
|
||||
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
|
||||
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
|
||||
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
|
||||
case translate = "translate" // ⌥` — translate the selected text (BroV)
|
||||
|
||||
// MARK: UserDefaults keys
|
||||
|
||||
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
|
||||
case .muteToggle: return "Выключить / включить звук"
|
||||
case .desktopToggle: return "BroV на рабочий стол / обратно"
|
||||
case .wardrobeToggle: return "Открыть / закрыть гардероб"
|
||||
case .translate: return "Перевести выделенный текст"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ enum ShortcutLogic {
|
||||
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
|
||||
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
|
||||
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
|
||||
.translate: ShortcutSpec(keyCode: 50, nsFlags: ShortcutSpec.optBit), // ⌥` (key left of 1)
|
||||
]
|
||||
|
||||
// MARK: - Load / save (UserDefaults)
|
||||
@@ -196,7 +199,7 @@ enum ShortcutLogic {
|
||||
0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X",
|
||||
8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y",
|
||||
17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J",
|
||||
40:"K", 45:"N", 46:"M", 30:"]", 49:"Space",
|
||||
40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", 50:"`", 27:"-", 29:"0",
|
||||
36:"↩", 51:"⌫", 53:"⎋",
|
||||
123:"←", 124:"→", 125:"↓", 126:"↑",
|
||||
18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9",
|
||||
|
||||
@@ -45,6 +45,15 @@ final class ChatTabs {
|
||||
return c
|
||||
}
|
||||
|
||||
private var seeds: [UUID: String] = [:]
|
||||
|
||||
/// Context prepended to the first message sent from a tab (e.g. the picked colour).
|
||||
func seed(_ id: UUID, _ text: String) { seeds[id] = text }
|
||||
func takeSeed(_ id: UUID?) -> String? {
|
||||
guard let id else { return nil }
|
||||
return seeds.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
|
||||
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
|
||||
|
||||
@@ -73,6 +82,7 @@ final class ChatTabs {
|
||||
clis[id]?.cancel()
|
||||
clis.removeValue(forKey: id)
|
||||
histories.removeValue(forKey: id)
|
||||
seeds.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +270,7 @@ struct TabChip: View {
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 260)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +304,7 @@ struct NewTabButton: View {
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,3 +333,15 @@ struct NewTabButton: View {
|
||||
.disabled(state.tabs.count >= AppState.maxTabs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension View {
|
||||
/// Popovers out of the notch: dark like the island, readable text whatever the
|
||||
/// island's foreground colour is.
|
||||
func notchPopoverStyle() -> some View {
|
||||
self
|
||||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||||
.background(Color(hex: "#16171B"))
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,44 @@ import CoreGraphics
|
||||
|
||||
// Hex color helpers shared by the Mac app and the iPhone app.
|
||||
|
||||
// Views ask for the same few dozen hex colours on every frame: parse each string once.
|
||||
private enum HexCache {
|
||||
nonisolated(unsafe) static var colors: [String: Color] = [:]
|
||||
nonisolated(unsafe) static var cgColors: [String: CGColor] = [:]
|
||||
static let lock = NSLock()
|
||||
}
|
||||
|
||||
// MARK: - Color from hex string
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
HexCache.lock.lock()
|
||||
if let c = HexCache.colors[hex] { HexCache.lock.unlock(); self = c; return }
|
||||
HexCache.lock.unlock()
|
||||
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
let val = UInt64(h, radix: 16) ?? 0
|
||||
let r = Double((val >> 16) & 0xFF) / 255
|
||||
let g = Double((val >> 8) & 0xFF) / 255
|
||||
let b = Double( val & 0xFF) / 255
|
||||
self.init(red: r, green: g, blue: b)
|
||||
HexCache.lock.lock(); HexCache.colors[hex] = self; HexCache.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CGColor from hex string
|
||||
|
||||
func cgColorFromHex(_ hex: String) -> CGColor? {
|
||||
HexCache.lock.lock()
|
||||
if let c = HexCache.cgColors[hex] { HexCache.lock.unlock(); return c }
|
||||
HexCache.lock.unlock()
|
||||
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard let val = UInt64(h, radix: 16) else { return nil }
|
||||
let r = CGFloat((val >> 16) & 0xFF) / 255
|
||||
let g = CGFloat((val >> 8) & 0xFF) / 255
|
||||
let b = CGFloat( val & 0xFF) / 255
|
||||
return CGColor(red: r, green: g, blue: b, alpha: 1)
|
||||
let c = CGColor(red: r, green: g, blue: b, alpha: 1)
|
||||
HexCache.lock.lock(); HexCache.cgColors[hex] = c; HexCache.lock.unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
extension CGColor {
|
||||
|
||||
+12
-6
@@ -6,7 +6,8 @@ options:
|
||||
xcodeVersion: "27.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
# BroV — personal fork of Coucou (MIT). Mac app only, local unsigned builds.
|
||||
# BroV — personal fork of Coucou (MIT). Mac app only, signed with the personal
|
||||
# Apple Development certificate (stable identity: TCC grants survive rebuilds).
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
@@ -14,14 +15,19 @@ configs:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
ENABLE_HARDENED_RUNTIME: NO
|
||||
# Hardened runtime, no get-task-allow: other processes can't inject code into BroV
|
||||
# and borrow its Accessibility / Apple Events permissions.
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/BroV.entitlements
|
||||
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
|
||||
# Ad-hoc signature: runs locally, no Apple Developer account needed.
|
||||
# Personal Team certificate (free Apple ID in Xcode). With ad-hoc signing macOS
|
||||
# forgot Accessibility after every build; a real identity keeps it.
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
CODE_SIGNING_REQUIRED: NO
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGNING_REQUIRED: YES
|
||||
CODE_SIGNING_ALLOWED: YES
|
||||
DEVELOPMENT_TEAM: ""
|
||||
DEVELOPMENT_TEAM: V3NLZK45Q4
|
||||
|
||||
# The only third-party dependency: a real terminal emulator for the term.macOS tabs.
|
||||
packages:
|
||||
|
||||
+4
-2
@@ -6,10 +6,12 @@ cd "$(dirname "$0")/../NotchBuddy"
|
||||
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
||||
xcodegen -q
|
||||
LOG=$(mktemp)
|
||||
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true
|
||||
# Release by default: optimised code (Debug is several times heavier on CPU).
|
||||
CONF="${1:-Release}"
|
||||
xcodebuild -scheme BroV -configuration "$CONF" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true
|
||||
grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
|
||||
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
|
||||
APP="$D/Build/Products/${1:-Debug}/BroV.app"
|
||||
APP="$D/Build/Products/$CONF/BroV.app"
|
||||
# Stop the running copy. If launchd keeps it alive, unload the job first or it would
|
||||
# restart the old binary mid-copy.
|
||||
PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"
|
||||
|
||||
Executable
+234
@@ -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
|
||||
Reference in New Issue
Block a user