Compare commits

..

14 commits

Author SHA1 Message Date
maxsoch 421ae5c40d Merge branch 'outdated-json': typed outdated parsing and type-aware auto-upgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:39:50 +02:00
maxsoch c296c03ee5 update menu and count before auto-upgrade starts
fetchOutdated returned before refreshing the UI when auto-upgrade
triggered, so the menu showed stale state for the whole upgrade. Now
the fetched list lands in the menu, status line and menu bar count
first; runAutoUpgrade then overwrites the status with its own message.
Notification stays after the post-upgrade refetch — leftovers only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:28:17 +02:00
maxsoch 813fc47461 fetch outdated as JSON; auto-upgrade only for matching package kinds
fetchOutdated now uses --json and the typed parser, so cachedOutdated
knows formulae from casks. The auto-upgrade trigger checks each kind
against its own toggle: an outdated cask no longer starts a pointless
"upgrade --formula" run (previously any outdated package triggered
whichever auto-upgrades were enabled). runAutoUpgrade takes explicit
formulae/casks flags instead of re-reading settings.

Bonus from the version info now available: outdated menu items and the
Upgrade All confirmation show "name installed → current" instead of
bare names. The legacy line parser and its tests are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:27:37 +02:00
maxsoch 7b3468126a add typed JSON parser for brew outdated
parseOutdatedJSON decodes brew outdated --json into OutdatedPackages,
which separates formulae from casks — the plain-text output is just
names with no type information. Codable structs mirror the JSON shape,
with convertFromSnakeCase mapping installed_versions to Swift naming.
Undecodable input (brew error text, empty output) parses as .none.

The legacy line parser stays until callers switch over next commit.
Covered by 4 new unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:26:12 +02:00
maxsoch 3a5a7ee623 explain the greedy setting in the settings window
Add a note under the greedy checkbox spelling out what self-updating
casks are and that the setting affects both the outdated listing and
upgrades (Upgrade All and auto-upgrade). The indented-gray-note layout
is extracted into a noteRow helper, now shared with the cask warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:18:23 +02:00
maxsoch 741f9572b7 fix: Upgrade All must pass --greedy when greedy listing is on
--greedy exists on both brew commands: outdated --greedy lists
self-updating casks, upgrade --greedy actually upgrades them (plain
upgrade skips them, per brew's own help). The listing and auto-upgrade
paths had the flag but manual Upgrade All didn't, so greedy-listed
casks would survive Upgrade All and reappear as outdated forever.

Single-package upgrades don't need it: brew upgrades an explicitly
named cask even when it self-updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:16:22 +02:00
maxsoch 0bc18b79b8 make the Upgrade All confirmation dialog optional, on by default
New "Ask for confirmation before Upgrade All" checkbox; when disabled,
Upgrade All runs immediately. Auto-upgrade is unaffected either way —
it never prompts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:09:26 +02:00
maxsoch 29b9412a34 add optional outdated count next to the menu bar icon
Off by default. The status item button shows both image and title
(imagePosition = .imageLeft), so the icon stays visible with the count
beside it. Cleared when up to date or when the setting is off; toggling
applies immediately from the cached list without refetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:08:51 +02:00
maxsoch 32d81423ab 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>
2026-07-06 22:08:10 +02:00
maxsoch 7c53413e40 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>
2026-07-06 22:06:55 +02:00
maxsoch bd19bee52e add setting to include self-updating casks (--greedy), on by default
brew outdated normally skips casks that auto-update themselves; with
includeGreedyCasks enabled (the default) BrewBar passes --greedy so they
show up too. Toggling the checkbox refetches immediately since the list
semantics change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:05:53 +02:00
maxsoch 617e07d53d add launch-at-login setting via SMAppService
Checkbox registers/unregisters SMAppService.mainApp (macOS 13+ API, no
helper app needed). Deliberately not mirrored in UserDefaults: the
service status is the single source of truth, and syncUI reads it live
so changes made in System Settings > Login Items stay in sync. Failures
revert the checkbox and surface the error in an alert.

Note: registration points at the app bundle's current path, so it only
behaves properly once BrewBar runs from a stable location like
/Applications — debug builds may register the build folder copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:05:14 +02:00
maxsoch 98d8cbe2a9 replace interval submenu with a Settings window (frequency)
New SettingsWindowController (programmatic NSWindow, same pattern as
LogWindowController) with a preset popup and a custom-minutes field for
the refresh frequency. Changes apply immediately, per macOS convention.

The ⏱ Refresh Interval submenu, its custom text-field-in-menu view and
the two backing actions are removed in favor of "⚙️ Settings…" (Cmd+,).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:04:31 +02:00
maxsoch be49480732 add UserDefaults-backed Settings storage
Settings enum holds all upcoming settings-window preferences. Booleans
default to false (disabled by default); includeGreedyCasks and
confirmBeforeUpgradeAll default to true via register(defaults:), which
provides fallbacks without writing to disk. Launch-at-login is not
stored here — SMAppService will be its source of truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:19 +02:00
5 changed files with 528 additions and 82 deletions

View file

@ -1,4 +1,5 @@
import Cocoa import Cocoa
import UserNotifications
@main @main
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
@ -20,6 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var versionItem: NSMenuItem! var versionItem: NSMenuItem!
var logWindow: LogWindowController? var logWindow: LogWindowController?
var settingsWindow: SettingsWindowController?
var logBuffer: String = "" var logBuffer: String = ""
var brewPath: String? var brewPath: String?
@ -38,12 +40,16 @@ 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> = []
// MARK: - Menu // MARK: - Menu
func applicationDidFinishLaunching(_: Notification) { func applicationDidFinishLaunching(_: Notification) {
Settings.registerDefaults()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.imagePosition = .imageLeft // icon stays visible next to the count
setMenuBarIcon("brewbar-uptodate") setMenuBarIcon("brewbar-uptodate")
let menu = NSMenu() let menu = NSMenu()
@ -66,40 +72,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
let intervalItem = NSMenuItem(title: "⏱ Refresh Interval", action: nil, keyEquivalent: "") menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))
let intervalSubmenu = NSMenu()
let options: [(String, TimeInterval)] = [
("1 heure", 3600),
("6 heures", 21600),
]
for (label, interval) in options {
let item = NSMenuItem(title: label, action: #selector(setRefreshInterval(_:)), keyEquivalent: "")
item.representedObject = interval
intervalSubmenu.addItem(item)
}
let customMenuItem = NSMenuItem()
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 30))
let textField = NSTextField(frame: NSRect(x: 10, y: 4, width: 120, height: 22))
textField.placeholderString = "Secondes..."
textField.stringValue = "\(Int(refreshInterval))"
let confirmButton = NSButton(frame: NSRect(x: 138, y: 4, width: 52, height: 22))
confirmButton.title = "✓ Set"
confirmButton.bezelStyle = .rounded
confirmButton.target = self
confirmButton.action = #selector(applyCustomInterval(_:))
customView.addSubview(textField)
customView.addSubview(confirmButton)
customMenuItem.view = customView
intervalSubmenu.addItem(customMenuItem)
intervalItem.submenu = intervalSubmenu
menu.addItem(intervalItem)
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "") versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
menu.addItem(versionItem) menu.addItem(versionItem)
@ -139,20 +112,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
@objc func setRefreshInterval(_ sender: NSMenuItem) { @objc func showSettings() {
guard let interval = sender.representedObject as? TimeInterval else { return } if settingsWindow == nil {
refreshInterval = interval // le set sauvegarde dans UserDefaults settingsWindow = SettingsWindowController(appDelegate: self)
restartTimer()
} }
settingsWindow?.syncUI()
@objc func applyCustomInterval(_ sender: NSButton) { settingsWindow?.showWindow(nil)
guard let view = sender.superview, // accessory app: without activation the window can open behind others
let textField = view.subviews.first(where: { $0 is NSTextField }) as? NSTextField, NSApp.activate(ignoringOtherApps: true)
let seconds = Int(textField.stringValue), seconds > 0 else { return } settingsWindow?.window?.makeKeyAndOrderFront(nil)
refreshInterval = TimeInterval(seconds)
restartTimer()
statusItem.menu?.cancelTracking()
} }
func resolveBrewPath() -> String { func resolveBrewPath() -> String {
@ -242,13 +210,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc func upgradeAll() { @objc func upgradeAll() {
if Settings.confirmBeforeUpgradeAll {
guard confirmUpgrade() else { return } guard confirmUpgrade() else { return }
}
statusMenuItem.title = "Upgrading..." statusMenuItem.title = "Upgrading..."
// must match the outdated listing: greedy-listed casks are skipped by
// plain "upgrade" and would stay outdated forever
let command = Settings.includeGreedyCasks ? "upgrade --greedy" : "upgrade"
runBrew("update") { _ in runBrew("update") { _ in
self.runBrew("upgrade") { _ in self.runBrew(command) { _ in
self.fetchOutdated() // everything was just upgraded; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false)
} }
} }
} }
@ -265,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
@ -277,17 +252,38 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return alert.runModal() == .alertFirstButtonReturn 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") setMenuBarIcon("brewbar-updating")
runBrew("outdated") { output in // --greedy also lists casks that self-update (brew skips them by default)
let lines = BrewParser.parseOutdated(output) let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
self.cachedOutdated = lines runBrew(command) { output in
let outdated = BrewParser.parseOutdatedJSON(output)
self.updateOutdatedMenu(with: lines) self.cachedOutdated = outdated
self.updateStatus(count: lines.count)
if lines.isEmpty { // reflect what was found before any auto-upgrade starts, so the
// menu shows the real list while the upgrade is running
self.updateOutdatedMenu(with: outdated)
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
}
// notify only about what auto-upgrade didn't (or couldn't) handle
self.notifyIfNeeded(outdated: outdated.all.map(\.name))
if outdated.isEmpty {
self.setMenuBarIcon("brewbar-uptodate") self.setMenuBarIcon("brewbar-uptodate")
} else { } else {
self.setMenuBarIcon("brewbar-outdated") self.setMenuBarIcon("brewbar-outdated")
@ -295,18 +291,67 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
func updateOutdatedMenu(with lines: [String]) { /// Notifies only when packages appear that weren't in the last notified
outdatedSubmenu.removeAllItems() /// set an unchanged list stays silent instead of pinging every refresh.
outdatedItem.isHidden = lines.isEmpty func notifyIfNeeded(outdated: [String]) {
upgradeAllItem.isHidden = lines.isEmpty let current = Set(outdated)
defer { lastNotifiedOutdated = current }
for formula in lines { guard Settings.notifyOnNewUpdates,
let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") !current.subtracting(lastNotifiedOutdated).isEmpty else { return }
item.representedObject = formula
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(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..."
let upgradeCasksThenRefresh = {
if casks {
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
self.runBrew(command) { _ in
self.fetchOutdated(allowAutoUpgrade: false)
}
} else {
self.fetchOutdated(allowAutoUpgrade: false)
}
}
if formulae {
runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
}
}
func updateOutdatedMenu(with outdated: OutdatedPackages) {
outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = outdated.isEmpty
for package in outdated.all {
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
item.representedObject = package.name // the title has versions; brew needs the bare name
outdatedSubmenu.addItem(item) outdatedSubmenu.addItem(item)
} }
} }
func updateMenuBarCount() {
if Settings.showCountInMenuBar, !cachedOutdated.isEmpty {
statusItem.button?.title = " \(cachedOutdated.count)"
} else {
statusItem.button?.title = ""
}
}
func updateStatus(count: Int) { func updateStatus(count: Int) {
if count == 0 { if count == 0 {
statusMenuItem.title = "✅ All up to date" statusMenuItem.title = "✅ All up to date"

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 {

47
BrewBar/Settings.swift Normal file
View file

@ -0,0 +1,47 @@
import Foundation
/// UserDefaults-backed app settings. Booleans read false for unset keys,
/// which covers the disabled-by-default settings; enabled-by-default ones
/// get their fallback via registerDefaults() (consulted only when the user
/// has never changed the value nothing is written to disk).
///
/// Launch-at-login is intentionally absent: SMAppService is its source of
/// truth, so storing a copy here could only drift out of sync.
enum Settings {
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
"includeGreedyCasks": true,
"confirmBeforeUpgradeAll": true,
])
}
static var autoUpgradeFormulae: Bool {
get { UserDefaults.standard.bool(forKey: "autoUpgradeFormulae") }
set { UserDefaults.standard.set(newValue, forKey: "autoUpgradeFormulae") }
}
static var autoUpgradeCasks: Bool {
get { UserDefaults.standard.bool(forKey: "autoUpgradeCasks") }
set { UserDefaults.standard.set(newValue, forKey: "autoUpgradeCasks") }
}
static var notifyOnNewUpdates: Bool {
get { UserDefaults.standard.bool(forKey: "notifyOnNewUpdates") }
set { UserDefaults.standard.set(newValue, forKey: "notifyOnNewUpdates") }
}
static var showCountInMenuBar: Bool {
get { UserDefaults.standard.bool(forKey: "showCountInMenuBar") }
set { UserDefaults.standard.set(newValue, forKey: "showCountInMenuBar") }
}
static var includeGreedyCasks: Bool {
get { UserDefaults.standard.bool(forKey: "includeGreedyCasks") }
set { UserDefaults.standard.set(newValue, forKey: "includeGreedyCasks") }
}
static var confirmBeforeUpgradeAll: Bool {
get { UserDefaults.standard.bool(forKey: "confirmBeforeUpgradeAll") }
set { UserDefaults.standard.set(newValue, forKey: "confirmBeforeUpgradeAll") }
}
}

View file

@ -0,0 +1,284 @@
import Cocoa
import ServiceManagement
import UserNotifications
class SettingsWindowController: NSWindowController {
weak var appDelegate: AppDelegate?
let presets: [(label: String, interval: TimeInterval)] = [
("30 minutes", 1800),
("1 hour", 3600),
("6 hours", 21600),
("12 hours", 43200),
("24 hours", 86400),
]
var intervalPopup: NSPopUpButton!
var customMinutesField: NSTextField!
var launchAtLoginCheckbox: NSButton!
var greedyCheckbox: NSButton!
var autoUpgradeFormulaeCheckbox: NSButton!
var autoUpgradeCasksCheckbox: NSButton!
var notifyCheckbox: NSButton!
var showCountCheckbox: NSButton!
var confirmCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 200),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "BrewBar Settings"
// the controller keeps the window alive; without this, closing the
// window would deallocate it and reopening would crash
window.isReleasedWhenClosed = false
self.init(window: window)
self.appDelegate = appDelegate
buildUI()
syncUI()
window.center()
}
// MARK: - Layout
func buildUI() {
guard let contentView = window?.contentView else { return }
intervalPopup = NSPopUpButton()
for preset in presets {
intervalPopup.addItem(withTitle: preset.label)
}
intervalPopup.addItem(withTitle: "Custom")
intervalPopup.target = self
intervalPopup.action = #selector(presetPicked(_:))
customMinutesField = NSTextField()
customMinutesField.placeholderString = "60"
customMinutesField.alignment = .right
customMinutesField.target = self
customMinutesField.action = #selector(customMinutesEntered(_:))
customMinutesField.widthAnchor.constraint(equalToConstant: 60).isActive = true
let frequencyRow = NSStackView(views: [
NSTextField(labelWithString: "Check for updates every:"),
intervalPopup,
])
frequencyRow.orientation = .horizontal
let customRow = NSStackView(views: [
customMinutesField,
NSTextField(labelWithString: "minutes"),
])
customRow.orientation = .horizontal
launchAtLoginCheckbox = NSButton(
checkboxWithTitle: "Launch BrewBar at login",
target: self,
action: #selector(toggleLaunchAtLogin(_:))
)
greedyCheckbox = NSButton(
checkboxWithTitle: "Include self-updating casks (--greedy)",
target: self,
action: #selector(toggleGreedy(_:))
)
let greedyNote = noteRow(
"Some apps (Chrome, Firefox…) update themselves, so Homebrew normally ignores them. When enabled, BrewBar also lists them as outdated and includes them in Upgrade All and auto-upgrades."
)
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 caskWarningRow = noteRow(
"⚠️ Upgrading a cask can close and replace the app while it is running (e.g. your browser), without warning."
)
notifyCheckbox = NSButton(
checkboxWithTitle: "Notify when new updates are found",
target: self,
action: #selector(toggleNotify(_:))
)
showCountCheckbox = NSButton(
checkboxWithTitle: "Show outdated count in the menu bar",
target: self,
action: #selector(toggleShowCount(_:))
)
confirmCheckbox = NSButton(
checkboxWithTitle: "Ask for confirmation before Upgrade All",
target: self,
action: #selector(toggleConfirm(_:))
)
let stack = NSStackView(views: [
frequencyRow,
customRow,
greedyCheckbox,
greedyNote,
separator(),
autoUpgradeFormulaeCheckbox,
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
notifyCheckbox,
showCountCheckbox,
confirmCheckbox,
launchAtLoginCheckbox,
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -20),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20),
])
contentView.layoutSubtreeIfNeeded()
window?.setContentSize(contentView.fittingSize)
}
/// Push current values into the controls. Called on every open, since
/// values may have changed while the window was closed.
func syncUI() {
guard let appDelegate else { return }
let interval = appDelegate.refreshInterval
if let presetIndex = presets.firstIndex(where: { $0.interval == interval }) {
intervalPopup.selectItem(at: presetIndex)
} else {
intervalPopup.selectItem(at: presets.count) // Custom
}
customMinutesField.stringValue = "\(Int(interval / 60))"
// SMAppService is the source of truth the user may have changed
// 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
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
showCountCheckbox.state = Settings.showCountInMenuBar ? .on : .off
confirmCheckbox.state = Settings.confirmBeforeUpgradeAll ? .on : .off
}
func separator() -> NSBox {
let box = NSBox()
box.boxType = .separator
return box
}
/// Small gray explanatory text, indented to read as belonging to the
/// checkbox above it.
func noteRow(_ text: String) -> NSStackView {
let label = NSTextField(wrappingLabelWithString: text)
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
label.textColor = .secondaryLabelColor
label.preferredMaxLayoutWidth = 320
let spacer = NSView()
spacer.widthAnchor.constraint(equalToConstant: 18).isActive = true
let row = NSStackView(views: [spacer, label])
row.orientation = .horizontal
return row
}
// MARK: - Actions
@objc func presetPicked(_ sender: NSPopUpButton) {
guard sender.indexOfSelectedItem < presets.count else {
window?.makeFirstResponder(customMinutesField)
return
}
applyInterval(presets[sender.indexOfSelectedItem].interval)
}
@objc func customMinutesEntered(_ sender: NSTextField) {
guard let minutes = Int(sender.stringValue), minutes > 0 else {
syncUI() // reject invalid input by restoring the current value
return
}
applyInterval(TimeInterval(minutes * 60))
}
func applyInterval(_ interval: TimeInterval) {
appDelegate?.refreshInterval = interval
appDelegate?.restartTimer()
syncUI()
}
@objc func toggleGreedy(_ sender: NSButton) {
Settings.includeGreedyCasks = sender.state == .on
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 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 toggleShowCount(_ sender: NSButton) {
Settings.showCountInMenuBar = sender.state == .on
appDelegate?.updateMenuBarCount() // reflect immediately, no refetch needed
}
@objc func toggleConfirm(_ sender: NSButton) {
Settings.confirmBeforeUpgradeAll = sender.state == .on
}
@objc func toggleLaunchAtLogin(_ sender: NSButton) {
do {
if sender.state == .on {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
sender.state = sender.state == .on ? .off : .on // revert on failure
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Could not change login item"
alert.informativeText = error.localizedDescription
alert.runModal()
}
}
}

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