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>
542 lines
20 KiB
Swift
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 offline→online
|
|
/// 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])
|
|
}
|
|
}
|