import Cocoa @main class AppDelegate: NSObject, NSApplicationDelegate { static func main() { let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.accessory) app.run() } // MARK: - Varaibles var statusItem: NSStatusItem! var statusMenuItem: NSMenuItem! var outdatedItem: NSMenuItem! var upgradeAllItem: NSMenuItem! var outdatedSubmenu: NSMenu! var versionItem: NSMenuItem! var logWindow: LogWindowController? var settingsWindow: SettingsWindowController? var logBuffer: String = "" var brewPath: String? var refreshTimer: Timer? /// var refreshInterval: TimeInterval = 3600 var refreshInterval: TimeInterval { get { let saved = UserDefaults.standard.double(forKey: "refreshInterval") return saved > 0 ? saved : 3600 // fallback 1h si jamais défini } set { UserDefaults.standard.set(newValue, forKey: "refreshInterval") UserDefaults.standard.synchronize() } } /// last known outdated list, shown in the Upgrade All confirmation var cachedOutdated: [String] = [] // MARK: - Menu func applicationDidFinishLaunching(_: Notification) { Settings.registerDefaults() statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) setMenuBarIcon("brewbar-uptodate") let menu = NSMenu() statusMenuItem = NSMenuItem(title: "Checking...", action: nil, keyEquivalent: "") menu.addItem(statusMenuItem) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r")) upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u") upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages menu.addItem(upgradeAllItem) outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "") outdatedSubmenu = NSMenu() outdatedItem.submenu = outdatedSubmenu outdatedItem.isHidden = true // shown once a fetch finds outdated packages menu.addItem(outdatedItem) menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l")) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ",")) versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "") menu.addItem(versionItem) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) statusItem.menu = menu refreshAll() refreshBrewVersion() refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in self.refreshAll() } // pour arrêter : refreshTimer?.invalidate() } // MARK: - Env func func setMenuBarIcon(_ name: String) { DispatchQueue.main.async { if let button = self.statusItem.button { if let icon = NSImage(named: name) { icon.size = NSSize(width: 22, height: 22) icon.isTemplate = true button.image = icon } } } } func restartTimer() { refreshTimer?.invalidate() // stop running timer refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in self.refreshAll() } } @objc func showSettings() { if settingsWindow == nil { settingsWindow = SettingsWindowController(appDelegate: self) } settingsWindow?.syncUI() settingsWindow?.showWindow(nil) // accessory app: without activation the window can open behind others NSApp.activate(ignoringOtherApps: true) settingsWindow?.window?.makeKeyAndOrderFront(nil) } func resolveBrewPath() -> String { if let cached = brewPath { return cached } let paths = [ "/opt/homebrew/bin/brew", "/usr/local/bin/brew", ] for path in paths { if FileManager.default.isExecutableFile(atPath: path) { brewPath = path return path } } // fallback brewPath = "brew" return "brew" } /// Written once per launch; sudo runs it to show a GUI password dialog /// whenever brew needs admin rights (no terminal is attached to Process). static let askpassURL: URL = { let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("BrewBar", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent("askpass.sh") let script = """ #!/bin/zsh osascript -e 'display dialog "BrewBar needs your administrator password to continue:" default answer "" with hidden answer with title "BrewBar" with icon caution buttons {"Cancel", "OK"} default button "OK"' -e 'text returned of result' """ try? script.write(to: url, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) return url }() func runBrew(_ command: String, completion: @escaping (String) -> Void = { _ in }) { DispatchQueue.global().async { let brew = self.resolveBrewPath() let cleaned = command.replacingOccurrences(of: "brew ", with: "") let arguments = [brew] + cleaned.split(separator: " ").map(String.init) self.log("→ \(arguments.joined(separator: " "))\n") // env resolves brew via PATH (covers the bare "brew" fallback) and // execs it directly — no shell, so no metacharacter interpretation let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = arguments var environment = ProcessInfo.processInfo.environment environment["SUDO_ASKPASS"] = Self.askpassURL.path process.environment = environment let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { try process.run() } catch { self.log("ERROR: \(error)\n") DispatchQueue.main.async { completion("") } return } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let output = String(data: data, encoding: .utf8) ?? "" self.log(output) // completion always on main: callers update AppKit UI, which is main-thread only DispatchQueue.main.async { completion(output) } } } // MARK: - Actions @objc func refreshAction() { refreshAll() } @objc func upgradeAll() { guard confirmUpgrade() else { return } statusMenuItem.title = "Upgrading..." runBrew("update") { _ in self.runBrew("upgrade") { _ in self.fetchOutdated() } } } func confirmUpgrade() -> Bool { let alert = NSAlert() alert.alertStyle = .warning if cachedOutdated.isEmpty { alert.messageText = "Upgrade all outdated packages?" } else { alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?" } var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first." if !cachedOutdated.isEmpty { info += "\n\n" + cachedOutdated.joined(separator: "\n") } alert.informativeText = info alert.addButton(withTitle: "Upgrade") alert.addButton(withTitle: "Cancel") // accessory app: bring the alert to the front, otherwise it can appear behind other windows NSApp.activate(ignoringOtherApps: true) return alert.runModal() == .alertFirstButtonReturn } func fetchOutdated() { setMenuBarIcon("brewbar-updating") // --greedy also lists casks that self-update (brew skips them by default) let command = Settings.includeGreedyCasks ? "outdated --greedy" : "outdated" runBrew(command) { output in let lines = BrewParser.parseOutdated(output) self.cachedOutdated = lines self.updateOutdatedMenu(with: lines) self.updateStatus(count: lines.count) if lines.isEmpty { self.setMenuBarIcon("brewbar-uptodate") } else { self.setMenuBarIcon("brewbar-outdated") } } } func updateOutdatedMenu(with lines: [String]) { outdatedSubmenu.removeAllItems() outdatedItem.isHidden = lines.isEmpty upgradeAllItem.isHidden = lines.isEmpty for formula in lines { let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") item.representedObject = formula outdatedSubmenu.addItem(item) } } func updateStatus(count: Int) { if count == 0 { statusMenuItem.title = "✅ All up to date" } else { statusMenuItem.title = "⚠️ \(count) outdated" } } @objc func upgradeSingle(_ sender: NSMenuItem) { guard let formula = sender.representedObject as? String else { return } runBrew("upgrade \(formula)") { _ in self.refreshAll() } } func refreshAll() { statusMenuItem.title = "Updating..." runBrew("update") { _ in self.fetchOutdated() // ✅ ONLY place calling outdated } } /// ✅ Brew version (correct) func refreshBrewVersion() { runBrew("--version") { output in let firstLine = BrewParser.parseVersion(output) self.versionItem.title = "🏷 \(firstLine)" } } // MARK: - Logs /// oldest content is dropped past this size, or the buffer grows forever /// and every log() re-renders an ever-larger string into the text view let maxLogLength = 100_000 func log(_ text: String) { DispatchQueue.main.async { self.logBuffer += text if self.logBuffer.count > self.maxLogLength { self.logBuffer = String(self.logBuffer.suffix(self.maxLogLength)) } self.logWindow?.update(text: self.logBuffer) } } @objc func showLogs() { if logWindow == nil { logWindow = LogWindowController() } logWindow?.showWindow(nil) logWindow?.update(text: logBuffer) } }