Merge branch 'feature/running-apps'
This commit is contained in:
commit
390e7e526b
|
|
@ -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
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: [
|
||||
"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") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue