Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c565507a3 | ||
|
|
1f90ae20ad | ||
|
|
af220a4ee6 | ||
|
|
210a8167b8 | ||
|
|
778671e751 | ||
|
|
390e7e526b | ||
|
|
9131c88a64 | ||
|
|
9f54e27e47 | ||
|
|
3d6f39048a | ||
|
|
861c356e80 | ||
|
|
44705cccae | ||
|
|
1c997751b7 | ||
|
|
03c8250def | ||
|
|
29501abcd6 | ||
|
|
20ca403d53 | ||
|
|
19e63f0dcd | ||
|
|
149f394ecf | ||
|
|
2931b80074 | ||
|
|
c4baca221d | ||
|
|
6a1c92a6fd | ||
|
|
4494b937bf | ||
|
|
e2413e52d2 | ||
|
|
64b9bae564 | ||
|
|
c6372ece05 |
|
|
@ -352,7 +352,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|
@ -400,7 +400,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|
@ -428,7 +428,7 @@
|
||||||
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
|
import Network
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
|
|
@ -17,11 +18,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var statusMenuItem: NSMenuItem!
|
var statusMenuItem: NSMenuItem!
|
||||||
var outdatedItem: NSMenuItem!
|
var outdatedItem: NSMenuItem!
|
||||||
var upgradeAllItem: NSMenuItem!
|
var upgradeAllItem: NSMenuItem!
|
||||||
|
var upgradeOnlyItem: NSMenuItem!
|
||||||
|
var upgradeFormulaeItem: NSMenuItem!
|
||||||
|
var upgradeCasksItem: NSMenuItem!
|
||||||
var outdatedSubmenu: NSMenu!
|
var outdatedSubmenu: NSMenu!
|
||||||
var versionItem: NSMenuItem!
|
var versionItem: NSMenuItem!
|
||||||
|
|
||||||
var logWindow: LogWindowController?
|
var logWindow: LogWindowController?
|
||||||
var settingsWindow: SettingsWindowController?
|
var settingsWindow: SettingsWindowController?
|
||||||
|
var tipsWindow: TipsWindowController?
|
||||||
var logBuffer: String = ""
|
var logBuffer: String = ""
|
||||||
|
|
||||||
var brewPath: String?
|
var brewPath: String?
|
||||||
|
|
@ -42,6 +47,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
/// last known outdated list, shown in the Upgrade All confirmation
|
/// last known outdated list, shown in the Upgrade All confirmation
|
||||||
var cachedOutdated: OutdatedPackages = .none
|
var cachedOutdated: OutdatedPackages = .none
|
||||||
var lastNotifiedOutdated: Set<String> = []
|
var lastNotifiedOutdated: Set<String> = []
|
||||||
|
/// packages whose last upgrade attempt failed; reset when an upgrade
|
||||||
|
/// run starts, displayed once by updateStatus after the run's refetch
|
||||||
|
var failedUpgrades: [String] = []
|
||||||
|
|
||||||
|
let pathMonitor = NWPathMonitor()
|
||||||
|
/// false until the monitor's first update, so the launch refresh waits
|
||||||
|
/// until the network state is actually known. Main-queue only.
|
||||||
|
var isOnline = false
|
||||||
|
|
||||||
// MARK: - Menu
|
// MARK: - Menu
|
||||||
|
|
||||||
|
|
@ -68,19 +81,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r"))
|
menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r"))
|
||||||
upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")
|
upgradeAllItem = NSMenuItem(title: "📦 Upgrade formulae & casks", action: #selector(upgradeAll), keyEquivalent: "u")
|
||||||
upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
|
upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
|
||||||
menu.addItem(upgradeAllItem)
|
menu.addItem(upgradeAllItem)
|
||||||
|
upgradeOnlyItem = NSMenuItem(title: "🎯 Upgrade only…", action: nil, keyEquivalent: "")
|
||||||
|
let upgradeOnlySubmenu = NSMenu()
|
||||||
|
// manual isEnabled control below; otherwise AppKit re-enables the
|
||||||
|
// items from the responder chain every time the menu opens
|
||||||
|
upgradeOnlySubmenu.autoenablesItems = false
|
||||||
|
upgradeFormulaeItem = NSMenuItem(title: "Formulae", action: #selector(upgradeFormulaeOnly), keyEquivalent: "")
|
||||||
|
upgradeCasksItem = NSMenuItem(title: "Casks", action: #selector(upgradeCasksOnly), keyEquivalent: "")
|
||||||
|
upgradeOnlySubmenu.addItem(upgradeFormulaeItem)
|
||||||
|
upgradeOnlySubmenu.addItem(upgradeCasksItem)
|
||||||
|
upgradeOnlyItem.submenu = upgradeOnlySubmenu
|
||||||
|
upgradeOnlyItem.isHidden = true // shown only when both kinds are outdated
|
||||||
|
menu.addItem(upgradeOnlyItem)
|
||||||
outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "")
|
outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "")
|
||||||
outdatedSubmenu = NSMenu()
|
outdatedSubmenu = NSMenu()
|
||||||
outdatedItem.submenu = outdatedSubmenu
|
outdatedItem.submenu = outdatedSubmenu
|
||||||
outdatedItem.isHidden = true // shown once a fetch finds outdated packages
|
outdatedItem.isHidden = true // shown once a fetch finds outdated packages
|
||||||
menu.addItem(outdatedItem)
|
menu.addItem(outdatedItem)
|
||||||
|
menu.addItem(NSMenuItem(title: "🧹 Cleanup", action: #selector(cleanupAction), keyEquivalent: "c"))
|
||||||
|
// isAlternate swaps this in for the item right above while ⌥ is held;
|
||||||
|
// the pairing requires the same key equivalent with other modifiers
|
||||||
|
let purgeItem = NSMenuItem(title: "🧹 Cleanup (purge cache)", action: #selector(cleanupPurgeAction), keyEquivalent: "c")
|
||||||
|
purgeItem.keyEquivalentModifierMask = .option
|
||||||
|
purgeItem.isAlternate = true
|
||||||
|
menu.addItem(purgeItem)
|
||||||
menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l"))
|
menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l"))
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))
|
menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))
|
||||||
|
menu.addItem(NSMenuItem(title: "💡 Tips", action: #selector(showTips), keyEquivalent: "t"))
|
||||||
|
|
||||||
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
|
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
|
||||||
menu.addItem(versionItem)
|
menu.addItem(versionItem)
|
||||||
|
|
@ -91,7 +124,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
statusItem.menu = menu
|
statusItem.menu = menu
|
||||||
|
|
||||||
refreshAll()
|
// no refreshAll() here: the monitor fires once immediately after
|
||||||
|
// start(), and the first online update triggers the launch refresh
|
||||||
|
pathMonitor.pathUpdateHandler = { path in
|
||||||
|
// updates arrive on the monitor's queue; hop to main for AppKit
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.networkPathChanged(online: path.status == .satisfied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathMonitor.start(queue: DispatchQueue(label: "fr.socheleau.BrewBar.network"))
|
||||||
|
|
||||||
refreshBrewVersion()
|
refreshBrewVersion()
|
||||||
|
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
||||||
|
|
@ -113,6 +155,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// icon matching the last fetched state, for operations that end
|
||||||
|
/// without triggering a refetch
|
||||||
|
func cachedStateIcon() -> String {
|
||||||
|
cachedOutdated.isEmpty ? "brewbar-uptodate" : "brewbar-outdated"
|
||||||
|
}
|
||||||
|
|
||||||
func restartTimer() {
|
func restartTimer() {
|
||||||
refreshTimer?.invalidate() // stop running timer
|
refreshTimer?.invalidate() // stop running timer
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
||||||
|
|
@ -128,6 +176,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
presentWindow(of: settingsWindow)
|
presentWindow(of: settingsWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func showTips() {
|
||||||
|
if tipsWindow == nil {
|
||||||
|
tipsWindow = TipsWindowController()
|
||||||
|
}
|
||||||
|
presentWindow(of: tipsWindow)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Window presentation
|
// MARK: - Window presentation
|
||||||
|
|
||||||
/// Accessory apps never appear in Cmd-Tab and their windows can open
|
/// Accessory apps never appear in Cmd-Tab and their windows can open
|
||||||
|
|
@ -144,7 +199,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
@objc func windowWillClose(_: Notification) {
|
@objc func windowWillClose(_: Notification) {
|
||||||
// async so the closing window's isVisible has flipped to false
|
// async so the closing window's isVisible has flipped to false
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window]
|
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window, self.tipsWindow?.window]
|
||||||
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
|
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
|
||||||
if !stillOpen {
|
if !stillOpen {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
@ -205,7 +260,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
|
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
|
||||||
/// defaults to naming the brew command.
|
/// defaults to naming the brew command.
|
||||||
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) {
|
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
|
||||||
Self.brewQueue.async {
|
Self.brewQueue.async {
|
||||||
let brew = self.resolveBrewPath()
|
let brew = self.resolveBrewPath()
|
||||||
let cleaned = command.replacingOccurrences(of: "brew ", with: "")
|
let cleaned = command.replacingOccurrences(of: "brew ", with: "")
|
||||||
|
|
@ -231,7 +286,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
try process.run()
|
try process.run()
|
||||||
} catch {
|
} catch {
|
||||||
self.log("ERROR: \(error)\n")
|
self.log("ERROR: \(error)\n")
|
||||||
DispatchQueue.main.async { completion("") }
|
DispatchQueue.main.async { completion("", false) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,8 +296,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
self.log(output)
|
self.log(output)
|
||||||
|
let success = process.terminationStatus == 0
|
||||||
|
if !success {
|
||||||
|
self.log("✗ exited with status \(process.terminationStatus)\n")
|
||||||
|
}
|
||||||
// completion always on main: callers update AppKit UI, which is main-thread only
|
// completion always on main: callers update AppKit UI, which is main-thread only
|
||||||
DispatchQueue.main.async { completion(output) }
|
DispatchQueue.main.async { completion(output, success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,31 +311,96 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
refreshAll()
|
refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func cleanupAction() {
|
||||||
|
runCleanup(purgeCache: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func cleanupPurgeAction() {
|
||||||
|
runCleanup(purgeCache: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --prune=all also deletes cached downloads brew would normally keep
|
||||||
|
/// (current versions); the only cost is re-downloading at next install
|
||||||
|
func runCleanup(purgeCache: Bool) {
|
||||||
|
statusMenuItem.title = "Cleaning up..."
|
||||||
|
setMenuBarIcon("brewbar-updating")
|
||||||
|
runBrew(purgeCache ? "cleanup --prune=all" : "cleanup") { _, _ in
|
||||||
|
self.updateStatus(count: self.cachedOutdated.count)
|
||||||
|
self.setMenuBarIcon(self.cachedStateIcon())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interposed between the end of an upgrade and its closing refetch;
|
||||||
|
/// passes straight through unless the user opted in.
|
||||||
|
func cleanupAfterUpgradeIfEnabled(completion: @escaping () -> Void) {
|
||||||
|
guard Settings.cleanupAfterUpgrade else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusMenuItem.title = "Cleaning up..."
|
||||||
|
runBrew("cleanup") { _, _ in completion() }
|
||||||
|
}
|
||||||
|
|
||||||
@objc func upgradeAll() {
|
@objc func upgradeAll() {
|
||||||
|
upgrade(formulae: true, casks: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func upgradeFormulaeOnly() {
|
||||||
|
upgrade(formulae: true, casks: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func upgradeCasksOnly() {
|
||||||
|
upgrade(formulae: false, casks: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgrade(formulae: Bool, casks: Bool) {
|
||||||
if Settings.confirmBeforeUpgradeAll {
|
if Settings.confirmBeforeUpgradeAll {
|
||||||
guard confirmUpgrade() else { return }
|
guard confirmUpgrade(formulae: formulae, casks: casks) else { return }
|
||||||
}
|
}
|
||||||
|
|
||||||
statusMenuItem.title = "Upgrading..."
|
statusMenuItem.title = "Upgrading..."
|
||||||
|
setMenuBarIcon("brewbar-updating")
|
||||||
|
failedUpgrades = []
|
||||||
|
|
||||||
runBrew("update") { _ in
|
runBrew("update") { _, _ in
|
||||||
// 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 caskNames = self.cachedOutdated.casks.map(\.name)
|
let formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : []
|
||||||
let upgradeCasksThenRefresh = {
|
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) {
|
self.upgradeCasksSequentially(caskNames) {
|
||||||
// everything was just upgraded; don't chain an auto-upgrade
|
self.cleanupAfterUpgradeIfEnabled {
|
||||||
|
// a manual upgrade just ran; don't chain an auto-upgrade
|
||||||
self.fetchOutdated(allowAutoUpgrade: false)
|
self.fetchOutdated(allowAutoUpgrade: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.cachedOutdated.formulae.isEmpty {
|
/// Formulae also upgrade one at a time: a single broken formula (no
|
||||||
upgradeCasksThenRefresh()
|
/// bottle for this macOS, deprecated, …) makes brew's batch upgrade
|
||||||
} else {
|
/// error out, taking every other pending formula down with it.
|
||||||
self.runBrew("upgrade --formula") { _ in
|
func upgradeFormulaeSequentially(_ names: [String], completion: @escaping () -> Void) {
|
||||||
upgradeCasksThenRefresh()
|
guard let next = names.first else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusMenuItem.title = "Upgrading \(next)..."
|
||||||
|
runBrew("upgrade --formula \(next)", askpassMessage: "upgrade \(next)") { _, success in
|
||||||
|
if !success {
|
||||||
|
self.failedUpgrades.append(next)
|
||||||
}
|
}
|
||||||
|
self.upgradeFormulaeSequentially(Array(names.dropFirst()), completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,27 +413,63 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
statusMenuItem.title = "Upgrading \(next)..."
|
statusMenuItem.title = "Upgrading \(next)..."
|
||||||
// an explicitly named cask upgrades even when self-updating, no --greedy needed
|
upgradeCask(next) {
|
||||||
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _ in
|
|
||||||
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
|
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmUpgrade() -> Bool {
|
/// Self-updating apps (Zen, Chrome, …) can drift from what brew
|
||||||
|
/// installed, and brew then refuses to upgrade the cask in place —
|
||||||
|
/// it only prints a warning naming the fix. Run that fix for it.
|
||||||
|
/// (An explicitly named cask upgrades even when self-updating, no
|
||||||
|
/// --greedy needed.)
|
||||||
|
func upgradeCask(_ name: String, completion: @escaping () -> Void) {
|
||||||
|
runBrew("upgrade --cask \(name)", askpassMessage: "upgrade \(name)") { output, success in
|
||||||
|
guard output.contains("cannot be upgraded as-is") else {
|
||||||
|
if !success {
|
||||||
|
self.failedUpgrades.append(name)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.runBrew("reinstall --cask --force \(name)", askpassMessage: "reinstall \(name)") { _, reinstalled in
|
||||||
|
if !reinstalled {
|
||||||
|
self.failedUpgrades.append(name)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmUpgrade(formulae: Bool, casks: Bool) -> Bool {
|
||||||
|
let packages = (formulae ? cachedOutdated.formulae : []) + (casks ? cachedOutdated.casks : [])
|
||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
|
|
||||||
if cachedOutdated.isEmpty {
|
if packages.isEmpty {
|
||||||
alert.messageText = "Upgrade all outdated packages?"
|
alert.messageText = "Upgrade all outdated packages?"
|
||||||
} else {
|
} else {
|
||||||
alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?"
|
alert.messageText = "Upgrade \(packages.count) package\(packages.count == 1 ? "" : "s")?"
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first."
|
// warn only about apps that are actually running right now, by name
|
||||||
if !cachedOutdated.isEmpty {
|
var lines: [String] = []
|
||||||
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
|
if casks {
|
||||||
|
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.")
|
||||||
}
|
}
|
||||||
alert.informativeText = info
|
}
|
||||||
|
}
|
||||||
|
if !packages.isEmpty {
|
||||||
|
lines.append(packages.map(\.label).joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
alert.informativeText = lines.joined(separator: "\n\n")
|
||||||
|
|
||||||
alert.addButton(withTitle: "Upgrade")
|
alert.addButton(withTitle: "Upgrade")
|
||||||
alert.addButton(withTitle: "Cancel")
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
|
@ -328,7 +488,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// --greedy also lists casks that self-update (brew skips them by default)
|
// --greedy also lists casks that self-update (brew skips them by default)
|
||||||
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
|
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
|
||||||
|
|
||||||
runBrew(command) { output in
|
runBrew(command) { output, success in
|
||||||
|
// parsing a failure's output would yield .none and masquerade as
|
||||||
|
// "all up to date"; keep the cached state and its icon instead
|
||||||
|
guard success else {
|
||||||
|
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
|
||||||
|
self.setMenuBarIcon(self.cachedStateIcon())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let outdated = BrewParser.parseOutdatedJSON(output)
|
let outdated = BrewParser.parseOutdatedJSON(output)
|
||||||
|
|
||||||
self.cachedOutdated = outdated
|
self.cachedOutdated = outdated
|
||||||
|
|
@ -342,9 +510,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,22 +553,19 @@ 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")
|
||||||
|
failedUpgrades = []
|
||||||
|
|
||||||
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
|
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
|
||||||
let upgradeCasksThenRefresh = {
|
|
||||||
|
upgradeFormulaeSequentially(formulaNames) {
|
||||||
self.upgradeCasksSequentially(caskNames) {
|
self.upgradeCasksSequentially(caskNames) {
|
||||||
|
self.cleanupAfterUpgradeIfEnabled {
|
||||||
self.fetchOutdated(allowAutoUpgrade: false)
|
self.fetchOutdated(allowAutoUpgrade: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if formulae {
|
|
||||||
runBrew("upgrade --formula") { _ in
|
|
||||||
upgradeCasksThenRefresh()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
upgradeCasksThenRefresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,6 +573,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
outdatedSubmenu.removeAllItems()
|
outdatedSubmenu.removeAllItems()
|
||||||
outdatedItem.isHidden = outdated.isEmpty
|
outdatedItem.isHidden = outdated.isEmpty
|
||||||
upgradeAllItem.isHidden = outdated.isEmpty
|
upgradeAllItem.isHidden = outdated.isEmpty
|
||||||
|
// visible whenever anything is outdated; a kind with nothing to
|
||||||
|
// upgrade stays listed but greyed out
|
||||||
|
upgradeOnlyItem.isHidden = outdated.isEmpty
|
||||||
|
upgradeFormulaeItem.title = outdated.formulae.isEmpty ? "Formulae" : "Formulae (\(outdated.formulae.count))"
|
||||||
|
upgradeFormulaeItem.isEnabled = !outdated.formulae.isEmpty
|
||||||
|
upgradeCasksItem.title = outdated.casks.isEmpty ? "Casks" : "Casks (\(outdated.casks.count))"
|
||||||
|
upgradeCasksItem.isEnabled = !outdated.casks.isEmpty
|
||||||
|
|
||||||
for package in outdated.all {
|
for package in outdated.all {
|
||||||
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
|
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
|
||||||
|
|
@ -416,7 +597,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStatus(count: Int) {
|
func updateStatus(count: Int) {
|
||||||
if count == 0 {
|
// surface what the last upgrade run could not handle — brew only
|
||||||
|
// reports these in its output, which lands in the logs. Consumed
|
||||||
|
// here so the next plain refresh shows the normal state again.
|
||||||
|
if !failedUpgrades.isEmpty {
|
||||||
|
statusMenuItem.title = "⚠️ Upgrade failed: \(failedUpgrades.joined(separator: ", ")) — see Logs"
|
||||||
|
failedUpgrades = []
|
||||||
|
} else if count == 0 {
|
||||||
statusMenuItem.title = "✅ All up to date"
|
statusMenuItem.title = "✅ All up to date"
|
||||||
} else {
|
} else {
|
||||||
statusMenuItem.title = "⚠️ \(count) outdated"
|
statusMenuItem.title = "⚠️ \(count) outdated"
|
||||||
|
|
@ -424,24 +611,75 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func upgradeSingle(_ sender: NSMenuItem) {
|
@objc func upgradeSingle(_ sender: NSMenuItem) {
|
||||||
guard let formula = sender.representedObject as? String else { return }
|
guard let name = sender.representedObject as? String else { return }
|
||||||
|
|
||||||
runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in
|
statusMenuItem.title = "Upgrading \(name)..."
|
||||||
|
setMenuBarIcon("brewbar-updating")
|
||||||
|
failedUpgrades = []
|
||||||
|
|
||||||
|
let finish = {
|
||||||
|
self.cleanupAfterUpgradeIfEnabled {
|
||||||
self.refreshAll()
|
self.refreshAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshAll() {
|
// casks go through upgradeCask for the reinstall fallback; a bare
|
||||||
statusMenuItem.title = "Updating..."
|
// "brew upgrade <name>" would hit the same warning and do nothing
|
||||||
|
if cachedOutdated.casks.contains(where: { $0.name == name }) {
|
||||||
|
upgradeCask(name, completion: finish)
|
||||||
|
} else {
|
||||||
|
runBrew("upgrade \(name)", askpassMessage: "upgrade \(name)") { _, success in
|
||||||
|
if !success {
|
||||||
|
self.failedUpgrades.append(name)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runBrew("update") { _ in
|
/// Refreshing offline would silently succeed on stale data: brew update
|
||||||
|
/// fails but its exit code was never checked, and brew outdated compares
|
||||||
|
/// against the local taps without touching the network — so the app
|
||||||
|
/// claimed "all up to date" no matter what. Every offline→online
|
||||||
|
/// transition refreshes, which also covers the refreshes skipped below.
|
||||||
|
func networkPathChanged(online: Bool) {
|
||||||
|
let wasOnline = isOnline
|
||||||
|
isOnline = online
|
||||||
|
|
||||||
|
guard online else {
|
||||||
|
statusMenuItem.title = "📡 Offline"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !wasOnline {
|
||||||
|
refreshAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAll() {
|
||||||
|
guard isOnline else {
|
||||||
|
statusMenuItem.title = "📡 Offline"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMenuItem.title = "Updating..."
|
||||||
|
setMenuBarIcon("brewbar-updating")
|
||||||
|
|
||||||
|
// NWPathMonitor can't see a network that is up but broken (captive
|
||||||
|
// portal, dead DNS, git host down); brew update's exit code can.
|
||||||
|
// Proceeding anyway would read stale local data and report a false OK.
|
||||||
|
runBrew("update") { _, success in
|
||||||
|
guard success else {
|
||||||
|
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
|
||||||
|
self.setMenuBarIcon(self.cachedStateIcon())
|
||||||
|
return
|
||||||
|
}
|
||||||
self.fetchOutdated() // ✅ ONLY place calling outdated
|
self.fetchOutdated() // ✅ ONLY place calling outdated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ✅ Brew version (correct)
|
/// ✅ Brew version (correct)
|
||||||
func refreshBrewVersion() {
|
func refreshBrewVersion() {
|
||||||
runBrew("--version") { output in
|
runBrew("--version") { output, _ in
|
||||||
let firstLine = BrewParser.parseVersion(output)
|
let firstLine = BrewParser.parseVersion(output)
|
||||||
self.versionItem.title = "🏷 \(firstLine)"
|
self.versionItem.title = "🏷 \(firstLine)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ struct OutdatedPackage: Codable, Equatable {
|
||||||
|
|
||||||
/// Menu label, e.g. "wget 1.21 → 1.22"
|
/// Menu label, e.g. "wget 1.21 → 1.22"
|
||||||
var label: String {
|
var label: String {
|
||||||
let installed = installedVersions.joined(separator: ", ")
|
let installed = installedVersions.map(Self.displayVersion).joined(separator: ", ")
|
||||||
return "\(name) \(installed) → \(currentVersion)"
|
return "\(name) \(installed) → \(Self.displayVersion(currentVersion))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Some casks (e.g. claude: "1.20186.9,69f150a4c9…") version themselves
|
||||||
|
/// as "user-facing-version,build-id" — the part after the comma only
|
||||||
|
/// matters for brew's own download URL, never for display.
|
||||||
|
static func displayVersion(_ version: String) -> String {
|
||||||
|
String(version.split(separator: ",", maxSplits: 1)[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,4 +45,23 @@ enum Settings {
|
||||||
get { UserDefaults.standard.bool(forKey: "confirmBeforeUpgradeAll") }
|
get { UserDefaults.standard.bool(forKey: "confirmBeforeUpgradeAll") }
|
||||||
set { UserDefaults.standard.set(newValue, forKey: "confirmBeforeUpgradeAll") }
|
set { UserDefaults.standard.set(newValue, forKey: "confirmBeforeUpgradeAll") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var cleanupAfterUpgrade: Bool {
|
||||||
|
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") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ class SettingsWindowController: NSWindowController {
|
||||||
var notifyCheckbox: NSButton!
|
var notifyCheckbox: NSButton!
|
||||||
var showCountCheckbox: NSButton!
|
var showCountCheckbox: NSButton!
|
||||||
var confirmCheckbox: NSButton!
|
var confirmCheckbox: NSButton!
|
||||||
|
var cleanupCheckbox: NSButton!
|
||||||
|
var skipRunningAutoCheckbox: NSButton!
|
||||||
|
var skipRunningManualCheckbox: NSButton!
|
||||||
|
|
||||||
convenience init(appDelegate: AppDelegate) {
|
convenience init(appDelegate: AppDelegate) {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
|
|
@ -107,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,
|
||||||
|
|
@ -120,11 +143,17 @@ class SettingsWindowController: NSWindowController {
|
||||||
)
|
)
|
||||||
|
|
||||||
confirmCheckbox = NSButton(
|
confirmCheckbox = NSButton(
|
||||||
checkboxWithTitle: "Ask for confirmation before Upgrade All",
|
checkboxWithTitle: "Ask for confirmation before bulk upgrades",
|
||||||
target: self,
|
target: self,
|
||||||
action: #selector(toggleConfirm(_:))
|
action: #selector(toggleConfirm(_:))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cleanupCheckbox = NSButton(
|
||||||
|
checkboxWithTitle: "Run brew cleanup after upgrades",
|
||||||
|
target: self,
|
||||||
|
action: #selector(toggleCleanup(_:))
|
||||||
|
)
|
||||||
|
|
||||||
let stack = NSStackView(views: [
|
let stack = NSStackView(views: [
|
||||||
frequencyRow,
|
frequencyRow,
|
||||||
customRow,
|
customRow,
|
||||||
|
|
@ -135,9 +164,15 @@ class SettingsWindowController: NSWindowController {
|
||||||
autoUpgradeCasksCheckbox,
|
autoUpgradeCasksCheckbox,
|
||||||
caskWarningRow,
|
caskWarningRow,
|
||||||
separator(),
|
separator(),
|
||||||
|
runningAppsHeader,
|
||||||
|
skipRunningAutoCheckbox,
|
||||||
|
skipRunningManualCheckbox,
|
||||||
|
runningAppsNote,
|
||||||
|
separator(),
|
||||||
notifyCheckbox,
|
notifyCheckbox,
|
||||||
showCountCheckbox,
|
showCountCheckbox,
|
||||||
confirmCheckbox,
|
confirmCheckbox,
|
||||||
|
cleanupCheckbox,
|
||||||
launchAtLoginCheckbox,
|
launchAtLoginCheckbox,
|
||||||
])
|
])
|
||||||
stack.orientation = .vertical
|
stack.orientation = .vertical
|
||||||
|
|
@ -179,6 +214,9 @@ class SettingsWindowController: NSWindowController {
|
||||||
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
|
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
|
||||||
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
|
||||||
|
skipRunningAutoCheckbox.state = Settings.skipRunningCasksAuto ? .on : .off
|
||||||
|
skipRunningManualCheckbox.state = Settings.skipRunningCasksManual ? .on : .off
|
||||||
}
|
}
|
||||||
|
|
||||||
func separator() -> NSBox {
|
func separator() -> NSBox {
|
||||||
|
|
@ -311,6 +349,18 @@ class SettingsWindowController: NSWindowController {
|
||||||
Settings.confirmBeforeUpgradeAll = sender.state == .on
|
Settings.confirmBeforeUpgradeAll = sender.state == .on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func toggleCleanup(_ sender: NSButton) {
|
||||||
|
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 {
|
||||||
|
|
|
||||||
78
BrewBar/TipsWindowController.swift
Normal file
78
BrewBar/TipsWindowController.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
/// Static "good to know" window for the features a menu can't explain
|
||||||
|
/// by itself. Content lives in the tips array — adding one there is all
|
||||||
|
/// it takes.
|
||||||
|
class TipsWindowController: NSWindowController {
|
||||||
|
let tips: [(title: String, body: String)] = [
|
||||||
|
("⌥ reveals hidden menu items",
|
||||||
|
"With the menu open, hold Option (⌥): Cleanup becomes Cleanup (purge cache), which also empties Homebrew's download cache (brew cleanup --prune=all). Downloads are simply re-fetched when next needed."),
|
||||||
|
|
||||||
|
("Keyboard shortcuts",
|
||||||
|
"While the menu is open: R refreshes, U upgrades formulae & casks, C cleans up, L opens the logs, T shows these tips, comma (,) opens Settings and Q quits."),
|
||||||
|
|
||||||
|
("Upgrade only one kind",
|
||||||
|
"When both formulae and casks are outdated, an “Upgrade only…” submenu appears under the main upgrade item to handle just one of the two."),
|
||||||
|
|
||||||
|
("Self-updating apps",
|
||||||
|
"Apps that update themselves (browsers, editors…) can drift from what brew installed, and brew then refuses a normal upgrade. BrewBar detects this and automatically reinstalls the cask instead — you may see reinstall --cask --force in the logs; that is expected."),
|
||||||
|
|
||||||
|
("When something looks off, read the Logs",
|
||||||
|
"Every brew command BrewBar runs is in the Logs window with its full output — errors, warnings and all. It is the first place to look."),
|
||||||
|
]
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 440, height: 300),
|
||||||
|
styleMask: [.titled, .closable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "BrewBar Tips"
|
||||||
|
// the controller keeps the window alive; without this, closing the
|
||||||
|
// window would deallocate it and reopening would crash
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
self.init(window: window)
|
||||||
|
|
||||||
|
buildUI()
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUI() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
let tipViews: [NSView] = tips.map { tip in
|
||||||
|
let title = NSTextField(labelWithString: tip.title)
|
||||||
|
title.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
|
||||||
|
let body = NSTextField(wrappingLabelWithString: tip.body)
|
||||||
|
body.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
body.textColor = .secondaryLabelColor
|
||||||
|
body.preferredMaxLayoutWidth = 400
|
||||||
|
|
||||||
|
let tipStack = NSStackView(views: [title, body])
|
||||||
|
tipStack.orientation = .vertical
|
||||||
|
tipStack.alignment = .leading
|
||||||
|
tipStack.spacing = 4
|
||||||
|
return tipStack
|
||||||
|
}
|
||||||
|
|
||||||
|
let stack = NSStackView(views: tipViews)
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.spacing = 16
|
||||||
|
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -20),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20),
|
||||||
|
])
|
||||||
|
|
||||||
|
contentView.layoutSubtreeIfNeeded()
|
||||||
|
window?.setContentSize(contentView.fittingSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -52,6 +64,15 @@ final class BrewParserTests: XCTestCase {
|
||||||
XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4")
|
XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testParseOutdatedJSON_packageLabel_stripsBuildIdAfterComma() {
|
||||||
|
let package = OutdatedPackage(
|
||||||
|
name: "claude",
|
||||||
|
installedVersions: ["1.20185.0,5e8f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f"],
|
||||||
|
currentVersion: "1.20186.9,69f150a4c9316d5c8cd7b9f130ed583d15c0383e"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(package.label, "claude 1.20185.0 → 1.20186.9")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - parseVersion
|
// MARK: - parseVersion
|
||||||
|
|
||||||
func testParseVersion_picksFirstLine() {
|
func testParseVersion_picksFirstLine() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
cask "brewbar" do
|
cask "brewbar" do
|
||||||
version "0.1"
|
version "0.3"
|
||||||
sha256 "5b37d2457d12ca42f61d3d95476987e965b3d56927c35be68abc91b047294a88"
|
sha256 "d87a744e034c2ce0f92a24c3ebba167533419f948337a2dbedd393152d08d13d"
|
||||||
|
|
||||||
url "https://forgejo.socheleau.fr/maxsoch/BrewBar/releases/download/v#{version}/BrewBar-#{version}.zip"
|
url "https://forgejo.socheleau.fr/maxsoch/BrewBar/releases/download/v#{version}/BrewBar-#{version}.zip"
|
||||||
name "BrewBar"
|
name "BrewBar"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue