Compare commits

..

24 commits
v0.1 ... main

Author SHA1 Message Date
maxsoch 1c565507a3 release 0.3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:07:45 +02:00
maxsoch 1f90ae20ad Merge branch 'fix/version-label' 2026-07-14 18:02:46 +02:00
maxsoch af220a4ee6 strip the build-id suffix from cask version labels
Some casks (claude and others) version themselves as
"user-facing-version,build-id" — the hash after the comma only
feeds brew's own download URL and was never meant for display,
but showed up verbatim in the Outdated menu. displayVersion() now
truncates at the first comma for both the installed and target
version shown in the label; brew commands are unaffected since
they operate on the package name, never this string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:02:46 +02:00
maxsoch 210a8167b8 Merge branch 'fix/upgrade-only-menu' 2026-07-14 17:54:40 +02:00
maxsoch 778671e751 keep the Upgrade only… submenu visible, grey out the empty kind
It only appeared when both formulae and casks were outdated; with a
single kind it vanished entirely. Now it shows whenever anything is
outdated, with the empty kind greyed out (autoenablesItems is off —
AppKit would otherwise re-enable the items from the responder chain
on every menu open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:54:40 +02:00
maxsoch 390e7e526b Merge branch 'feature/running-apps' 2026-07-14 17:52:56 +02:00
maxsoch 9131c88a64 detect running apps: skip their casks on upgrade, name them in the dialog
RunningApps maps installed casks to their .app bundles via the
Caskroom copy (no brew process needed) and checks them against
NSWorkspace's running applications. Two settings in a new "Running
apps" section: auto-upgrade skips running casks (on by default —
that flow has no dialog to warn anyone), manual bulk upgrades can opt
in. The auto-upgrade filter runs before the trigger decision so a
permanently running app cannot start an upgrade cycle that would skip
it anyway. The confirmation dialog now names the apps actually
running instead of a blanket warning, and stays silent when none are.
Single-package upgrades from the Outdated menu always proceed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:52:56 +02:00
maxsoch 9f54e27e47 release 0.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:24:08 +02:00
maxsoch 3d6f39048a Merge branch 'fix/upgrade-failures' 2026-07-13 19:22:54 +02:00
maxsoch 861c356e80 upgrade formulae one at a time and surface failed upgrades
brew's batch upgrade --formula errors out on the first broken formula
(no bottle for this macOS, deprecated, …), blocking every other
pending formula — and the failure only showed in the logs, so the
menu silently fell back to "N outdated" as if nothing had run.
Formulae now upgrade individually like casks already did, failures
of any kind (including a failed reinstall fallback) are collected,
and the status line reports them once after the closing refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:22:54 +02:00
maxsoch 44705cccae Merge branch 'feature/menu-shortcuts' 2026-07-13 19:11:39 +02:00
maxsoch 1c997751b7 add keyboard shortcuts for Cleanup (C) and Tips (T)
The ⌥ purge alternate carries the same key equivalent as Cleanup —
alternate pairing requires it — so ⌥C triggers the purge variant.
The shortcut list in the Tips window is updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:11:39 +02:00
maxsoch 03c8250def Merge branch 'fix/upgrading-icon' 2026-07-13 19:08:59 +02:00
maxsoch 29501abcd6 show the updating icon during upgrades and cleanup, not just checks
Only fetchOutdated ever touched the menu bar icon, so during an
upgrade it kept showing "updates available". Every long-running
operation now sets the updating icon when it starts; paths that end
without a refetch (cleanup, failed refresh) restore the icon of the
last known state via a new cachedStateIcon() helper. Single-package
upgrades also gained a status line, they showed nothing before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:08:59 +02:00
maxsoch 20ca403d53 Merge branch 'feature/tips' 2026-07-13 19:04:08 +02:00
maxsoch 19e63f0dcd add a Tips window for the app's less discoverable features
Lists the ⌥ cleanup variant, menu keyboard shortcuts, selective
upgrades, the self-updating cask reinstall behavior and the Logs
reflex. Content is a plain array in TipsWindowController — adding a
tip is one entry. No pbxproj change needed: the project uses Xcode 16
synchronized groups, so new files in BrewBar/ are picked up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:04:08 +02:00
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
maxsoch 6a1c92a6fd Merge branch 'feature/cleanup'
Resolved by combining both sides in upgradeSingle (two-param runBrew
closure from fix/offline-refresh + cleanup hook from feature/cleanup)
and adapting the cleanup branch's runBrew calls to the new signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:36:08 +02:00
maxsoch 4494b937bf Merge branch 'fix/offline-refresh' 2026-07-13 18:34:14 +02:00
maxsoch e2413e52d2 add brew cleanup: menu action, ⌥ purge variant, optional post-upgrade run
A Cleanup menu item runs brew cleanup; holding ⌥ swaps it for a purge
variant (cleanup --prune=all, which also drops cached downloads of
current versions). A new opt-out-by-default setting interposes a plain
cleanup between the end of any upgrade path and its closing refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:37:41 +02:00
maxsoch 64b9bae564 surface brew failures instead of reporting a false all-up-to-date
runBrew now reports the process exit status to its completion handler.
refreshAll and fetchOutdated stop on failure and show "Check failed —
see Logs": NWPathMonitor cannot see a network that is up but broken
(captive portal, dead DNS, git host down), while the exit code of the
brew command itself catches every failure mode. Upgrade commands keep
ignoring the flag on purpose — a failed upgrade leaves the package in
the outdated list at the next fetch, so the final state stays correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:28:09 +02:00
maxsoch c6372ece05 skip refreshes while offline, catch up when the connection returns
brew outdated never touches the network: offline, a refresh silently
succeeded on stale local tap data and reported "all up to date". An
NWPathMonitor now gates refreshAll — offline shows a dedicated status
instead, and every offline→online transition triggers a refresh, which
covers the launch refresh, scheduled refreshes skipped during an
outage, and clearing the offline status when the connection returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:27:41 +02:00
9 changed files with 519 additions and 59 deletions

View file

@ -352,7 +352,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@ -400,7 +400,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@ -428,7 +428,7 @@
DEVELOPMENT_TEAM = GLVUYYHFD9;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.3;
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
@ -449,7 +449,7 @@
DEVELOPMENT_TEAM = GLVUYYHFD9;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.3;
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;

View file

@ -1,4 +1,5 @@
import Cocoa
import Network
import UserNotifications
@main
@ -17,11 +18,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var statusMenuItem: NSMenuItem!
var outdatedItem: NSMenuItem!
var upgradeAllItem: NSMenuItem!
var upgradeOnlyItem: NSMenuItem!
var upgradeFormulaeItem: NSMenuItem!
var upgradeCasksItem: NSMenuItem!
var outdatedSubmenu: NSMenu!
var versionItem: NSMenuItem!
var logWindow: LogWindowController?
var settingsWindow: SettingsWindowController?
var tipsWindow: TipsWindowController?
var logBuffer: String = ""
var brewPath: String?
@ -42,6 +47,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: OutdatedPackages = .none
var lastNotifiedOutdated: Set<String> = []
/// packages whose last upgrade attempt failed; reset when an upgrade
/// run starts, displayed once by updateStatus after the run's refetch
var failedUpgrades: [String] = []
let pathMonitor = NWPathMonitor()
/// false until the monitor's first update, so the launch refresh waits
/// until the network state is actually known. Main-queue only.
var isOnline = false
// MARK: - Menu
@ -68,19 +81,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(NSMenuItem.separator())
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
menu.addItem(upgradeAllItem)
upgradeOnlyItem = NSMenuItem(title: "🎯 Upgrade only…", action: nil, keyEquivalent: "")
let upgradeOnlySubmenu = NSMenu()
// manual isEnabled control below; otherwise AppKit re-enables the
// items from the responder chain every time the menu opens
upgradeOnlySubmenu.autoenablesItems = false
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: "")
outdatedSubmenu = NSMenu()
outdatedItem.submenu = outdatedSubmenu
outdatedItem.isHidden = true // shown once a fetch finds outdated packages
menu.addItem(outdatedItem)
menu.addItem(NSMenuItem(title: "🧹 Cleanup", action: #selector(cleanupAction), keyEquivalent: "c"))
// isAlternate swaps this in for the item right above while is held;
// the pairing requires the same key equivalent with other modifiers
let purgeItem = NSMenuItem(title: "🧹 Cleanup (purge cache)", action: #selector(cleanupPurgeAction), keyEquivalent: "c")
purgeItem.keyEquivalentModifierMask = .option
purgeItem.isAlternate = true
menu.addItem(purgeItem)
menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l"))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem(title: "💡 Tips", action: #selector(showTips), keyEquivalent: "t"))
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
menu.addItem(versionItem)
@ -91,7 +124,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusItem.menu = menu
refreshAll()
// no refreshAll() here: the monitor fires once immediately after
// start(), and the first online update triggers the launch refresh
pathMonitor.pathUpdateHandler = { path in
// updates arrive on the monitor's queue; hop to main for AppKit
DispatchQueue.main.async {
self.networkPathChanged(online: path.status == .satisfied)
}
}
pathMonitor.start(queue: DispatchQueue(label: "fr.socheleau.BrewBar.network"))
refreshBrewVersion()
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
@ -113,6 +155,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
/// icon matching the last fetched state, for operations that end
/// without triggering a refetch
func cachedStateIcon() -> String {
cachedOutdated.isEmpty ? "brewbar-uptodate" : "brewbar-outdated"
}
func restartTimer() {
refreshTimer?.invalidate() // stop running timer
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
@ -128,6 +176,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
presentWindow(of: settingsWindow)
}
@objc func showTips() {
if tipsWindow == nil {
tipsWindow = TipsWindowController()
}
presentWindow(of: tipsWindow)
}
// MARK: - Window presentation
/// Accessory apps never appear in Cmd-Tab and their windows can open
@ -144,7 +199,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func windowWillClose(_: Notification) {
// async so the closing window's isVisible has flipped to false
DispatchQueue.main.async {
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window]
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window, self.tipsWindow?.window]
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
if !stillOpen {
NSApp.setActivationPolicy(.accessory)
@ -205,7 +260,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
/// defaults to naming the brew command.
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) {
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
Self.brewQueue.async {
let brew = self.resolveBrewPath()
let cleaned = command.replacingOccurrences(of: "brew ", with: "")
@ -231,7 +286,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try process.run()
} catch {
self.log("ERROR: \(error)\n")
DispatchQueue.main.async { completion("") }
DispatchQueue.main.async { completion("", false) }
return
}
@ -241,8 +296,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let output = String(data: data, encoding: .utf8) ?? ""
self.log(output)
let success = process.terminationStatus == 0
if !success {
self.log("✗ exited with status \(process.terminationStatus)\n")
}
// completion always on main: callers update AppKit UI, which is main-thread only
DispatchQueue.main.async { completion(output) }
DispatchQueue.main.async { completion(output, success) }
}
}
@ -252,31 +311,96 @@ class AppDelegate: NSObject, NSApplicationDelegate {
refreshAll()
}
@objc func cleanupAction() {
runCleanup(purgeCache: false)
}
@objc func cleanupPurgeAction() {
runCleanup(purgeCache: true)
}
/// --prune=all also deletes cached downloads brew would normally keep
/// (current versions); the only cost is re-downloading at next install
func runCleanup(purgeCache: Bool) {
statusMenuItem.title = "Cleaning up..."
setMenuBarIcon("brewbar-updating")
runBrew(purgeCache ? "cleanup --prune=all" : "cleanup") { _, _ in
self.updateStatus(count: self.cachedOutdated.count)
self.setMenuBarIcon(self.cachedStateIcon())
}
}
/// Interposed between the end of an upgrade and its closing refetch;
/// passes straight through unless the user opted in.
func cleanupAfterUpgradeIfEnabled(completion: @escaping () -> Void) {
guard Settings.cleanupAfterUpgrade else {
completion()
return
}
statusMenuItem.title = "Cleaning up..."
runBrew("cleanup") { _, _ in completion() }
}
@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 {
guard confirmUpgrade() else { return }
guard confirmUpgrade(formulae: formulae, casks: casks) else { return }
}
statusMenuItem.title = "Upgrading..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
runBrew("update") { _ in
runBrew("update") { _, _ in
// packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
let caskNames = self.cachedOutdated.casks.map(\.name)
let upgradeCasksThenRefresh = {
let formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : []
var caskNames = casks ? self.cachedOutdated.casks.map(\.name) : []
if Settings.skipRunningCasksManual, !caskNames.isEmpty {
let running = RunningApps.runningCasks(caskNames, brewPath: self.resolveBrewPath())
if !running.isEmpty {
self.log("upgrade: skipping running app\(running.count == 1 ? "" : "s"): \(running.joined(separator: ", "))\n")
caskNames.removeAll(where: running.contains)
}
}
self.upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) {
// everything was just upgraded; don't chain an auto-upgrade
self.cleanupAfterUpgradeIfEnabled {
// a manual upgrade just ran; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false)
}
}
}
}
}
if self.cachedOutdated.formulae.isEmpty {
upgradeCasksThenRefresh()
} else {
self.runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
/// Formulae also upgrade one at a time: a single broken formula (no
/// bottle for this macOS, deprecated, ) makes brew's batch upgrade
/// error out, taking every other pending formula down with it.
func upgradeFormulaeSequentially(_ names: [String], completion: @escaping () -> Void) {
guard let next = names.first else {
completion()
return
}
statusMenuItem.title = "Upgrading \(next)..."
runBrew("upgrade --formula \(next)", askpassMessage: "upgrade \(next)") { _, success in
if !success {
self.failedUpgrades.append(next)
}
self.upgradeFormulaeSequentially(Array(names.dropFirst()), completion: completion)
}
}
@ -289,27 +413,63 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
statusMenuItem.title = "Upgrading \(next)..."
// an explicitly named cask upgrades even when self-updating, no --greedy needed
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _ in
upgradeCask(next) {
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, success in
guard output.contains("cannot be upgraded as-is") else {
if !success {
self.failedUpgrades.append(name)
}
completion()
return
}
self.runBrew("reinstall --cask --force \(name)", askpassMessage: "reinstall \(name)") { _, reinstalled in
if !reinstalled {
self.failedUpgrades.append(name)
}
completion()
}
}
}
func confirmUpgrade(formulae: Bool, casks: Bool) -> Bool {
let packages = (formulae ? cachedOutdated.formulae : []) + (casks ? cachedOutdated.casks : [])
let alert = NSAlert()
alert.alertStyle = .warning
if cachedOutdated.isEmpty {
if packages.isEmpty {
alert.messageText = "Upgrade all outdated packages?"
} 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."
if !cachedOutdated.isEmpty {
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
// warn only about apps that are actually running right now, by name
var lines: [String] = []
if casks {
let running = RunningApps.runningCasks(cachedOutdated.casks.map(\.name), brewPath: resolveBrewPath())
if !running.isEmpty {
let list = running.joined(separator: ", ")
if Settings.skipRunningCasksManual {
lines.append("Currently running, will be skipped: \(list).")
} else {
lines.append("Currently running: \(list). These apps may be closed and replaced mid-use, 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: "Cancel")
@ -328,7 +488,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// --greedy also lists casks that self-update (brew skips them by default)
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
runBrew(command) { output in
runBrew(command) { output, success in
// parsing a failure's output would yield .none and masquerade as
// "all up to date"; keep the cached state and its icon instead
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
self.setMenuBarIcon(self.cachedStateIcon())
return
}
let outdated = BrewParser.parseOutdatedJSON(output)
self.cachedOutdated = outdated
@ -342,9 +510,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// 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)
var autoCaskNames = Settings.autoUpgradeCasks ? outdated.casks.map(\.name) : []
// filtered before the trigger decision: a permanently running app
// must not start an upgrade cycle that would skip it anyway
if Settings.skipRunningCasksAuto, !autoCaskNames.isEmpty {
let running = RunningApps.runningCasks(autoCaskNames, brewPath: self.resolveBrewPath())
if !running.isEmpty {
self.log("auto-upgrade: skipping running app\(running.count == 1 ? "" : "s"): \(running.joined(separator: ", "))\n")
autoCaskNames.removeAll(where: running.contains)
}
}
if allowAutoUpgrade, upgradeFormulae || !autoCaskNames.isEmpty {
self.runAutoUpgrade(formulae: upgradeFormulae, caskNames: autoCaskNames)
return
}
@ -376,22 +553,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().add(request)
}
func runAutoUpgrade(formulae: Bool, casks: Bool) {
func runAutoUpgrade(formulae: Bool, caskNames: [String]) {
statusMenuItem.title = "Auto-upgrading..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = {
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) {
self.cleanupAfterUpgradeIfEnabled {
self.fetchOutdated(allowAutoUpgrade: false)
}
}
if formulae {
runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
}
}
@ -399,6 +573,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = outdated.isEmpty
// visible whenever anything is outdated; a kind with nothing to
// upgrade stays listed but greyed out
upgradeOnlyItem.isHidden = outdated.isEmpty
upgradeFormulaeItem.title = outdated.formulae.isEmpty ? "Formulae" : "Formulae (\(outdated.formulae.count))"
upgradeFormulaeItem.isEnabled = !outdated.formulae.isEmpty
upgradeCasksItem.title = outdated.casks.isEmpty ? "Casks" : "Casks (\(outdated.casks.count))"
upgradeCasksItem.isEnabled = !outdated.casks.isEmpty
for package in outdated.all {
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
@ -416,7 +597,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func updateStatus(count: Int) {
if count == 0 {
// surface what the last upgrade run could not handle brew only
// reports these in its output, which lands in the logs. Consumed
// here so the next plain refresh shows the normal state again.
if !failedUpgrades.isEmpty {
statusMenuItem.title = "⚠️ Upgrade failed: \(failedUpgrades.joined(separator: ", ")) — see Logs"
failedUpgrades = []
} else if count == 0 {
statusMenuItem.title = "✅ All up to date"
} else {
statusMenuItem.title = "⚠️ \(count) outdated"
@ -424,24 +611,75 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
@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
statusMenuItem.title = "Upgrading \(name)..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let finish = {
self.cleanupAfterUpgradeIfEnabled {
self.refreshAll()
}
}
func refreshAll() {
statusMenuItem.title = "Updating..."
// 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)") { _, success in
if !success {
self.failedUpgrades.append(name)
}
finish()
}
}
}
runBrew("update") { _ in
/// Refreshing offline would silently succeed on stale data: brew update
/// fails but its exit code was never checked, and brew outdated compares
/// against the local taps without touching the network so the app
/// claimed "all up to date" no matter what. Every offlineonline
/// transition refreshes, which also covers the refreshes skipped below.
func networkPathChanged(online: Bool) {
let wasOnline = isOnline
isOnline = online
guard online else {
statusMenuItem.title = "📡 Offline"
return
}
if !wasOnline {
refreshAll()
}
}
func refreshAll() {
guard isOnline else {
statusMenuItem.title = "📡 Offline"
return
}
statusMenuItem.title = "Updating..."
setMenuBarIcon("brewbar-updating")
// NWPathMonitor can't see a network that is up but broken (captive
// portal, dead DNS, git host down); brew update's exit code can.
// Proceeding anyway would read stale local data and report a false OK.
runBrew("update") { _, success in
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
self.setMenuBarIcon(self.cachedStateIcon())
return
}
self.fetchOutdated() // ONLY place calling outdated
}
}
/// Brew version (correct)
func refreshBrewVersion() {
runBrew("--version") { output in
runBrew("--version") { output, _ in
let firstLine = BrewParser.parseVersion(output)
self.versionItem.title = "🏷 \(firstLine)"
}

View file

@ -7,8 +7,15 @@ struct OutdatedPackage: Codable, Equatable {
/// Menu label, e.g. "wget 1.21 1.22"
var label: String {
let installed = installedVersions.joined(separator: ", ")
return "\(name) \(installed)\(currentVersion)"
let installed = installedVersions.map(Self.displayVersion).joined(separator: ", ")
return "\(name) \(installed)\(Self.displayVersion(currentVersion))"
}
/// Some casks (e.g. claude: "1.20186.9,69f150a4c9") version themselves
/// as "user-facing-version,build-id" the part after the comma only
/// matters for brew's own download URL, never for display.
static func displayVersion(_ version: String) -> String {
String(version.split(separator: ",", maxSplits: 1)[0])
}
}

46
BrewBar/RunningApps.swift Normal file
View file

@ -0,0 +1,46 @@
import AppKit
/// Maps installed casks to their .app bundles and checks them against the
/// apps currently running. The mapping comes from the Caskroom: brew keeps
/// a copy of every installed cask's artifacts there, so listing
/// <prefix>/Caskroom/<token>/<version>/*.app gives the app names without
/// spawning a brew process.
enum RunningApps {
/// Caskroom lives next to the brew binary: <prefix>/bin/brew <prefix>/Caskroom
static func caskroomURL(brewPath: String) -> URL {
URL(fileURLWithPath: brewPath)
.deletingLastPathComponent() // <prefix>/bin
.deletingLastPathComponent() // <prefix>
.appendingPathComponent("Caskroom", isDirectory: true)
}
/// .app bundle names (e.g. "Firefox.app") found in any installed version
/// of the cask. Casks without an app artifact (fonts, pkg-based
/// installers) return [] and are never considered running.
static func appNames(forCask token: String, brewPath: String) -> [String] {
let caskDir = caskroomURL(brewPath: brewPath).appendingPathComponent(token, isDirectory: true)
let fm = FileManager.default
guard let versions = try? fm.contentsOfDirectory(atPath: caskDir.path) else { return [] }
var names: Set<String> = []
for version in versions where !version.hasPrefix(".") {
let versionDir = caskDir.appendingPathComponent(version, isDirectory: true)
for entry in (try? fm.contentsOfDirectory(atPath: versionDir.path)) ?? [] where entry.hasSuffix(".app") {
names.insert(entry)
}
}
return Array(names)
}
/// Subset of the given cask tokens whose app is currently running.
/// Matches on the bundle name the running copy lives in /Applications,
/// the Caskroom one is brew's, but both carry the same name.
static func runningCasks(_ tokens: [String], brewPath: String) -> [String] {
let running = Set(NSWorkspace.shared.runningApplications.compactMap {
$0.bundleURL?.lastPathComponent
})
return tokens.filter { token in
appNames(forCask: token, brewPath: brewPath).contains { running.contains($0) }
}
}
}

View file

@ -12,6 +12,7 @@ enum Settings {
UserDefaults.standard.register(defaults: [
"includeGreedyCasks": true,
"confirmBeforeUpgradeAll": true,
"skipRunningCasksAuto": true,
])
}
@ -44,4 +45,23 @@ enum Settings {
get { UserDefaults.standard.bool(forKey: "confirmBeforeUpgradeAll") }
set { UserDefaults.standard.set(newValue, forKey: "confirmBeforeUpgradeAll") }
}
static var cleanupAfterUpgrade: Bool {
get { UserDefaults.standard.bool(forKey: "cleanupAfterUpgrade") }
set { UserDefaults.standard.set(newValue, forKey: "cleanupAfterUpgrade") }
}
/// auto-upgrade never touches a cask whose app is running (on by default:
/// there is no dialog to warn anyone in that flow)
static var skipRunningCasksAuto: Bool {
get { UserDefaults.standard.bool(forKey: "skipRunningCasksAuto") }
set { UserDefaults.standard.set(newValue, forKey: "skipRunningCasksAuto") }
}
/// manual bulk upgrades skip running apps too (off by default: the
/// confirmation dialog already names them)
static var skipRunningCasksManual: Bool {
get { UserDefaults.standard.bool(forKey: "skipRunningCasksManual") }
set { UserDefaults.standard.set(newValue, forKey: "skipRunningCasksManual") }
}
}

View file

@ -22,6 +22,9 @@ class SettingsWindowController: NSWindowController {
var notifyCheckbox: NSButton!
var showCountCheckbox: NSButton!
var confirmCheckbox: NSButton!
var cleanupCheckbox: NSButton!
var skipRunningAutoCheckbox: NSButton!
var skipRunningManualCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
let window = NSWindow(
@ -107,6 +110,26 @@ class SettingsWindowController: NSWindowController {
"⚠️ Upgrading a cask can close and replace the app while it is running (e.g. your browser), without warning."
)
let runningAppsHeader = NSTextField(labelWithString: "Running apps")
runningAppsHeader.font = .boldSystemFont(ofSize: NSFont.smallSystemFontSize)
runningAppsHeader.textColor = .secondaryLabelColor
skipRunningAutoCheckbox = NSButton(
checkboxWithTitle: "Auto-upgrade: skip casks whose app is running",
target: self,
action: #selector(toggleSkipRunningAuto(_:))
)
skipRunningManualCheckbox = NSButton(
checkboxWithTitle: "Manual upgrades: skip them too",
target: self,
action: #selector(toggleSkipRunningManual(_:))
)
let runningAppsNote = noteRow(
"Skipped apps stay in the outdated list and upgrade at the first check after they quit. Upgrading a single package from the Outdated menu always proceeds."
)
notifyCheckbox = NSButton(
checkboxWithTitle: "Notify when new updates are found",
target: self,
@ -120,11 +143,17 @@ class SettingsWindowController: NSWindowController {
)
confirmCheckbox = NSButton(
checkboxWithTitle: "Ask for confirmation before Upgrade All",
checkboxWithTitle: "Ask for confirmation before bulk upgrades",
target: self,
action: #selector(toggleConfirm(_:))
)
cleanupCheckbox = NSButton(
checkboxWithTitle: "Run brew cleanup after upgrades",
target: self,
action: #selector(toggleCleanup(_:))
)
let stack = NSStackView(views: [
frequencyRow,
customRow,
@ -135,9 +164,15 @@ class SettingsWindowController: NSWindowController {
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
runningAppsHeader,
skipRunningAutoCheckbox,
skipRunningManualCheckbox,
runningAppsNote,
separator(),
notifyCheckbox,
showCountCheckbox,
confirmCheckbox,
cleanupCheckbox,
launchAtLoginCheckbox,
])
stack.orientation = .vertical
@ -179,6 +214,9 @@ class SettingsWindowController: NSWindowController {
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
showCountCheckbox.state = Settings.showCountInMenuBar ? .on : .off
confirmCheckbox.state = Settings.confirmBeforeUpgradeAll ? .on : .off
cleanupCheckbox.state = Settings.cleanupAfterUpgrade ? .on : .off
skipRunningAutoCheckbox.state = Settings.skipRunningCasksAuto ? .on : .off
skipRunningManualCheckbox.state = Settings.skipRunningCasksManual ? .on : .off
}
func separator() -> NSBox {
@ -311,6 +349,18 @@ class SettingsWindowController: NSWindowController {
Settings.confirmBeforeUpgradeAll = sender.state == .on
}
@objc func toggleCleanup(_ sender: NSButton) {
Settings.cleanupAfterUpgrade = sender.state == .on
}
@objc func toggleSkipRunningAuto(_ sender: NSButton) {
Settings.skipRunningCasksAuto = sender.state == .on
}
@objc func toggleSkipRunningManual(_ sender: NSButton) {
Settings.skipRunningCasksManual = sender.state == .on
}
@objc func toggleLaunchAtLogin(_ sender: NSButton) {
do {
if sender.state == .on {

View file

@ -0,0 +1,78 @@
import Cocoa
/// Static "good to know" window for the features a menu can't explain
/// by itself. Content lives in the tips array adding one there is all
/// it takes.
class TipsWindowController: NSWindowController {
let tips: [(title: String, body: String)] = [
("⌥ reveals hidden menu items",
"With the menu open, hold Option (⌥): Cleanup becomes Cleanup (purge cache), which also empties Homebrew's download cache (brew cleanup --prune=all). Downloads are simply re-fetched when next needed."),
("Keyboard shortcuts",
"While the menu is open: R refreshes, U upgrades formulae & casks, C cleans up, L opens the logs, T shows these tips, comma (,) opens Settings and Q quits."),
("Upgrade only one kind",
"When both formulae and casks are outdated, an “Upgrade only…” submenu appears under the main upgrade item to handle just one of the two."),
("Self-updating apps",
"Apps that update themselves (browsers, editors…) can drift from what brew installed, and brew then refuses a normal upgrade. BrewBar detects this and automatically reinstalls the cask instead — you may see reinstall --cask --force in the logs; that is expected."),
("When something looks off, read the Logs",
"Every brew command BrewBar runs is in the Logs window with its full output — errors, warnings and all. It is the first place to look."),
]
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 440, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "BrewBar Tips"
// 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)
buildUI()
window.center()
}
func buildUI() {
guard let contentView = window?.contentView else { return }
let tipViews: [NSView] = tips.map { tip in
let title = NSTextField(labelWithString: tip.title)
title.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
let body = NSTextField(wrappingLabelWithString: tip.body)
body.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
body.textColor = .secondaryLabelColor
body.preferredMaxLayoutWidth = 400
let tipStack = NSStackView(views: [title, body])
tipStack.orientation = .vertical
tipStack.alignment = .leading
tipStack.spacing = 4
return tipStack
}
let stack = NSStackView(views: tipViews)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 16
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)
}
}

View file

@ -1,6 +1,18 @@
@testable import BrewBar
import XCTest
final class RunningAppsTests: XCTestCase {
func testCaskroomURL_appleSilicon() {
let url = RunningApps.caskroomURL(brewPath: "/opt/homebrew/bin/brew")
XCTAssertEqual(url.path, "/opt/homebrew/Caskroom")
}
func testCaskroomURL_intel() {
let url = RunningApps.caskroomURL(brewPath: "/usr/local/bin/brew")
XCTAssertEqual(url.path, "/usr/local/Caskroom")
}
}
final class BrewParserTests: XCTestCase {
// MARK: - parseOutdatedJSON
@ -52,6 +64,15 @@ final class BrewParserTests: XCTestCase {
XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4")
}
func testParseOutdatedJSON_packageLabel_stripsBuildIdAfterComma() {
let package = OutdatedPackage(
name: "claude",
installedVersions: ["1.20185.0,5e8f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f"],
currentVersion: "1.20186.9,69f150a4c9316d5c8cd7b9f130ed583d15c0383e"
)
XCTAssertEqual(package.label, "claude 1.20185.0 → 1.20186.9")
}
// MARK: - parseVersion
func testParseVersion_picksFirstLine() {

View file

@ -1,6 +1,6 @@
cask "brewbar" do
version "0.1"
sha256 "5b37d2457d12ca42f61d3d95476987e965b3d56927c35be68abc91b047294a88"
version "0.3"
sha256 "d87a744e034c2ce0f92a24c3ebba167533419f948337a2dbedd393152d08d13d"
url "https://forgejo.socheleau.fr/maxsoch/BrewBar/releases/download/v#{version}/BrewBar-#{version}.zip"
name "BrewBar"