Merge branch 'feature/running-apps'

This commit is contained in:
maxsoch 2026-07-14 17:52:56 +02:00
commit 390e7e526b
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 // 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 +450,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 +507,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 +550,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) {

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