Merge branch 'outdated-json': typed outdated parsing and type-aware auto-upgrade

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
maxsoch 2026-07-06 22:39:50 +02:00
commit 421ae5c40d
3 changed files with 114 additions and 38 deletions

View file

@ -40,7 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
/// last known outdated list, shown in the Upgrade All confirmation /// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: [String] = [] var cachedOutdated: OutdatedPackages = .none
var lastNotifiedOutdated: Set<String> = [] var lastNotifiedOutdated: Set<String> = []
// MARK: - Menu // MARK: - Menu
@ -240,7 +240,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first." 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 { if !cachedOutdated.isEmpty {
info += "\n\n" + cachedOutdated.joined(separator: "\n") info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
} }
alert.informativeText = info alert.informativeText = info
@ -259,25 +259,31 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setMenuBarIcon("brewbar-updating") setMenuBarIcon("brewbar-updating")
// --greedy also lists casks that self-update (brew skips them by default) // --greedy also lists casks that self-update (brew skips them by default)
let command = Settings.includeGreedyCasks ? "outdated --greedy" : "outdated" let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
runBrew(command) { output in runBrew(command) { output in
let lines = BrewParser.parseOutdated(output) let outdated = BrewParser.parseOutdatedJSON(output)
self.cachedOutdated = lines self.cachedOutdated = outdated
if allowAutoUpgrade, !lines.isEmpty, // reflect what was found before any auto-upgrade starts, so the
Settings.autoUpgradeFormulae || Settings.autoUpgradeCasks // menu shows the real list while the upgrade is running
{ self.updateOutdatedMenu(with: outdated)
self.runAutoUpgrade() 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 return
} }
self.updateOutdatedMenu(with: lines) // notify only about what auto-upgrade didn't (or couldn't) handle
self.updateStatus(count: lines.count) self.notifyIfNeeded(outdated: outdated.all.map(\.name))
self.updateMenuBarCount() if outdated.isEmpty {
self.notifyIfNeeded(outdated: lines)
if lines.isEmpty {
self.setMenuBarIcon("brewbar-uptodate") self.setMenuBarIcon("brewbar-uptodate")
} else { } else {
self.setMenuBarIcon("brewbar-outdated") self.setMenuBarIcon("brewbar-outdated")
@ -303,11 +309,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
} }
func runAutoUpgrade() { func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..." statusMenuItem.title = "Auto-upgrading..."
let upgradeCasksThenRefresh = { let upgradeCasksThenRefresh = {
if Settings.autoUpgradeCasks { if casks {
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask" let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
self.runBrew(command) { _ in self.runBrew(command) { _ in
self.fetchOutdated(allowAutoUpgrade: false) self.fetchOutdated(allowAutoUpgrade: false)
@ -317,7 +323,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
if Settings.autoUpgradeFormulae { if formulae {
runBrew("upgrade --formula") { _ in runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh() upgradeCasksThenRefresh()
} }
@ -326,14 +332,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
func updateOutdatedMenu(with lines: [String]) { func updateOutdatedMenu(with outdated: OutdatedPackages) {
outdatedSubmenu.removeAllItems() outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = lines.isEmpty outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = lines.isEmpty upgradeAllItem.isHidden = outdated.isEmpty
for formula in lines { for package in outdated.all {
let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
item.representedObject = formula item.representedObject = package.name // the title has versions; brew needs the bare name
outdatedSubmenu.addItem(item) outdatedSubmenu.addItem(item)
} }
} }

View file

@ -1,10 +1,50 @@
import Foundation import Foundation
struct OutdatedPackage: Codable, Equatable {
let name: String
let installedVersions: [String]
let currentVersion: String
/// Menu label, e.g. "wget 1.21 1.22"
var label: String {
let installed = installedVersions.joined(separator: ", ")
return "\(name) \(installed)\(currentVersion)"
}
}
struct OutdatedPackages: Codable, Equatable {
let formulae: [OutdatedPackage]
let casks: [OutdatedPackage]
var all: [OutdatedPackage] {
formulae + casks
}
var isEmpty: Bool {
formulae.isEmpty && casks.isEmpty
}
var count: Int {
formulae.count + casks.count
}
static let none = OutdatedPackages(formulae: [], casks: [])
}
enum BrewParser { enum BrewParser {
static func parseOutdated(_ output: String) -> [String] { /// Parses `brew outdated --json` output. Returns .none for anything
output.split(separator: "\n") /// undecodable (brew error text, empty output) same effect as an
.map { String($0) } /// empty result, and the raw output is already in the log window.
.filter { !$0.isEmpty } static func parseOutdatedJSON(_ output: String) -> OutdatedPackages {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase // installed_versions installedVersions
guard let data = output.data(using: .utf8),
let parsed = try? decoder.decode(OutdatedPackages.self, from: data)
else {
return .none
}
return parsed
} }
static func parseVersion(_ output: String) -> String { static func parseVersion(_ output: String) -> String {

View file

@ -2,24 +2,54 @@
import XCTest import XCTest
final class BrewParserTests: XCTestCase { final class BrewParserTests: XCTestCase {
// MARK: - parseOutdated // MARK: - parseOutdatedJSON
func testParseOutdated_multiplePackages() { func testParseOutdatedJSON_formulaeAndCasks() {
let output = "wget\nffmpeg\ngit\n" let output = """
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget", "ffmpeg", "git"]) {
"formulae": [
{
"name": "wget",
"installed_versions": ["1.21.3"],
"current_version": "1.21.4",
"pinned": false,
"pinned_version": null
}
],
"casks": [
{
"name": "tailscale-app",
"installed_versions": ["1.98.5"],
"current_version": "1.98.8",
"pinned": false,
"pinned_version": null
}
]
}
"""
let result = BrewParser.parseOutdatedJSON(output)
XCTAssertEqual(result.formulae.map(\.name), ["wget"])
XCTAssertEqual(result.casks.map(\.name), ["tailscale-app"])
XCTAssertEqual(result.count, 2)
XCTAssertFalse(result.isEmpty)
XCTAssertEqual(result.all.map(\.name), ["wget", "tailscale-app"])
} }
func testParseOutdated_emptyOutput() { func testParseOutdatedJSON_nothingOutdated() {
XCTAssertEqual(BrewParser.parseOutdated(""), []) let result = BrewParser.parseOutdatedJSON("{\"formulae\": [], \"casks\": []}")
XCTAssertTrue(result.isEmpty)
XCTAssertEqual(result.count, 0)
} }
func testParseOutdated_singlePackage() { func testParseOutdatedJSON_invalidInputReturnsNone() {
XCTAssertEqual(BrewParser.parseOutdated("wget\n"), ["wget"]) XCTAssertEqual(BrewParser.parseOutdatedJSON(""), .none)
XCTAssertEqual(BrewParser.parseOutdatedJSON("Error: some brew failure"), .none)
} }
func testParseOutdated_trailingNewlinesIgnored() { func testParseOutdatedJSON_packageLabel() {
let output = "wget\n\n\n" let package = OutdatedPackage(name: "wget", installedVersions: ["1.21.3"], currentVersion: "1.21.4")
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"]) XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4")
} }
// MARK: - parseVersion // MARK: - parseVersion