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:
commit
421ae5c40d
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue