Compare commits

..

7 commits
v0.2 ... 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
8 changed files with 175 additions and 21 deletions

View file

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

View file

@ -86,6 +86,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(upgradeAllItem) menu.addItem(upgradeAllItem)
upgradeOnlyItem = NSMenuItem(title: "🎯 Upgrade only…", action: nil, keyEquivalent: "") upgradeOnlyItem = NSMenuItem(title: "🎯 Upgrade only…", action: nil, keyEquivalent: "")
let upgradeOnlySubmenu = NSMenu() 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: "") upgradeFormulaeItem = NSMenuItem(title: "Formulae", action: #selector(upgradeFormulaeOnly), keyEquivalent: "")
upgradeCasksItem = NSMenuItem(title: "Casks", action: #selector(upgradeCasksOnly), keyEquivalent: "") upgradeCasksItem = NSMenuItem(title: "Casks", action: #selector(upgradeCasksOnly), keyEquivalent: "")
upgradeOnlySubmenu.addItem(upgradeFormulaeItem) upgradeOnlySubmenu.addItem(upgradeFormulaeItem)
@ -363,7 +366,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// 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 formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : [] let formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : []
let caskNames = casks ? self.cachedOutdated.casks.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.upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) { self.upgradeCasksSequentially(caskNames) {
@ -443,10 +453,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
alert.messageText = "Upgrade \(packages.count) package\(packages.count == 1 ? "" : "s")?" alert.messageText = "Upgrade \(packages.count) package\(packages.count == 1 ? "" : "s")?"
} }
// the running-app warning only applies when casks are being upgraded // warn only about apps that are actually running right now, by name
var lines: [String] = [] var lines: [String] = []
if casks { 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.") 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.")
}
}
} }
if !packages.isEmpty { if !packages.isEmpty {
lines.append(packages.map(\.label).joined(separator: "\n")) lines.append(packages.map(\.label).joined(separator: "\n"))
@ -492,9 +510,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// only trigger for the kinds that are both enabled AND outdated // only trigger for the kinds that are both enabled AND outdated
// e.g. an outdated cask must not start a pointless formulae upgrade // e.g. an outdated cask must not start a pointless formulae upgrade
let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty
let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty var autoCaskNames = Settings.autoUpgradeCasks ? outdated.casks.map(\.name) : []
if allowAutoUpgrade, upgradeFormulae || upgradeCasks { // filtered before the trigger decision: a permanently running app
self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks) // 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 return
} }
@ -526,13 +553,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
} }
func runAutoUpgrade(formulae: Bool, casks: Bool) { func runAutoUpgrade(formulae: Bool, caskNames: [String]) {
statusMenuItem.title = "Auto-upgrading..." statusMenuItem.title = "Auto-upgrading..."
setMenuBarIcon("brewbar-updating") setMenuBarIcon("brewbar-updating")
failedUpgrades = [] failedUpgrades = []
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : [] let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
upgradeFormulaeSequentially(formulaNames) { upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) { self.upgradeCasksSequentially(caskNames) {
@ -547,11 +573,13 @@ 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 // visible whenever anything is outdated; a kind with nothing to
// single kind, the main item already upgrades exactly what there is // upgrade stays listed but greyed out
upgradeOnlyItem.isHidden = outdated.formulae.isEmpty || outdated.casks.isEmpty upgradeOnlyItem.isHidden = outdated.isEmpty
upgradeFormulaeItem.title = "Formulae (\(outdated.formulae.count))" upgradeFormulaeItem.title = outdated.formulae.isEmpty ? "Formulae" : "Formulae (\(outdated.formulae.count))"
upgradeCasksItem.title = "Casks (\(outdated.casks.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 { 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: "")

View file

@ -7,8 +7,15 @@ struct OutdatedPackage: Codable, Equatable {
/// Menu label, e.g. "wget 1.21 1.22" /// Menu label, e.g. "wget 1.21 1.22"
var label: String { var label: String {
let installed = installedVersions.joined(separator: ", ") let installed = installedVersions.map(Self.displayVersion).joined(separator: ", ")
return "\(name) \(installed)\(currentVersion)" 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: [ UserDefaults.standard.register(defaults: [
"includeGreedyCasks": true, "includeGreedyCasks": true,
"confirmBeforeUpgradeAll": true, "confirmBeforeUpgradeAll": true,
"skipRunningCasksAuto": true,
]) ])
} }
@ -49,4 +50,18 @@ enum Settings {
get { UserDefaults.standard.bool(forKey: "cleanupAfterUpgrade") } get { UserDefaults.standard.bool(forKey: "cleanupAfterUpgrade") }
set { UserDefaults.standard.set(newValue, 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

@ -23,6 +23,8 @@ class SettingsWindowController: NSWindowController {
var showCountCheckbox: NSButton! var showCountCheckbox: NSButton!
var confirmCheckbox: NSButton! var confirmCheckbox: NSButton!
var cleanupCheckbox: NSButton! var cleanupCheckbox: NSButton!
var skipRunningAutoCheckbox: NSButton!
var skipRunningManualCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) { convenience init(appDelegate: AppDelegate) {
let window = NSWindow( let window = NSWindow(
@ -108,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." "⚠️ 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( notifyCheckbox = NSButton(
checkboxWithTitle: "Notify when new updates are found", checkboxWithTitle: "Notify when new updates are found",
target: self, target: self,
@ -142,6 +164,11 @@ class SettingsWindowController: NSWindowController {
autoUpgradeCasksCheckbox, autoUpgradeCasksCheckbox,
caskWarningRow, caskWarningRow,
separator(), separator(),
runningAppsHeader,
skipRunningAutoCheckbox,
skipRunningManualCheckbox,
runningAppsNote,
separator(),
notifyCheckbox, notifyCheckbox,
showCountCheckbox, showCountCheckbox,
confirmCheckbox, confirmCheckbox,
@ -188,6 +215,8 @@ class SettingsWindowController: NSWindowController {
showCountCheckbox.state = Settings.showCountInMenuBar ? .on : .off showCountCheckbox.state = Settings.showCountInMenuBar ? .on : .off
confirmCheckbox.state = Settings.confirmBeforeUpgradeAll ? .on : .off confirmCheckbox.state = Settings.confirmBeforeUpgradeAll ? .on : .off
cleanupCheckbox.state = Settings.cleanupAfterUpgrade ? .on : .off cleanupCheckbox.state = Settings.cleanupAfterUpgrade ? .on : .off
skipRunningAutoCheckbox.state = Settings.skipRunningCasksAuto ? .on : .off
skipRunningManualCheckbox.state = Settings.skipRunningCasksManual ? .on : .off
} }
func separator() -> NSBox { func separator() -> NSBox {
@ -324,6 +353,14 @@ class SettingsWindowController: NSWindowController {
Settings.cleanupAfterUpgrade = sender.state == .on 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) { @objc func toggleLaunchAtLogin(_ sender: NSButton) {
do { do {
if sender.state == .on { if sender.state == .on {

View file

@ -1,6 +1,18 @@
@testable import BrewBar @testable import BrewBar
import XCTest 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 { final class BrewParserTests: XCTestCase {
// MARK: - parseOutdatedJSON // MARK: - parseOutdatedJSON
@ -52,6 +64,15 @@ final class BrewParserTests: XCTestCase {
XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4") 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 // MARK: - parseVersion
func testParseVersion_picksFirstLine() { func testParseVersion_picksFirstLine() {

View file

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