From 64b9bae564ef7ce9f6e98516157da2065290ff25 Mon Sep 17 00:00:00 2001 From: maxsoch Date: Thu, 9 Jul 2026 19:28:09 +0200 Subject: [PATCH] surface brew failures instead of reporting a false all-up-to-date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BrewBar/BrewBarApp.swift | 41 +++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 79ef580..6a071df 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -220,7 +220,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// askpassMessage customizes the sudo dialog ("...password to ."); /// defaults to naming the brew command. - func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) { + 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: "") @@ -246,7 +246,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { try process.run() } catch { self.log("ERROR: \(error)\n") - DispatchQueue.main.async { completion("") } + DispatchQueue.main.async { completion("", false) } return } @@ -256,8 +256,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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) } + DispatchQueue.main.async { completion(output, success) } } } @@ -274,7 +278,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { statusMenuItem.title = "Upgrading..." - runBrew("update") { _ in + 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) @@ -288,7 +292,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if self.cachedOutdated.formulae.isEmpty { upgradeCasksThenRefresh() } else { - self.runBrew("upgrade --formula") { _ in + self.runBrew("upgrade --formula") { _, _ in upgradeCasksThenRefresh() } } @@ -305,7 +309,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { statusMenuItem.title = "Upgrading \(next)..." // an explicitly named cask upgrades even when self-updating, no --greedy needed - runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _ in + runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _, _ in self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion) } } @@ -343,7 +347,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // --greedy also lists casks that self-update (brew skips them by default) let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json" - runBrew(command) { output in + 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 @@ -402,7 +414,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } if formulae { - runBrew("upgrade --formula") { _ in + runBrew("upgrade --formula") { _, _ in upgradeCasksThenRefresh() } } else { @@ -441,7 +453,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc func upgradeSingle(_ sender: NSMenuItem) { guard let formula = sender.representedObject as? String else { return } - runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in + runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _, _ in self.refreshAll() } } @@ -472,14 +484,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { statusMenuItem.title = "Updating..." - runBrew("update") { _ in + // 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 + runBrew("--version") { output, _ in let firstLine = BrewParser.parseVersion(output) self.versionItem.title = "🏷 \(firstLine)" }