From 9131c88a648403978d48e4a59253586dd1113840 Mon Sep 17 00:00:00 2001 From: maxsoch Date: Tue, 14 Jul 2026 17:52:56 +0200 Subject: [PATCH] detect running apps: skip their casks on upgrade, name them in the dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BrewBar/BrewBarApp.swift | 39 +++++++++++++++++----- BrewBar/RunningApps.swift | 46 ++++++++++++++++++++++++++ BrewBar/Settings.swift | 15 +++++++++ BrewBar/SettingsWindowController.swift | 37 +++++++++++++++++++++ BrewBarTests/BrewBarTests.swift | 12 +++++++ 5 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 BrewBar/RunningApps.swift diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index 03d4727..4df8fd1 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -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) { diff --git a/BrewBar/RunningApps.swift b/BrewBar/RunningApps.swift new file mode 100644 index 0000000..f92b326 --- /dev/null +++ b/BrewBar/RunningApps.swift @@ -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 +/// /Caskroom///*.app gives the app names without +/// spawning a brew process. +enum RunningApps { + /// Caskroom lives next to the brew binary: /bin/brew → /Caskroom + static func caskroomURL(brewPath: String) -> URL { + URL(fileURLWithPath: brewPath) + .deletingLastPathComponent() // /bin + .deletingLastPathComponent() // + .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 = [] + 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) } + } + } +} diff --git a/BrewBar/Settings.swift b/BrewBar/Settings.swift index 24eac13..b6aa3e7 100644 --- a/BrewBar/Settings.swift +++ b/BrewBar/Settings.swift @@ -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") } + } } diff --git a/BrewBar/SettingsWindowController.swift b/BrewBar/SettingsWindowController.swift index 0e84065..311e445 100644 --- a/BrewBar/SettingsWindowController.swift +++ b/BrewBar/SettingsWindowController.swift @@ -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 { diff --git a/BrewBarTests/BrewBarTests.swift b/BrewBarTests/BrewBarTests.swift index fbff287..8cd9f62 100644 --- a/BrewBarTests/BrewBarTests.swift +++ b/BrewBarTests/BrewBarTests.swift @@ -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