Compare commits

..

3 commits

Author SHA1 Message Date
maxsoch 149f394ecf Merge branch 'feature/upgrade-menu' 2026-07-13 18:58:32 +02:00
maxsoch 2931b80074 fall back to reinstall when a cask cannot be upgraded as-is
Self-updating apps (Zen, Chrome, …) drift from what brew installed;
brew then refuses to upgrade the cask in place, prints a warning and
does nothing, so the package stayed outdated forever. Detect the
warning and run the fix brew itself suggests (reinstall --cask
--force). Single-package upgrades now also route casks through
upgrade --cask instead of a bare brew upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:58:32 +02:00
maxsoch c4baca221d split Upgrade All into formulae & casks plus selective upgrades
"Upgrade All" becomes "Upgrade formulae & casks", and an "Upgrade
only…" submenu offers Formulae/Casks with live counts — shown only
when both kinds are outdated, since with a single kind the main item
already upgrades exactly what there is. All three actions share one
parametrized upgrade(formulae:casks:) flow; the confirmation dialog
lists only the selected packages and drops the running-app warning
when no casks are involved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:57:18 +02:00
2 changed files with 78 additions and 19 deletions

View file

@ -18,6 +18,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var statusMenuItem: NSMenuItem! var statusMenuItem: NSMenuItem!
var outdatedItem: NSMenuItem! var outdatedItem: NSMenuItem!
var upgradeAllItem: NSMenuItem! var upgradeAllItem: NSMenuItem!
var upgradeOnlyItem: NSMenuItem!
var upgradeFormulaeItem: NSMenuItem!
var upgradeCasksItem: NSMenuItem!
var outdatedSubmenu: NSMenu! var outdatedSubmenu: NSMenu!
var versionItem: NSMenuItem! var versionItem: NSMenuItem!
@ -74,9 +77,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r")) menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r"))
upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u") upgradeAllItem = NSMenuItem(title: "📦 Upgrade formulae & casks", action: #selector(upgradeAll), keyEquivalent: "u")
upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
menu.addItem(upgradeAllItem) menu.addItem(upgradeAllItem)
upgradeOnlyItem = NSMenuItem(title: "🎯 Upgrade only…", action: nil, keyEquivalent: "")
let upgradeOnlySubmenu = NSMenu()
upgradeFormulaeItem = NSMenuItem(title: "Formulae", action: #selector(upgradeFormulaeOnly), keyEquivalent: "")
upgradeCasksItem = NSMenuItem(title: "Casks", action: #selector(upgradeCasksOnly), keyEquivalent: "")
upgradeOnlySubmenu.addItem(upgradeFormulaeItem)
upgradeOnlySubmenu.addItem(upgradeCasksItem)
upgradeOnlyItem.submenu = upgradeOnlySubmenu
upgradeOnlyItem.isHidden = true // shown only when both kinds are outdated
menu.addItem(upgradeOnlyItem)
outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "") outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "")
outdatedSubmenu = NSMenu() outdatedSubmenu = NSMenu()
outdatedItem.submenu = outdatedSubmenu outdatedItem.submenu = outdatedSubmenu
@ -306,8 +318,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc func upgradeAll() { @objc func upgradeAll() {
upgrade(formulae: true, casks: true)
}
@objc func upgradeFormulaeOnly() {
upgrade(formulae: true, casks: false)
}
@objc func upgradeCasksOnly() {
upgrade(formulae: false, casks: true)
}
func upgrade(formulae: Bool, casks: Bool) {
if Settings.confirmBeforeUpgradeAll { if Settings.confirmBeforeUpgradeAll {
guard confirmUpgrade() else { return } guard confirmUpgrade(formulae: formulae, casks: casks) else { return }
} }
statusMenuItem.title = "Upgrading..." statusMenuItem.title = "Upgrading..."
@ -315,22 +339,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
runBrew("update") { _, _ in runBrew("update") { _, _ in
// packages that turn outdated only after this brew update are not // packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them // in cachedOutdated yet; the final refetch will surface them
let caskNames = self.cachedOutdated.casks.map(\.name) let caskNames = casks ? self.cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = { let upgradeCasksThenRefresh = {
self.upgradeCasksSequentially(caskNames) { self.upgradeCasksSequentially(caskNames) {
self.cleanupAfterUpgradeIfEnabled { self.cleanupAfterUpgradeIfEnabled {
// everything was just upgraded; don't chain an auto-upgrade // a manual upgrade just ran; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false) self.fetchOutdated(allowAutoUpgrade: false)
} }
} }
} }
if self.cachedOutdated.formulae.isEmpty { if formulae, !self.cachedOutdated.formulae.isEmpty {
upgradeCasksThenRefresh()
} else {
self.runBrew("upgrade --formula") { _, _ in self.runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh() upgradeCasksThenRefresh()
} }
} else {
upgradeCasksThenRefresh()
} }
} }
} }
@ -344,27 +368,49 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
statusMenuItem.title = "Upgrading \(next)..." statusMenuItem.title = "Upgrading \(next)..."
// an explicitly named cask upgrades even when self-updating, no --greedy needed upgradeCask(next) {
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _, _ in
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion) self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
} }
} }
func confirmUpgrade() -> Bool { /// Self-updating apps (Zen, Chrome, ) can drift from what brew
/// installed, and brew then refuses to upgrade the cask in place
/// it only prints a warning naming the fix. Run that fix for it.
/// (An explicitly named cask upgrades even when self-updating, no
/// --greedy needed.)
func upgradeCask(_ name: String, completion: @escaping () -> Void) {
runBrew("upgrade --cask \(name)", askpassMessage: "upgrade \(name)") { output, _ in
guard output.contains("cannot be upgraded as-is") else {
completion()
return
}
self.runBrew("reinstall --cask --force \(name)", askpassMessage: "reinstall \(name)") { _, _ in
completion()
}
}
}
func confirmUpgrade(formulae: Bool, casks: Bool) -> Bool {
let packages = (formulae ? cachedOutdated.formulae : []) + (casks ? cachedOutdated.casks : [])
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .warning alert.alertStyle = .warning
if cachedOutdated.isEmpty { if packages.isEmpty {
alert.messageText = "Upgrade all outdated packages?" alert.messageText = "Upgrade all outdated packages?"
} else { } else {
alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?" alert.messageText = "Upgrade \(packages.count) package\(packages.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." // the running-app warning only applies when casks are being upgraded
if !cachedOutdated.isEmpty { var lines: [String] = []
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n") if casks {
lines.append("Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first.")
} }
alert.informativeText = info if !packages.isEmpty {
lines.append(packages.map(\.label).joined(separator: "\n"))
}
alert.informativeText = lines.joined(separator: "\n\n")
alert.addButton(withTitle: "Upgrade") alert.addButton(withTitle: "Upgrade")
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
@ -464,6 +510,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
outdatedSubmenu.removeAllItems() outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = outdated.isEmpty outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = outdated.isEmpty upgradeAllItem.isHidden = outdated.isEmpty
// "only" is meaningful only when both kinds are outdated; with a
// single kind, the main item already upgrades exactly what there is
upgradeOnlyItem.isHidden = outdated.formulae.isEmpty || outdated.casks.isEmpty
upgradeFormulaeItem.title = "Formulae (\(outdated.formulae.count))"
upgradeCasksItem.title = "Casks (\(outdated.casks.count))"
for package in outdated.all { for package in outdated.all {
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "") let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
@ -489,13 +540,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc func upgradeSingle(_ sender: NSMenuItem) { @objc func upgradeSingle(_ sender: NSMenuItem) {
guard let formula = sender.representedObject as? String else { return } guard let name = sender.representedObject as? String else { return }
runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _, _ in let finish = {
self.cleanupAfterUpgradeIfEnabled { self.cleanupAfterUpgradeIfEnabled {
self.refreshAll() self.refreshAll()
} }
} }
// casks go through upgradeCask for the reinstall fallback; a bare
// "brew upgrade <name>" would hit the same warning and do nothing
if cachedOutdated.casks.contains(where: { $0.name == name }) {
upgradeCask(name, completion: finish)
} else {
runBrew("upgrade \(name)", askpassMessage: "upgrade \(name)") { _, _ in finish() }
}
} }
/// Refreshing offline would silently succeed on stale data: brew update /// Refreshing offline would silently succeed on stale data: brew update

View file

@ -121,7 +121,7 @@ class SettingsWindowController: NSWindowController {
) )
confirmCheckbox = NSButton( confirmCheckbox = NSButton(
checkboxWithTitle: "Ask for confirmation before Upgrade All", checkboxWithTitle: "Ask for confirmation before bulk upgrades",
target: self, target: self,
action: #selector(toggleConfirm(_:)) action: #selector(toggleConfirm(_:))
) )