Files
brov-macbook/NotchBuddy/Sources/App/SettingsView.swift
T

1494 lines
68 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import ServiceManagement
import AppKit
struct SettingsView: View {
@ObservedObject private var state = AppState.shared
@State private var apiKey: String = KeychainStore.shared.get("anthropic-api-key") ?? ""
// Claude model — dynamic list fetched from the API, static fallback if unavailable
private static let fallbackModels: [(id: String, label: String)] = [
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
("claude-sonnet-5-5", "Claude Sonnet 5.5"),
("claude-opus-5-5", "Claude Opus 5.5"),
("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
]
private static let customModelTag = "__custom__"
@State private var fetchedModels: [(id: String, label: String)] = []
@State private var modelChoice: String = {
let m = AppState.shared.claudeModel
return SettingsView.fallbackModels.contains { $0.id == m } ? m : SettingsView.customModelTag
}()
@State private var customModel: String = {
let m = AppState.shared.claudeModel
return SettingsView.fallbackModels.contains { $0.id == m } ? "" : m
}()
private var displayModels: [(id: String, label: String)] {
fetchedModels.isEmpty ? Self.fallbackModels : fetchedModels
}
@State private var launchAtStartup: Bool = (SMAppService.mainApp.status == .enabled)
@State private var statusMessage: String = ""
@State private var showDiff: Bool = false
@State private var pendingHookJSON: String = ""
@State private var hookNeedsUpdate: Bool = HookServer.hooksNeedUpdate()
/// Inline success text shown right under each hooks GroupBox's buttons (key: "claude", "gemini", "agy", "codex", "statusline").
@State private var hookResult: [String: String] = [:]
#if !APPSTORE
@State private var showStatusLineDiff: Bool = false
@State private var pendingStatusLineJSON: String = ""
@State private var statusLinePendingInstall: Bool = true
@State private var planTogglePending: Bool = false
@State private var geminiHooksInstalled: Bool = HookServer.geminiHooksInstalled()
@State private var showGeminiDiff: Bool = false
@State private var pendingGeminiJSON: String = ""
@State private var geminiPendingInstall: Bool = true
@State private var agyHooksInstalled: Bool = HookServer.agyHooksInstalled()
@State private var showAgyDiff: Bool = false
@State private var pendingAgyJSON: String = ""
@State private var agyPendingInstall: Bool = true
@State private var codexHooksInstalled: Bool = HookServer.codexHooksInstalled()
@State private var showCodexDiff: Bool = false
@State private var pendingCodexJSON: String = ""
@State private var codexPendingInstall: Bool = true
#endif
// Multi-provider chat keys
@State private var googleKey: String = KeychainStore.shared.get("google-api-key") ?? ""
@State private var openAIKey: String = KeychainStore.shared.get("openai-api-key") ?? ""
@State private var ollamaURL: String = AppState.shared.ollamaServerURL
@State private var lmstudioURL: String = AppState.shared.lmstudioServerURL
@State private var connectingOllama: Bool = false
@State private var connectingLMStudio: Bool = false
// Integration keys
@State private var resendKey: String = KeychainStore.shared.get("resend-api-key") ?? ""
@State private var resendFrom: String = KeychainStore.shared.get("resend-from") ?? ""
@State private var n8nUrl: String = KeychainStore.shared.get("n8n-url") ?? ""
@State private var n8nKey: String = KeychainStore.shared.get("n8n-api-key") ?? ""
@State private var vercelToken: String = KeychainStore.shared.get("vercel-token") ?? ""
@State private var githubToken: String = KeychainStore.shared.get("github-token") ?? ""
@State private var stripeKey: String = KeychainStore.shared.get("stripe-api-key") ?? ""
@State private var calcomKey: String = KeychainStore.shared.get("calcom-api-key") ?? ""
@State private var notionKey: String = KeychainStore.shared.get("notion-api-key") ?? ""
// Hotkey
@State private var hotkeyFlags: UInt = AppState.shared.hotkeyFlags
@State private var hotkeyCode: UInt16 = AppState.shared.hotkeyCode
// Vercel project filter
@State private var vercelProjects: [String] = []
@State private var loadingVercel: Bool = false
// n8n workflow filter
@State private var n8nWorkflows: [String] = []
@State private var loadingN8n: Bool = false
// Bindings in minutes for the absence field
private var absenceMinutes: Binding<Double> {
Binding(
get: { state.absenceInterval / 60 },
set: { state.absenceInterval = max(1, $0) * 60 }
)
}
// Sidebar selection persisted across sessions
@AppStorage("settingsSection") private var selectedSection: String = "general"
#if PHONE_LINK
@AppStorage("iPhoneSyncEnabled") private var iPhoneSyncEnabled = false
@AppStorage("iPhoneLiveActivityEnabled") private var iPhoneLiveActivityEnabled = false
@AppStorage("iPhoneInstructionsEnabled") private var iPhoneInstructionsEnabled = false
#endif
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
// MARK: - Body
var body: some View {
HStack(spacing: 0) {
// Sidebar — 200 pt, sidebar visual effect background
ZStack(alignment: .topLeading) {
SidebarBackground()
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(alignment: .center, spacing: 10) {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text("BroV")
.font(.system(size: 13, weight: .semibold))
Text(appVersion)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 10)
Divider()
List(selection: Binding(
get: { Optional(selectedSection) },
set: { if let v = $0 { selectedSection = v; statusMessage = "" } }
)) {
SettingsSidebarRow(title: "Основные", icon: "gearshape.fill", color: "#8E939C").tag("general")
SettingsSidebarRow(title: "Плашки", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills")
SettingsSidebarRow(title: "Агенты", icon: "terminal.fill", color: "#3B9EFF").tag("agents")
SettingsSidebarRow(title: "Чат", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat")
SettingsSidebarRow(title: "Интеграции", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations")
SettingsSidebarRow(title: "Сочетания", icon: "keyboard.fill", color: "#6366F1").tag("shortcuts")
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
}
}
.frame(width: 200)
Divider()
// Detail panel
VStack(alignment: .leading, spacing: 0) {
Text(sectionTitle)
.font(.title2)
.fontWeight(.semibold)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
sectionContent
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
if !statusMessage.isEmpty {
Divider()
Text(statusMessage)
.font(.system(size: 12))
.foregroundColor(statusMessage.hasPrefix("❌") ? .red : .secondary)
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
}
}
.onAppear {
#if !APPSTORE
state.refreshPlanRelayState()
#endif
guard fetchedModels.isEmpty,
let key = KeychainStore.shared.get("anthropic-api-key"), !key.isEmpty else { return }
Task {
let models = await ClaudeService.fetchModels(apiKey: key)
guard !models.isEmpty else { return }
await MainActor.run {
fetchedModels = models
let m = state.claudeModel
if models.contains(where: { $0.id == m }) {
modelChoice = m
customModel = ""
} else if modelChoice != Self.customModelTag {
modelChoice = Self.customModelTag
customModel = m
}
}
}
}
}
// MARK: - Section routing
private var sectionTitle: String {
switch selectedSection {
case "general": return "Основные"
case "activepills": return "Плашки"
case "agents": return "Агенты"
case "chat": return "Чат"
case "integrations": return "Интеграции"
case "shortcuts": return "Сочетания клавиш"
default: return "Основные"
}
}
@ViewBuilder private var sectionContent: some View {
switch selectedSection {
case "activepills": activePillsSection
case "agents": agentsSection
case "chat": chatSection
case "integrations": integrationsSection
case "shortcuts": ShortcutsSettingsView()
default: generalSection
}
}
// MARK: - General section
@ViewBuilder private var generalSection: some View {
GroupBox("Звук") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Включить звуки", isOn: $state.soundEnabled)
HStack(spacing: 8) {
Text("Громкость")
.frame(width: 56, alignment: .leading)
Slider(value: $state.soundVolume, in: 0...0.2)
.disabled(!state.soundEnabled)
Text("\(Int(state.soundVolume / 0.2 * 100)) %")
.frame(width: 36, alignment: .trailing)
.monospacedDigit()
}
}
.padding(6)
}
GroupBox("Поведение") {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Закрывать через")
TextField("60", value: $state.autoCloseInterval, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
Text("с бездействия")
}
HStack(spacing: 8) {
Text("Скрывать через")
TextField("3", value: absenceMinutes, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 48)
Text("мин без движения")
}
}
.padding(6)
}
GroupBox("Горячая клавиша") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Показывать остров по сочетанию", isOn: $state.hotkeyEnabled)
.onChange(of: state.hotkeyEnabled) { _, _ in
HotKeyCenter.shared.reregister(.toggleIsland)
}
if state.hotkeyEnabled {
HStack(spacing: 8) {
Text("Сочетание")
.frame(width: 70, alignment: .leading)
ShortcutRecorderButton(flags: $hotkeyFlags, code: $hotkeyCode)
.onChange(of: hotkeyFlags) { _, v in
state.hotkeyFlags = v
HotKeyCenter.shared.reregister(.toggleIsland)
}
.onChange(of: hotkeyCode) { _, v in
state.hotkeyCode = v
HotKeyCenter.shared.reregister(.toggleIsland)
}
Text("нажатие → остров открывается")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
.padding(6)
}
GroupBox("Запуск") {
Toggle("Запускать при входе в систему", isOn: $launchAtStartup)
.onChange(of: launchAtStartup) { _, on in toggleStartup(on) }
.padding(6)
}
#if PHONE_LINK
GroupBox("iPhone") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Показывать сессии агентов на iPhone", isOn: $iPhoneSyncEnabled)
.onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) }
Text("Отправляет сессии в личный iCloud для приложения BroV на iPhone. Названия проектов, команды и вопросы шифруются ключами iCloud. При отключении они удаляются из iCloud.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Toggle("Переносить BroV в Dynamic Island на iPhone, когда Mac заблокирован", isOn: $iPhoneLiveActivityEnabled)
.disabled(!iPhoneSyncEnabled)
.onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) }
Text("Идёт через ретранслятор BroV в push-сервис Apple. Передаются только имя и состояние агента: без названия проекта, команд и путей.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
#if !APPSTORE
Toggle("Разрешить iPhone отправлять инструкции в Claude Code", isOn: $iPhoneInstructionsEnabled)
.disabled(!iPhoneSyncEnabled)
.onChange(of: iPhoneInstructionsEnabled) { _, on in InstructionRunner.shared.setEnabled(on) }
Text("Инструкция с iPhone (нужен Face ID) продолжает последнюю сессию Claude Code в фоне, в её папке, через claude --resume. Пока опция включена, Mac проверяет новые инструкции каждые 15 с.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
#endif
}
.padding(6)
}
#endif
}
// MARK: - Active pills section
@ViewBuilder private var activePillsSection: some View {
GroupBox {
VStack(alignment: .leading, spacing: 10) {
Text("Выберите инструменты, которыми пользуетесь. BroV показывает только то, что отмечено здесь.")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text("Занято слотов: \(state.activeIntegrations.count)/4")
.font(.system(size: 11))
.foregroundColor(state.activeIntegrations.count >= 4 ? .orange : .secondary)
Picker("Основная", selection: $state.mainPillId) {
ForEach(PillCatalog.available.filter { $0.category == .workspace && !$0.comingSoon }, id: \.id) { def in
Text(def.name).tag(def.id)
}
}
.onChange(of: state.mainPillId) { _, newId in
state.activeIntegrations.remove(newId)
state.loadIntegrationTasks()
state.setFocus(newId)
}
ForEach(PillCategory.allCases, id: \.self) { cat in
let catPills = PillCatalog.available.filter { $0.category == cat }
if !catPills.isEmpty {
Divider()
Text(cat.title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
ForEach(catPills, id: \.id) { def in
pillRow(def)
}
}
}
}
.padding(6)
}
}
// MARK: - Agents section
@ViewBuilder private var agentsSection: some View {
GroupBox("Хуки Claude Code") {
VStack(alignment: .leading, spacing: 10) {
if hookNeedsUpdate {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Хуки устарели — обновите их, чтобы отвечать на вопросы Claude из выреза")
.font(.system(size: 11))
.foregroundColor(.orange)
}
#if APPSTORE
Button("Обновить хуки") { installHooksAppStore() }
#else
Button("Обновить хуки") { installHooks() }
#endif
}
#if APPSTORE
Text("~/.claude/coucou/nb-hook")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
HStack(spacing: 10) {
Button("Установить хуки") { installHooksAppStore() }
.buttonStyle(.borderedProminent)
Button("Удалить") { uninstallHooksAppStore() }
.buttonStyle(.bordered)
}
#else
Text("nb-hook : \(HookServer.hookScriptPath)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
HStack(spacing: 10) {
Button("Установить хуки") { installHooks() }
.buttonStyle(.borderedProminent)
Button("Удалить") { uninstallHooks() }
.buttonStyle(.bordered)
}
#endif
hookResultText("claude")
#if !APPSTORE
if showDiff {
ScrollView {
Text(pendingHookJSON)
.font(.system(size: 10, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 140)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
HStack {
Button("Подтвердить и записать") { confirmInstall() }
.buttonStyle(.borderedProminent)
Button("Отмена") { showDiff = false; pendingHookJSON = "" }
.buttonStyle(.bordered)
}
}
#endif
}
.padding(6)
}
#if !APPSTORE
GroupBox("Хуки Gemini CLI") {
VStack(alignment: .leading, spacing: 10) {
Text(geminiHooksInstalled
? "Хуки установлены — перезапустите Gemini CLI"
: "~/.gemini/settings.json")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
HStack(spacing: 10) {
Button("Установить хуки") { triggerGeminiPreview(install: true) }
.buttonStyle(.borderedProminent)
Button("Удалить") { triggerGeminiPreview(install: false) }
.buttonStyle(.bordered)
}
hookResultText("gemini")
if showGeminiDiff {
ScrollView {
Text(pendingGeminiJSON)
.font(.system(size: 10, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 140)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
HStack {
Button("Подтвердить и записать") { confirmGeminiOp() }
.buttonStyle(.borderedProminent)
Button("Отмена") { showGeminiDiff = false; pendingGeminiJSON = "" }
.buttonStyle(.bordered)
}
}
}
.padding(6)
}
GroupBox("Хуки Antigravity") {
VStack(alignment: .leading, spacing: 10) {
Text(agyHooksInstalled
? "Хуки установлены — перезапустите Antigravity"
: "~/.gemini/config/hooks.json")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
HStack(spacing: 10) {
Button("Установить хуки") { triggerAgyPreview(install: true) }
.buttonStyle(.borderedProminent)
Button("Удалить") { triggerAgyPreview(install: false) }
.buttonStyle(.bordered)
}
hookResultText("agy")
if showAgyDiff {
ScrollView {
Text(pendingAgyJSON)
.font(.system(size: 10, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 140)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
HStack {
Button("Подтвердить и записать") { confirmAgyOp() }
.buttonStyle(.borderedProminent)
Button("Отмена") { showAgyDiff = false; pendingAgyJSON = "" }
.buttonStyle(.bordered)
}
}
}
.padding(6)
}
GroupBox("Хуки Codex") {
VStack(alignment: .leading, spacing: 10) {
Text(codexHooksInstalled
? "Хуки установлены — в Codex выполните /hooks или откройте Hooks в настройках, чтобы доверить им"
: "~/.codex/hooks.json")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
HStack(spacing: 10) {
Button("Установить хуки") { triggerCodexPreview(install: true) }
.buttonStyle(.borderedProminent)
Button("Удалить") { triggerCodexPreview(install: false) }
.buttonStyle(.bordered)
}
hookResultText("codex")
if showCodexDiff {
ScrollView {
Text(pendingCodexJSON)
.font(.system(size: 10, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 140)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
HStack {
Button("Подтвердить и записать") { confirmCodexOp() }
.buttonStyle(.borderedProminent)
Button("Отмена") { showCodexDiff = false; pendingCodexJSON = "" }
.buttonStyle(.bordered)
}
}
}
.padding(6)
}
GroupBox("Лимиты плана") {
VStack(alignment: .leading, spacing: 10) {
Text("Показывает расход плана Claude (5-часовой и недельный лимиты) в шапке выреза. BroV добавляет ретранслятор строки состояния в ~/.claude/settings.json. Если строка состояния уже есть, она продолжит работать как раньше. Только для планов Pro и Max.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Toggle("Показывать в вырезе", isOn: Binding(
get: { state.showPlanInNotch || planTogglePending },
set: { on in
if on {
if state.planRelayInstalled {
state.showPlanInNotch = true
} else {
planTogglePending = true
installStatusLine()
}
} else {
state.showPlanInNotch = false
planTogglePending = false
}
}
))
HStack(spacing: 10) {
if state.planRelayInstalled {
Text("Ретранслятор: установлен")
.font(.system(size: 11))
.foregroundColor(.secondary)
Button("Удалить ретранслятор") { uninstallStatusLine() }
.buttonStyle(.bordered)
} else {
Text("Ретранслятор: не установлен")
.font(.system(size: 11))
.foregroundColor(.secondary)
Button("Установить ретранслятор") { installStatusLine() }
.buttonStyle(.borderedProminent)
}
}
hookResultText("statusline")
if showStatusLineDiff {
ScrollView {
Text(pendingStatusLineJSON)
.font(.system(size: 10, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 100)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
HStack {
Button("Подтвердить и записать") { confirmStatusLine() }
.buttonStyle(.borderedProminent)
Button("Отмена") {
showStatusLineDiff = false
pendingStatusLineJSON = ""
planTogglePending = false
}
.buttonStyle(.bordered)
}
}
}
.padding(6)
}
#endif
}
// MARK: - Chat section
@ViewBuilder private var chatSection: some View {
GroupBox("Claude Code (ваша подписка, без API-ключа)") {
VStack(alignment: .leading, spacing: 8) {
HStack {
TextField("Папка, в которой работает чат", text: $state.chatWorkingDirectory)
.textFieldStyle(.roundedBorder)
Button("Выбрать…") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation()
}
}
}
Picker("Модель", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id)
}
}
Picker("Разрешения", selection: $state.claudePermissionMode) {
Text("Спрашивать в вырезе").tag("default")
Text("Принимать правки").tag("acceptEdits")
Text("Только план").tag("plan")
Text("Без проверок (опасно)").tag("bypassPermissions")
}
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath)
Text(found.map { "✓ Используется \($0)" } ?? "✗ claude не найден — установите Claude Code или укажите путь.")
.font(.system(size: 11))
.foregroundColor(found == nil ? .red : .secondary)
Text("Чат запускает `claude -p` в этой папке и ведёт один диалог, пока вы не начнёте новый. Ваши MCP-серверы, скиллы и CLAUDE.md подключаются автоматически.")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Anthropic API") {
VStack(alignment: .leading, spacing: 8) {
SecureField("API-ключ (sk-ant-…)", text: $apiKey)
.textFieldStyle(.roundedBorder)
Button("Сохранить") {
KeychainStore.shared.set("anthropic-api-key", value: apiKey)
statusMessage = "✓ Ключ сохранён."
}
.buttonStyle(.borderedProminent)
Divider().padding(.vertical, 2)
Picker("Модель", selection: $modelChoice) {
ForEach(displayModels, id: \.id) { preset in
Text(preset.label).tag(preset.id)
}
Text("Своя…").tag(Self.customModelTag)
}
.onChange(of: modelChoice) { _, choice in
if choice != Self.customModelTag {
state.claudeModel = choice
} else {
applyCustomModel(customModel)
}
}
if modelChoice == Self.customModelTag {
TextField("ID модели (напр. claude-sonnet-4-6)", text: $customModel)
.textFieldStyle(.roundedBorder)
.onChange(of: customModel) { _, value in applyCustomModel(value) }
}
Text("Используется в чате. Список загружается из аккаунта Anthropic.")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Чат — другие провайдеры") {
VStack(alignment: .leading, spacing: 12) {
Text("Для работы с Google Gemini или OpenAI из чата. Ключи хранятся в Связке ключей.")
.font(.system(size: 12))
.foregroundColor(.secondary)
HStack(spacing: 8) {
Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8)
Text("Google AI").font(.system(size: 12, weight: .semibold))
}
SecureField("API-ключ (AI Studio)", text: $googleKey)
.textFieldStyle(.roundedBorder)
Button("Сохранить") {
KeychainStore.shared.set("google-api-key", value: googleKey)
statusMessage = "✓ Ключ Google сохранён."
}
.buttonStyle(.borderedProminent)
Divider()
HStack(spacing: 8) {
Circle().fill(Color(hex: "#10A37F")).frame(width: 8, height: 8)
Text("OpenAI").font(.system(size: 12, weight: .semibold))
}
SecureField("API-ключ (sk-…)", text: $openAIKey)
.textFieldStyle(.roundedBorder)
Button("Сохранить") {
KeychainStore.shared.set("openai-api-key", value: openAIKey)
statusMessage = "✓ Ключ OpenAI сохранён."
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 4)
}
GroupBox("Локальные модели") {
VStack(alignment: .leading, spacing: 12) {
Text("Подключение к локальному серверу моделей. API-ключ не нужен.")
.font(.system(size: 12))
.foregroundColor(.secondary)
// ── Ollama ──────────────────────────────────────────────────────
HStack(spacing: 8) {
Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8)
Text("Ollama").font(.system(size: 12, weight: .semibold))
if !state.ollamaServerURL.isEmpty {
Text("Подключено")
.font(.system(size: 10))
.foregroundColor(Color(hex: "#22C55E"))
}
}
if state.ollamaServerURL.isEmpty {
TextField("http://127.0.0.1:11434", text: $ollamaURL)
.textFieldStyle(.roundedBorder)
Button(connectingOllama ? "Подключение…" : "Подключить") {
Task { await connectLocal(provider: .ollama) }
}
.buttonStyle(.borderedProminent)
.disabled(connectingOllama)
} else {
Text(state.ollamaServerURL)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
Button("Отключить") {
state.ollamaServerURL = ""
ollamaURL = ""
state.fetchedProviderModels[.ollama] = nil
state.providerModelFetchError[.ollama] = nil
if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
statusMessage = "Ollama отключена."
}
.buttonStyle(.bordered)
}
Divider()
// ── LM Studio ───────────────────────────────────────────────────
HStack(spacing: 8) {
Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8)
Text("LM Studio").font(.system(size: 12, weight: .semibold))
if !state.lmstudioServerURL.isEmpty {
Text("Подключено")
.font(.system(size: 10))
.foregroundColor(Color(hex: "#22C55E"))
}
}
if state.lmstudioServerURL.isEmpty {
TextField("http://127.0.0.1:1234", text: $lmstudioURL)
.textFieldStyle(.roundedBorder)
Button(connectingLMStudio ? "Подключение…" : "Подключить") {
Task { await connectLocal(provider: .lmstudio) }
}
.buttonStyle(.borderedProminent)
.disabled(connectingLMStudio)
} else {
Text(state.lmstudioServerURL)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
Button("Отключить") {
state.lmstudioServerURL = ""
lmstudioURL = ""
state.fetchedProviderModels[.lmstudio] = nil
state.providerModelFetchError[.lmstudio] = nil
if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
statusMessage = "LM Studio отключена."
}
.buttonStyle(.bordered)
}
}
.padding(.vertical, 4)
}
}
// MARK: - Integrations section
@ViewBuilder private var integrationsSection: some View {
GroupBox {
VStack(alignment: .leading, spacing: 14) {
// Resend
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#22C55E")).frame(width: 8, height: 8)
Text("Resend").font(.system(size: 12, weight: .semibold))
}
SecureField("API-ключ (re_…)", text: $resendKey)
.textFieldStyle(.roundedBorder)
TextField("Адрес отправителя (you@yourdomain.com)", text: $resendFrom)
.textFieldStyle(.roundedBorder)
}
// n8n
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#F29B38")).frame(width: 8, height: 8)
Text("n8n").font(.system(size: 12, weight: .semibold))
}
TextField("URL инстанса (https://…)", text: $n8nUrl)
.textFieldStyle(.roundedBorder)
SecureField("API-ключ", text: $n8nKey)
.textFieldStyle(.roundedBorder)
IntegrationFilterRow(
label: "Воркфлоу",
items: n8nWorkflows,
filter: $state.n8nWorkflowFilter,
loading: loadingN8n,
onLoad: loadN8nWorkflows
)
}
// Vercel
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#7C5CFF")).frame(width: 8, height: 8)
Text("Vercel").font(.system(size: 12, weight: .semibold))
}
SecureField("Токен", text: $vercelToken)
.textFieldStyle(.roundedBorder)
IntegrationFilterRow(
label: "Проекты",
items: vercelProjects,
filter: $state.vercelProjectFilter,
loading: loadingVercel,
onLoad: loadVercelProjects
)
}
// GitHub
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#F4505E")).frame(width: 8, height: 8)
Text("GitHub").font(.system(size: 12, weight: .semibold))
}
SecureField("Персональный токен доступа", text: $githubToken)
.textFieldStyle(.roundedBorder)
Text("Классический токен с правом repo или fine-grained с доступом на чтение к Pull requests, Commit statuses и Actions.")
.font(.system(size: 10))
.foregroundColor(Color(hex: "#8E939C"))
}
// Stripe
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#0570DE")).frame(width: 8, height: 8)
Text("Stripe").font(.system(size: 12, weight: .semibold))
}
SecureField("Секретный ключ (sk_live_… или sk_test_…)", text: $stripeKey)
.textFieldStyle(.roundedBorder)
}
// Cal.com
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#C9956A")).frame(width: 8, height: 8)
Text("Cal.com").font(.system(size: 12, weight: .semibold))
}
SecureField("API-ключ (cal_live_…)", text: $calcomKey)
.textFieldStyle(.roundedBorder)
}
// Notion
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Circle().fill(Color(hex: "#E8E8E8")).frame(width: 8, height: 8)
Text("Notion").font(.system(size: 12, weight: .semibold))
}
SecureField("Токен интеграции (secret_…)", text: $notionKey)
.textFieldStyle(.roundedBorder)
}
Button("Сохранить интеграции") { saveIntegrations() }
.buttonStyle(.borderedProminent)
}
.padding(6)
}
}
@ViewBuilder
private func hookResultText(_ key: String) -> some View {
if let text = hookResult[key] {
Text(text)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.green)
}
}
// MARK: - Actions
private func applyCustomModel(_ value: String) {
let id = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !id.isEmpty { state.claudeModel = id }
}
private func toggleStartup(_ on: Bool) {
do {
if on { try SMAppService.mainApp.register() }
else { try SMAppService.mainApp.unregister() }
} catch {
statusMessage = "❌ Автозапуск: \(error.localizedDescription)"
launchAtStartup = !on
}
}
// MARK: - App Store: hooks via NSOpenPanel + security-scoped bookmark
#if APPSTORE
private func pickClaudeFolder(prompt: String) -> URL? {
let panel = NSOpenPanel()
panel.message = "Выберите папку .claude (⇧⌘. — показать скрытые файлы)"
panel.prompt = prompt
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.showsHiddenFiles = true
let realHomePath = getpwuid(getuid()).flatMap { String(cString: $0.pointee.pw_dir, encoding: .utf8) }
?? "/Users/\(NSUserName())"
panel.directoryURL = URL(fileURLWithPath: realHomePath)
guard panel.runModal() == .OK, let url = panel.url else { return nil }
guard url.lastPathComponent == ".claude" else {
statusMessage = "❌ Выберите папку .claude (скрытая, в домашней папке)."
return nil
}
return url
}
private func installHooksAppStore() {
guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return }
let alert = NSAlert()
alert.messageText = "Установить хуки BroV в ~/.claude?"
alert.informativeText = "Будут записаны:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (сначала создаётся резервная копия)"
alert.addButton(withTitle: "Установить")
alert.addButton(withTitle: "Отмена")
alert.alertStyle = .informational
guard alert.runModal() == .alertFirstButtonReturn else { return }
do {
try HookServer.shared.installAndWriteClaudeHooksAppStore(claudeURL: claudeURL)
hookNeedsUpdate = false
hookResult["claude"] = "✓ Хуки установлены"
statusMessage = "✓ Хуки установлены — перезапустите VS Code."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func uninstallHooksAppStore() {
guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return }
do {
try HookServer.shared.uninstallClaudeHooksAppStore(claudeURL: claudeURL)
hookResult["claude"] = "✓ Хуки удалены"
statusMessage = "✓ Хуки удалены."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
#endif
private func connectLocal(provider: ChatProvider) async {
let rawURL = provider == .ollama ? ollamaURL : lmstudioURL
let candidate = rawURL.isEmpty
? (provider == .ollama ? "http://127.0.0.1:11434" : "http://127.0.0.1:1234")
: rawURL
let normalised = LocalChat.normaliseURL(candidate)
guard normalised.hasPrefix("http://") || normalised.hasPrefix("https://") else {
statusMessage = "Поддерживаются только адреса http:// и https://."
return
}
if provider == .ollama { connectingOllama = true } else { connectingLMStudio = true }
statusMessage = ""
let result = await LocalChat.fetchModelsResult(baseURL: normalised)
if provider == .ollama { connectingOllama = false } else { connectingLMStudio = false }
let name = provider == .ollama ? "Ollama" : "LM Studio"
switch result {
case .success(let models) where models.isEmpty:
statusMessage = "Моделей пока нет — сначала скачайте модель в \(name)."
case .success(let models):
if provider == .ollama {
state.ollamaServerURL = normalised
ollamaURL = normalised
state.fetchedProviderModels[.ollama] = nil
state.providerModelFetchError[.ollama] = nil
} else {
state.lmstudioServerURL = normalised
lmstudioURL = normalised
state.fetchedProviderModels[.lmstudio] = nil
state.providerModelFetchError[.lmstudio] = nil
}
statusMessage = "✓ Подключено · моделей: \(models.count)"
case .failure:
statusMessage = "Не удалось подключиться к \(name) по адресу \(normalised). Он запущен?"
}
}
private func installHooks() {
do {
pendingHookJSON = try HookServer.shared.previewClaudeHooks()
hookResult["claude"] = nil
showDiff = true
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func confirmInstall() {
do {
try HookServer.shared.writeClaudeHooks()
showDiff = false
statusMessage = "✓ Хуки установлены в ~/.claude/settings.json"
pendingHookJSON = ""
hookNeedsUpdate = false
hookResult["claude"] = "✓ Хуки установлены"
} catch {
hookResult["claude"] = nil
statusMessage = "❌ Ошибка записи: \(error.localizedDescription)"
}
}
private func uninstallHooks() {
do {
try HookServer.shared.uninstallClaudeHooks()
hookResult["claude"] = "✓ Хуки удалены"
statusMessage = "✓ Хуки удалены."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
#if !APPSTORE
private func triggerGeminiPreview(install: Bool) {
do {
geminiPendingInstall = install
pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install)
showGeminiDiff = true
hookResult["gemini"] = nil
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func confirmGeminiOp() {
do {
try HookServer.shared.writeGeminiHooks()
showGeminiDiff = false
pendingGeminiJSON = ""
geminiHooksInstalled = geminiPendingInstall
hookResult["gemini"] = geminiPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
statusMessage = geminiPendingInstall
? "✓ Хуки Gemini CLI установлены в ~/.gemini/settings.json"
: "✓ Хуки Gemini CLI удалены."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func triggerAgyPreview(install: Bool) {
do {
agyPendingInstall = install
pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install)
showAgyDiff = true
hookResult["agy"] = nil
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func confirmAgyOp() {
do {
try HookServer.shared.writeAgyHooks()
showAgyDiff = false
pendingAgyJSON = ""
agyHooksInstalled = agyPendingInstall
hookResult["agy"] = agyPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
statusMessage = agyPendingInstall
? "✓ Хуки Antigravity установлены в ~/.gemini/config/hooks.json"
: "✓ Хуки Antigravity удалены."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func triggerCodexPreview(install: Bool) {
do {
codexPendingInstall = install
pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install)
showCodexDiff = true
hookResult["codex"] = nil
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func confirmCodexOp() {
do {
try HookServer.shared.writeCodexHooks()
showCodexDiff = false
pendingCodexJSON = ""
codexHooksInstalled = codexPendingInstall
hookResult["codex"] = codexPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
statusMessage = codexPendingInstall
? "✓ Хуки Codex установлены — выполните /hooks в Codex или откройте Hooks в настройках, чтобы доверить им."
: "✓ Хуки Codex удалены."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func installStatusLine() {
do {
pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true)
showStatusLineDiff = true
hookResult["statusline"] = nil
statusLinePendingInstall = true
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func uninstallStatusLine() {
do {
pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false)
showStatusLineDiff = true
hookResult["statusline"] = nil
statusLinePendingInstall = false
statusMessage = "Проверьте JSON ниже перед подтверждением."
} catch {
statusMessage = "❌ \(error.localizedDescription)"
}
}
private func confirmStatusLine() {
do {
try HookServer.shared.writeStatusLine()
showStatusLineDiff = false
pendingStatusLineJSON = ""
state.refreshPlanRelayState()
if planTogglePending {
state.showPlanInNotch = true
planTogglePending = false
}
if !statusLinePendingInstall {
state.showPlanInNotch = false
}
hookResult["statusline"] = statusLinePendingInstall ? "✓ Ретранслятор установлен" : "✓ Ретранслятор удалён"
statusMessage = statusLinePendingInstall
? "✓ Строка состояния установлена."
: "✓ Строка состояния удалена."
} catch {
planTogglePending = false
statusMessage = "❌ \(error.localizedDescription)"
}
}
#endif
private func saveIntegrations() {
saveKey("resend-api-key", value: resendKey)
saveKey("resend-from", value: resendFrom)
saveKey("n8n-url", value: n8nUrl)
saveKey("n8n-api-key", value: n8nKey)
saveKey("vercel-token", value: vercelToken)
// Detect GitHub token changes before writing
let prevGithubToken = KeychainStore.shared.get("github-token")
saveKey("github-token", value: githubToken)
let nextGithubToken = KeychainStore.shared.get("github-token")
if nextGithubToken != prevGithubToken {
AppState.shared.githubPulse = nil
AppState.shared.githubActivity = nil
if nextGithubToken == nil { AppState.shared.githubStats = nil }
if nextGithubToken != nil {
GithubPoller.shared.triggerPulseNow()
GithubPoller.shared.refreshActivityIfStale()
}
}
saveKey("stripe-api-key", value: stripeKey)
saveKey("calcom-api-key", value: calcomKey)
saveKey("notion-api-key", value: notionKey)
statusMessage = "✓ Ключи интеграций сохранены."
}
private func saveKey(_ key: String, value: String) {
if value.isEmpty {
KeychainStore.shared.remove(key)
} else {
KeychainStore.shared.set(key, value: value)
}
}
// MARK: - Vercel project list
private func loadVercelProjects() {
guard let token = KeychainStore.shared.get("vercel-token") else {
statusMessage = "❌ Сначала сохраните токен Vercel."
return
}
loadingVercel = true
guard let url = URL(string: "https://api.vercel.com/v9/projects?limit=100") else { return }
var req = URLRequest(url: url, timeoutInterval: 10)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: req) { data, response, _ in
let names: [String]
if let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let projects = json["projects"] as? [[String: Any]] {
names = projects.compactMap { $0["name"] as? String }.sorted()
} else {
names = []
}
DispatchQueue.main.async {
self.vercelProjects = names
self.loadingVercel = false
if names.isEmpty { self.statusMessage = "❌ Проекты Vercel не найдены." }
}
}.resume()
}
// MARK: - n8n workflow list
private func loadN8nWorkflows() {
guard let apiKey = KeychainStore.shared.get("n8n-api-key"),
let rawBase = KeychainStore.shared.get("n8n-url") else {
statusMessage = "❌ Сначала сохраните URL и API-ключ n8n."
return
}
loadingN8n = true
let base = rawBase.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let urls = ["\(base)/api/v1/workflows?limit=100", "\(base)/rest/workflows?limit=100"]
fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: 0)
}
private func fetchN8nWorkflows(urls: [String], apiKey: String, idx: Int) {
guard idx < urls.count, let url = URL(string: urls[idx]) else {
DispatchQueue.main.async { self.loadingN8n = false; self.statusMessage = "❌ Воркфлоу n8n не найдены." }
return
}
var req = URLRequest(url: url, timeoutInterval: 10)
req.setValue(apiKey, forHTTPHeaderField: "X-N8N-API-KEY")
URLSession.shared.dataTask(with: req) { data, response, _ in
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data, code == 200 else {
self.fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: idx + 1)
return
}
let items: [[String: Any]]
if let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let arr = obj["data"] as? [[String: Any]] { items = arr }
else if let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { items = arr }
else { items = [] }
let names = items.compactMap { $0["name"] as? String }.sorted()
DispatchQueue.main.async {
self.n8nWorkflows = names
self.loadingN8n = false
if names.isEmpty { self.statusMessage = "❌ Воркфлоу n8n не найдены." }
}
}.resume()
}
@ViewBuilder
private func pillRow(_ def: PillDefinition) -> some View {
let isMain = def.id == state.mainPillId
let isOn = state.activeIntegrations.contains(def.id)
let atMax = state.activeIntegrations.count >= 4 && !isOn && !isMain
let hint: String? = {
if isMain { return nil }
if def.comingSoon { return "Скоро" }
#if !APPSTORE
if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Хуки не установлены" }
if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Хуки не установлены" }
if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Хуки не установлены" }
#endif
if def.category == .ai {
if let provider = ChatProvider(pillID: def.id), provider.isLocal {
let url = provider == .ollama ? state.ollamaServerURL : state.lmstudioServerURL
if url.isEmpty { return "Не подключено" }
} else {
let keyId = def.id == "ai_anthropic" ? "anthropic-api-key"
: def.id == "ai_google" ? "google-api-key" : "openai-api-key"
if KeychainStore.shared.get(keyId) == nil { return "Ключ не настроен" }
}
}
return nil
}()
HStack(spacing: 8) {
Circle()
.fill(Color(hex: def.color))
.frame(width: 10, height: 10)
Text(def.name)
.font(.system(size: 12))
.foregroundColor(atMax ? .secondary : .primary)
Spacer()
if isMain {
Text("Основная")
.font(.system(size: 11))
.foregroundColor(.secondary)
} else {
if let h = hint {
Text(h)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
Toggle("", isOn: Binding(
get: { isOn },
set: { _ in state.toggleIntegration(def.id) }
))
.labelsHidden()
.disabled(atMax)
}
}
}
}
// MARK: - Sidebar background (NSVisualEffectView .sidebar)
struct SidebarBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = .sidebar
v.blendingMode = .behindWindow
v.state = .active
return v
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
// MARK: - Sidebar row (System Settings style icon)
struct SettingsSidebarRow: View {
let title: String
let icon: String
let color: String
var body: some View {
Label {
Text(title)
} icon: {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white)
.frame(width: 20, height: 20)
.background(RoundedRectangle(cornerRadius: 5).fill(Color(hex: color)))
}
}
}
// MARK: - Integration filter row (reusable for Vercel / n8n)
struct IntegrationFilterRow: View {
let label: String
let items: [String]
@Binding var filter: Set<String>
let loading: Bool
let onLoad: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
if loading {
ProgressView().scaleEffect(0.6)
} else {
Button(items.isEmpty ? "Загрузить список" : "Обновить") { onLoad() }
.buttonStyle(.bordered)
.controlSize(.mini)
}
if !filter.isEmpty {
Button("Сбросить") { filter = [] }
.buttonStyle(.bordered)
.controlSize(.mini)
.foregroundColor(.secondary)
}
}
if !items.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(items, id: \.self) { item in
Toggle(item, isOn: Binding(
get: { filter.isEmpty || filter.contains(item) },
set: { on in
if on { filter.insert(item) }
else {
if filter.isEmpty { filter = Set(items).subtracting([item]) }
else { filter.remove(item) }
if filter.count == items.count { filter = [] }
}
}
))
.font(.system(size: 11))
.toggleStyle(.checkbox)
}
}
.padding(.leading, 4)
if !filter.isEmpty {
Text("Отслеживается \(filter.count) из \(items.count)")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Shortcut recorder button
struct ShortcutRecorderButton: View {
@Binding var flags: UInt
@Binding var code: UInt16
@State private var isRecording = false
var body: some View {
Button {
guard !isRecording else { return }
isRecording = true
var token: Any?
token = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
guard !mods.isEmpty else { return event }
DispatchQueue.main.async {
self.flags = mods.rawValue
self.code = event.keyCode
self.isRecording = false
if let t = token { NSEvent.removeMonitor(t) }
}
return nil
}
} label: {
Text(isRecording ? "Нажмите клавиши…" : shortcutLabel)
.font(.system(size: 11, design: .monospaced))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(isRecording ? Color.accentColor.opacity(0.12) : Color(NSColor.controlBackgroundColor))
.cornerRadius(5)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.gray.opacity(0.3), lineWidth: 1))
}
.buttonStyle(.plain)
}
private var shortcutLabel: String {
let f = NSEvent.ModifierFlags(rawValue: flags)
var s = ""
if f.contains(.control) { s += "⌃" }
if f.contains(.option) { s += "⌥" }
if f.contains(.shift) { s += "⇧" }
if f.contains(.command) { s += "⌘" }
s += keyChar(code)
return s.isEmpty ? "Нет" : s
}
private func keyChar(_ c: UInt16) -> String {
let map: [UInt16: String] = [
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",
34:"I", 37:"L", 38:"J", 40:"K", 45:"N", 46:"M", 49:"Space", 50:"`", 27:"-"
]
return map[c] ?? "·"
}
}