import AppKit import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? private(set) var islandController: IslandWindowController? func applicationWillTerminate(_ notification: Notification) { HotKeyCenter.shared.unregisterAll() } func applicationDidFinishLaunching(_ notification: Notification) { // Ignore SIGPIPE — prevents crash when nb-hook closes socket before we write response signal(SIGPIPE, SIG_IGN) // Warm up Keychain cache on main thread BEFORE any poller or view touches it _ = KeychainStore.shared NSApp.setActivationPolicy(.accessory) setupMenuBarItem() CodexUsageMonitor.shared.start() setupIsland() #if PHONE_LINK CloudProbe.shared.startIfEnabled() #endif } // MARK: - Menu bar private func setupMenuBarItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) guard let button = statusItem?.button else { return } button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "BroV") button.image?.size = NSSize(width: 24, height: 18) button.image?.accessibilityDescription = "BroV" button.image?.isTemplate = true let menu = NSMenu() menu.addItem(withTitle: "Открыть BroV", action: #selector(openIsland), keyEquivalent: "") menu.addItem(.separator()) menu.addItem(withTitle: "Настройки…", action: #selector(openSettings), keyEquivalent: ",") menu.addItem(.separator()) menu.addItem(withTitle: "Выйти", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") statusItem?.menu = menu } // MARK: - Actions @objc private func openIsland() { islandController?.expand(to: .overview) } private var settingsWindow: NSWindow? @objc private func openSettingsFromNotification(_ notification: Notification) { if let section = notification.object as? String { UserDefaults.standard.set(section, forKey: "settingsSection") } openSettings() } @objc private func openSettings() { // The island floats above every window; fold it away so it can't cover Settings. if AppState.shared.mode == .expanded { islandController?.collapse() } if let w = settingsWindow, w.isVisible { placeBelowIsland(w) w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true); return } let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) win.title = "Настройки — BroV" let host = NSHostingView(rootView: SettingsView()) host.sizingOptions = [.minSize] win.contentView = host win.contentMinSize = NSSize(width: 640, height: 420) win.isReleasedWhenClosed = false placeBelowIsland(win) settingsWindow = win win.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } /// Centres the window horizontally and keeps its title bar clear of the island panel /// (320 pt tall at the top of the notch screen), shrinking it to fit if needed. private func placeBelowIsland(_ win: NSWindow) { let screen = IslandWindowController.notchScreen() ?? NSScreen.main ?? win.screen guard let screen else { win.center(); return } let visible = screen.visibleFrame let islandBottom = screen.frame.maxY - 320 - 12 // island panel height + margin let top = min(visible.maxY, islandBottom) var frame = win.frame frame.size.height = min(frame.height, max(top - visible.minY - 12, win.minSize.height)) frame.origin.x = visible.midX - frame.width / 2 frame.origin.y = max(visible.minY + 12, top - frame.height) win.setFrame(frame, display: true) } // MARK: - Island setup private func setupIsland() { islandController = IslandWindowController() islandController?.showWindow(nil) islandController?.fsm.launch() HookServer.shared.start() N8nPoller.shared.start() VercelPoller.shared.start() ResendPoller.shared.start() GithubPoller.shared.start() StripePoller.shared.start() CalcomPoller.shared.start() NotionPoller.shared.start() NotificationCenter.default.addObserver(self, selector: #selector(openSettingsFromNotification(_:)), name: .openFullSettings, object: nil) // After the greeting ends, fly Mochi back to the desktop if it was there at last quit NotificationCenter.default.addObserver(forName: .greetComplete, object: nil, queue: .main) { _ in DesktopMochiController.shared.launchFlyIfNeeded() } #if !APPSTORE _ = MusicController.shared #endif } }