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>
This commit is contained in:
parent
9f54e27e47
commit
9131c88a64
|
|
@ -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
46
BrewBar/RunningApps.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue