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