add auto-upgrade settings, split into formulae and casks

Two independent toggles, both off by default. When a scheduled fetch
finds outdated packages, runAutoUpgrade runs "upgrade --formula" and/or
"upgrade --cask" (with --greedy if that setting is on), then refetches.

The refetch passes allowAutoUpgrade: false — packages can legitimately
stay outdated after an upgrade (pinned formulae, greedy casks brew
declines), and without the guard that would trigger upgrade → fetch →
upgrade forever. Manual Upgrade All's final fetch gets the same guard.

The casks checkbox carries a warning note in the settings window: a
cask upgrade can close and replace the running app without warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
maxsoch 2026-07-06 22:06:55 +02:00
parent bd19bee52e
commit 7c53413e40
2 changed files with 77 additions and 2 deletions

View file

@ -213,7 +213,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
runBrew("update") { _ in
self.runBrew("upgrade") { _ in
self.fetchOutdated()
// everything was just upgraded; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false)
}
}
}
@ -242,7 +243,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return alert.runModal() == .alertFirstButtonReturn
}
func fetchOutdated() {
/// 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)
@ -253,6 +257,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.cachedOutdated = lines
if allowAutoUpgrade, !lines.isEmpty,
Settings.autoUpgradeFormulae || Settings.autoUpgradeCasks
{
self.runAutoUpgrade()
return
}
self.updateOutdatedMenu(with: lines)
self.updateStatus(count: lines.count)
if lines.isEmpty {
@ -263,6 +274,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
func runAutoUpgrade() {
statusMenuItem.title = "Auto-upgrading..."
let upgradeCasksThenRefresh = {
if Settings.autoUpgradeCasks {
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
self.runBrew(command) { _ in
self.fetchOutdated(allowAutoUpgrade: false)
}
} else {
self.fetchOutdated(allowAutoUpgrade: false)
}
}
if Settings.autoUpgradeFormulae {
runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
}
}
func updateOutdatedMenu(with lines: [String]) {
outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = lines.isEmpty

View file

@ -16,6 +16,8 @@ class SettingsWindowController: NSWindowController {
var customMinutesField: NSTextField!
var launchAtLoginCheckbox: NSButton!
var greedyCheckbox: NSButton!
var autoUpgradeFormulaeCheckbox: NSButton!
var autoUpgradeCasksCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
let window = NSWindow(
@ -81,11 +83,40 @@ class SettingsWindowController: NSWindowController {
action: #selector(toggleGreedy(_:))
)
autoUpgradeFormulaeCheckbox = NSButton(
checkboxWithTitle: "Automatically upgrade formulae (command-line tools)",
target: self,
action: #selector(toggleAutoUpgradeFormulae(_:))
)
autoUpgradeCasksCheckbox = NSButton(
checkboxWithTitle: "Automatically upgrade casks (apps)",
target: self,
action: #selector(toggleAutoUpgradeCasks(_:))
)
let caskWarning = NSTextField(
wrappingLabelWithString: "⚠️ Upgrading a cask can close and replace the app while it is running (e.g. your browser), without warning."
)
caskWarning.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
caskWarning.textColor = .secondaryLabelColor
caskWarning.preferredMaxLayoutWidth = 320
// indent the note so it reads as belonging to the checkbox above
let spacer = NSView()
spacer.widthAnchor.constraint(equalToConstant: 18).isActive = true
let caskWarningRow = NSStackView(views: [spacer, caskWarning])
caskWarningRow.orientation = .horizontal
let stack = NSStackView(views: [
frequencyRow,
customRow,
greedyCheckbox,
separator(),
autoUpgradeFormulaeCheckbox,
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
launchAtLoginCheckbox,
])
stack.orientation = .vertical
@ -122,6 +153,8 @@ class SettingsWindowController: NSWindowController {
// this in System Settings > Login Items while we weren't looking
launchAtLoginCheckbox.state = SMAppService.mainApp.status == .enabled ? .on : .off
greedyCheckbox.state = Settings.includeGreedyCasks ? .on : .off
autoUpgradeFormulaeCheckbox.state = Settings.autoUpgradeFormulae ? .on : .off
autoUpgradeCasksCheckbox.state = Settings.autoUpgradeCasks ? .on : .off
}
func separator() -> NSBox {
@ -159,6 +192,14 @@ class SettingsWindowController: NSWindowController {
appDelegate?.fetchOutdated() // the list contents just changed meaning
}
@objc func toggleAutoUpgradeFormulae(_ sender: NSButton) {
Settings.autoUpgradeFormulae = sender.state == .on
}
@objc func toggleAutoUpgradeCasks(_ sender: NSButton) {
Settings.autoUpgradeCasks = sender.state == .on
}
@objc func toggleLaunchAtLogin(_ sender: NSButton) {
do {
if sender.state == .on {