BrewBar/BrewBar/BrewBarApp.swift
maxsoch 64b9bae564 surface brew failures instead of reporting a false all-up-to-date
runBrew now reports the process exit status to its completion handler.
refreshAll and fetchOutdated stop on failure and show "Check failed —
see Logs": NWPathMonitor cannot see a network that is up but broken
(captive portal, dead DNS, git host down), while the exit code of the
brew command itself catches every failure mode. Upgrade commands keep
ignoring the flag on purpose — a failed upgrade leaves the package in
the outdated list at the next fetch, so the final state stays correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:28:09 +02:00

542 lines
20 KiB
Swift

import Cocoa
import Network
import UserNotifications
@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: OutdatedPackages = .none
var lastNotifiedOutdated: Set<String> = []
let pathMonitor = NWPathMonitor()
/// false until the monitor's first update, so the launch refresh waits
/// until the network state is actually known. Main-queue only.
var isOnline = false
// MARK: - Menu
func applicationDidFinishLaunching(_: Notification) {
Settings.registerDefaults()
UNUserNotificationCenter.current().delegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(windowWillClose(_:)),
name: NSWindow.willCloseNotification,
object: nil
)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.imagePosition = .imageLeft // icon stays visible next to the count
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
// no refreshAll() here: the monitor fires once immediately after
// start(), and the first online update triggers the launch refresh
pathMonitor.pathUpdateHandler = { path in
// updates arrive on the monitor's queue; hop to main for AppKit
DispatchQueue.main.async {
self.networkPathChanged(online: path.status == .satisfied)
}
}
pathMonitor.start(queue: DispatchQueue(label: "fr.socheleau.BrewBar.network"))
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()
presentWindow(of: settingsWindow)
}
// MARK: - Window presentation
/// Accessory apps never appear in Cmd-Tab and their windows can open
/// behind other apps. While one of our windows is open we temporarily
/// become a regular app (Dock icon + Cmd-Tab entry); windowWillClose
/// drops back to accessory once the last window is gone.
func presentWindow(of controller: NSWindowController?) {
NSApp.setActivationPolicy(.regular)
controller?.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
controller?.window?.makeKeyAndOrderFront(nil)
}
@objc func windowWillClose(_: Notification) {
// async so the closing window's isVisible has flipped to false
DispatchQueue.main.async {
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window]
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
if !stillOpen {
NSApp.setActivationPolicy(.accessory)
}
}
}
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"
}
/// Rewritten before each brew command so the sudo password dialog can say
/// which command needs it. Sudo runs this script to show a GUI password
/// prompt whenever brew needs admin rights (no terminal is attached to
/// Process). If two brew commands overlap, the later one's text wins
/// harmless, since prompts realistically only appear during upgrades.
static func writeAskpassScript(message: String) -> URL {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("BrewBar", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// the text lands inside shell single quotes AND an AppleScript string;
// whitelist characters that cannot break out of either
let safeMessage = message.filter { $0.isLetter || $0.isNumber || " ._@+=:/-".contains($0) }
let url = dir.appendingPathComponent("askpass.sh")
let script = """
#!/bin/zsh
osascript -e 'display dialog "BrewBar needs your administrator password to \(safeMessage)." 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
}
/// Serial: brew commands run strictly one after another. Concurrent brew
/// processes could each summon their own sudo password dialog (and their
/// log output interleaved); queueing them prevents both.
static let brewQueue = DispatchQueue(label: "fr.socheleau.BrewBar.brew")
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
/// defaults to naming the brew command.
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
Self.brewQueue.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.writeAskpassScript(message: askpassMessage ?? "run: brew \(cleaned)").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("", false) }
return
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(data: data, encoding: .utf8) ?? ""
self.log(output)
let success = process.terminationStatus == 0
if !success {
self.log("✗ exited with status \(process.terminationStatus)\n")
}
// completion always on main: callers update AppKit UI, which is main-thread only
DispatchQueue.main.async { completion(output, success) }
}
}
// MARK: - Actions
@objc func refreshAction() {
refreshAll()
}
@objc func upgradeAll() {
if Settings.confirmBeforeUpgradeAll {
guard confirmUpgrade() else { return }
}
statusMenuItem.title = "Upgrading..."
runBrew("update") { _, _ in
// packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
let caskNames = self.cachedOutdated.casks.map(\.name)
let upgradeCasksThenRefresh = {
self.upgradeCasksSequentially(caskNames) {
// everything was just upgraded; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false)
}
}
if self.cachedOutdated.formulae.isEmpty {
upgradeCasksThenRefresh()
} else {
self.runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
}
}
}
/// Upgrades casks one at a time so each sudo prompt can name the app it
/// is for a batched "upgrade --cask" gives no per-package hook.
func upgradeCasksSequentially(_ names: [String], completion: @escaping () -> Void) {
guard let next = names.first else {
completion()
return
}
statusMenuItem.title = "Upgrading \(next)..."
// an explicitly named cask upgrades even when self-updating, no --greedy needed
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _, _ in
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
}
}
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.all.map(\.label).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
}
/// allowAutoUpgrade guards against an upgrade loop: packages can stay
/// outdated after an upgrade (pinned formulae, greedy casks brew declines),
/// so the fetch that follows an auto-upgrade must not trigger another one.
func fetchOutdated(allowAutoUpgrade: Bool = true) {
setMenuBarIcon("brewbar-updating")
// --greedy also lists casks that self-update (brew skips them by default)
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
runBrew(command) { output, success in
// parsing a failure's output would yield .none and masquerade as
// "all up to date"; keep the cached state and its icon instead
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
self.setMenuBarIcon(self.cachedOutdated.isEmpty ? "brewbar-uptodate" : "brewbar-outdated")
return
}
let outdated = BrewParser.parseOutdatedJSON(output)
self.cachedOutdated = outdated
// reflect what was found before any auto-upgrade starts, so the
// menu shows the real list while the upgrade is running
self.updateOutdatedMenu(with: outdated)
self.updateStatus(count: outdated.count)
self.updateMenuBarCount()
// only trigger for the kinds that are both enabled AND outdated
// e.g. an outdated cask must not start a pointless formulae upgrade
let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty
let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
return
}
// notify only about what auto-upgrade didn't (or couldn't) handle
self.notifyIfNeeded(outdated: outdated.all.map(\.name))
if outdated.isEmpty {
self.setMenuBarIcon("brewbar-uptodate")
} else {
self.setMenuBarIcon("brewbar-outdated")
}
}
}
/// Notifies only when packages appear that weren't in the last notified
/// set an unchanged list stays silent instead of pinging every refresh.
func notifyIfNeeded(outdated: [String]) {
let current = Set(outdated)
defer { lastNotifiedOutdated = current }
guard Settings.notifyOnNewUpdates,
!current.subtracting(lastNotifiedOutdated).isEmpty else { return }
let content = UNMutableNotificationContent()
content.title = "Homebrew updates available"
let shown = outdated.prefix(4).joined(separator: ", ")
content.body = outdated.count > 4 ? "\(shown) and \(outdated.count - 4) more" : shown
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..."
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = {
self.upgradeCasksSequentially(caskNames) {
self.fetchOutdated(allowAutoUpgrade: false)
}
}
if formulae {
runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
}
}
func updateOutdatedMenu(with outdated: OutdatedPackages) {
outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = outdated.isEmpty
for package in outdated.all {
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
item.representedObject = package.name // the title has versions; brew needs the bare name
outdatedSubmenu.addItem(item)
}
}
func updateMenuBarCount() {
if Settings.showCountInMenuBar, !cachedOutdated.isEmpty {
statusItem.button?.title = " \(cachedOutdated.count)"
} else {
statusItem.button?.title = ""
}
}
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)", askpassMessage: "upgrade \(formula)") { _, _ in
self.refreshAll()
}
}
/// Refreshing offline would silently succeed on stale data: brew update
/// fails but its exit code was never checked, and brew outdated compares
/// against the local taps without touching the network so the app
/// claimed "all up to date" no matter what. Every offlineonline
/// transition refreshes, which also covers the refreshes skipped below.
func networkPathChanged(online: Bool) {
let wasOnline = isOnline
isOnline = online
guard online else {
statusMenuItem.title = "📡 Offline"
return
}
if !wasOnline {
refreshAll()
}
}
func refreshAll() {
guard isOnline else {
statusMenuItem.title = "📡 Offline"
return
}
statusMenuItem.title = "Updating..."
// NWPathMonitor can't see a network that is up but broken (captive
// portal, dead DNS, git host down); brew update's exit code can.
// Proceeding anyway would read stale local data and report a false OK.
runBrew("update") { _, success in
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
return
}
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?.update(text: logBuffer)
presentWindow(of: logWindow)
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
/// Without this, macOS suppresses banners while BrewBar is the active
/// app (e.g. right after toggling the setting with the window open).
func userNotificationCenter(_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler([.banner])
}
}