Compare commits

...

2 commits

Author SHA1 Message Date
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
5 changed files with 141 additions and 8 deletions

View file

@ -363,7 +363,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
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.upgradeCasksSequentially(caskNames) {
@ -443,10 +450,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
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] = []
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 {
lines.append(packages.map(\.label).joined(separator: "\n"))
@ -492,9 +507,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
}
@ -526,13 +550,12 @@ 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 formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) {

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,
])
}
@ -49,4 +50,18 @@ enum Settings {
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

@ -23,6 +23,8 @@ class SettingsWindowController: NSWindowController {
var showCountCheckbox: NSButton!
var confirmCheckbox: NSButton!
var cleanupCheckbox: NSButton!
var skipRunningAutoCheckbox: NSButton!
var skipRunningManualCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
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."
)
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,
@ -142,6 +164,11 @@ class SettingsWindowController: NSWindowController {
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
runningAppsHeader,
skipRunningAutoCheckbox,
skipRunningManualCheckbox,
runningAppsNote,
separator(),
notifyCheckbox,
showCountCheckbox,
confirmCheckbox,
@ -188,6 +215,8 @@ class SettingsWindowController: NSWindowController {
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 {
@ -324,6 +353,14 @@ class SettingsWindowController: NSWindowController {
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

@ -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