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 { 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.chatWidth) { ForEach(AppState.chatWidthOptions, id: \.self) { w in Text(w == 640 ? "640 (как главный экран)" : "\(Int(w))").tag(w) } } .pickerStyle(.segmented) 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 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] ?? "·" } }