add update notifications, only when new packages appear

Off by default. Enabling the checkbox requests notification permission
(UNUserNotificationCenter); if denied, the checkbox reverts — the auth
callback arrives on a background queue, so the UI update hops to main.

notifyIfNeeded remembers the last notified set and stays silent while
the outdated list is unchanged, so an hourly refresh doesn't re-ping
about the same packages. The set resets as packages get upgraded.

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

View file

@ -1,4 +1,5 @@
import Cocoa
import UserNotifications
@main
class AppDelegate: NSObject, NSApplicationDelegate {
@ -40,6 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: [String] = []
var lastNotifiedOutdated: Set<String> = []
// MARK: - Menu
@ -266,6 +268,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.updateOutdatedMenu(with: lines)
self.updateStatus(count: lines.count)
self.notifyIfNeeded(outdated: lines)
if lines.isEmpty {
self.setMenuBarIcon("brewbar-uptodate")
} else {
@ -274,6 +277,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
/// Notifies only when packages appear that weren't in the last notified
/// set an unchanged list stays silent instead of pinging every refresh.
func notifyIfNeeded(outdated: [String]) {
let current = Set(outdated)
defer { lastNotifiedOutdated = current }
guard Settings.notifyOnNewUpdates,
!current.subtracting(lastNotifiedOutdated).isEmpty else { return }
let content = UNMutableNotificationContent()
content.title = "Homebrew updates available"
let shown = outdated.prefix(4).joined(separator: ", ")
content.body = outdated.count > 4 ? "\(shown) and \(outdated.count - 4) more" : shown
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
func runAutoUpgrade() {
statusMenuItem.title = "Auto-upgrading..."

View file

@ -1,5 +1,6 @@
import Cocoa
import ServiceManagement
import UserNotifications
class SettingsWindowController: NSWindowController {
weak var appDelegate: AppDelegate?
@ -18,6 +19,7 @@ class SettingsWindowController: NSWindowController {
var greedyCheckbox: NSButton!
var autoUpgradeFormulaeCheckbox: NSButton!
var autoUpgradeCasksCheckbox: NSButton!
var notifyCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
let window = NSWindow(
@ -108,6 +110,12 @@ class SettingsWindowController: NSWindowController {
let caskWarningRow = NSStackView(views: [spacer, caskWarning])
caskWarningRow.orientation = .horizontal
notifyCheckbox = NSButton(
checkboxWithTitle: "Notify when new updates are found",
target: self,
action: #selector(toggleNotify(_:))
)
let stack = NSStackView(views: [
frequencyRow,
customRow,
@ -117,6 +125,7 @@ class SettingsWindowController: NSWindowController {
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
notifyCheckbox,
launchAtLoginCheckbox,
])
stack.orientation = .vertical
@ -155,6 +164,7 @@ class SettingsWindowController: NSWindowController {
greedyCheckbox.state = Settings.includeGreedyCasks ? .on : .off
autoUpgradeFormulaeCheckbox.state = Settings.autoUpgradeFormulae ? .on : .off
autoUpgradeCasksCheckbox.state = Settings.autoUpgradeCasks ? .on : .off
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
}
func separator() -> NSBox {
@ -200,6 +210,21 @@ class SettingsWindowController: NSWindowController {
Settings.autoUpgradeCasks = sender.state == .on
}
@objc func toggleNotify(_ sender: NSButton) {
guard sender.state == .on else {
Settings.notifyOnNewUpdates = false
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
// callback arrives on a background queue; UI needs main
DispatchQueue.main.async {
Settings.notifyOnNewUpdates = granted
sender.state = granted ? .on : .off
}
}
}
@objc func toggleLaunchAtLogin(_ sender: NSButton) {
do {
if sender.state == .on {