Compare commits
No commits in common. "main" and "v0.1" have entirely different histories.
|
|
@ -352,7 +352,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3;
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
|
@ -400,7 +400,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3;
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.MaxSoch.BrewBar;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
|
@ -428,7 +428,7 @@
|
|||
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.3;
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
|
|
@ -449,7 +449,7 @@
|
|||
DEVELOPMENT_TEAM = GLVUYYHFD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.3;
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Cocoa
|
||||
import Network
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
|
|
@ -18,15 +17,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
var statusMenuItem: NSMenuItem!
|
||||
var outdatedItem: NSMenuItem!
|
||||
var upgradeAllItem: NSMenuItem!
|
||||
var upgradeOnlyItem: NSMenuItem!
|
||||
var upgradeFormulaeItem: NSMenuItem!
|
||||
var upgradeCasksItem: NSMenuItem!
|
||||
var outdatedSubmenu: NSMenu!
|
||||
var versionItem: NSMenuItem!
|
||||
|
||||
var logWindow: LogWindowController?
|
||||
var settingsWindow: SettingsWindowController?
|
||||
var tipsWindow: TipsWindowController?
|
||||
var logBuffer: String = ""
|
||||
|
||||
var brewPath: String?
|
||||
|
|
@ -47,14 +42,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
/// last known outdated list, shown in the Upgrade All confirmation
|
||||
var cachedOutdated: OutdatedPackages = .none
|
||||
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
|
||||
|
||||
|
|
@ -81,39 +68,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r"))
|
||||
upgradeAllItem = NSMenuItem(title: "📦 Upgrade formulae & casks", action: #selector(upgradeAll), keyEquivalent: "u")
|
||||
upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")
|
||||
upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
|
||||
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: "")
|
||||
outdatedSubmenu = NSMenu()
|
||||
outdatedItem.submenu = outdatedSubmenu
|
||||
outdatedItem.isHidden = true // shown once a fetch finds outdated packages
|
||||
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.separator())
|
||||
|
||||
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: "")
|
||||
menu.addItem(versionItem)
|
||||
|
|
@ -124,16 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
|
||||
statusItem.menu = menu
|
||||
|
||||
// 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"))
|
||||
|
||||
refreshAll()
|
||||
refreshBrewVersion()
|
||||
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
||||
|
|
@ -155,12 +113,6 @@ 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() {
|
||||
refreshTimer?.invalidate() // stop running timer
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
|
||||
|
|
@ -176,13 +128,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
presentWindow(of: settingsWindow)
|
||||
}
|
||||
|
||||
@objc func showTips() {
|
||||
if tipsWindow == nil {
|
||||
tipsWindow = TipsWindowController()
|
||||
}
|
||||
presentWindow(of: tipsWindow)
|
||||
}
|
||||
|
||||
// MARK: - Window presentation
|
||||
|
||||
/// Accessory apps never appear in Cmd-Tab and their windows can open
|
||||
|
|
@ -199,7 +144,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
@objc func windowWillClose(_: Notification) {
|
||||
// async so the closing window's isVisible has flipped to false
|
||||
DispatchQueue.main.async {
|
||||
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window, self.tipsWindow?.window]
|
||||
let ourWindows = [self.logWindow?.window, self.settingsWindow?.window]
|
||||
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
|
||||
if !stillOpen {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
|
|
@ -260,7 +205,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
|
||||
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
|
||||
/// defaults to naming the brew command.
|
||||
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
|
||||
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) {
|
||||
Self.brewQueue.async {
|
||||
let brew = self.resolveBrewPath()
|
||||
let cleaned = command.replacingOccurrences(of: "brew ", with: "")
|
||||
|
|
@ -286,7 +231,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
try process.run()
|
||||
} catch {
|
||||
self.log("ERROR: \(error)\n")
|
||||
DispatchQueue.main.async { completion("", false) }
|
||||
DispatchQueue.main.async { completion("") }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -296,12 +241,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
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
|
||||
DispatchQueue.main.async { completion(output, success) }
|
||||
DispatchQueue.main.async { completion(output) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,96 +252,31 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
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() {
|
||||
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 {
|
||||
guard confirmUpgrade(formulae: formulae, casks: casks) else { return }
|
||||
guard confirmUpgrade() else { return }
|
||||
}
|
||||
|
||||
statusMenuItem.title = "Upgrading..."
|
||||
setMenuBarIcon("brewbar-updating")
|
||||
failedUpgrades = []
|
||||
|
||||
runBrew("update") { _, _ in
|
||||
runBrew("update") { _ in
|
||||
// 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) : []
|
||||
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) {
|
||||
let caskNames = self.cachedOutdated.casks.map(\.name)
|
||||
let upgradeCasksThenRefresh = {
|
||||
self.upgradeCasksSequentially(caskNames) {
|
||||
self.cleanupAfterUpgradeIfEnabled {
|
||||
// a manual upgrade just ran; don't chain an auto-upgrade
|
||||
// everything was just upgraded; don't chain an auto-upgrade
|
||||
self.fetchOutdated(allowAutoUpgrade: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulae also upgrade one at a time: a single broken formula (no
|
||||
/// bottle for this macOS, deprecated, …) makes brew's batch upgrade
|
||||
/// error out, taking every other pending formula down with it.
|
||||
func upgradeFormulaeSequentially(_ names: [String], completion: @escaping () -> Void) {
|
||||
guard let next = names.first else {
|
||||
completion()
|
||||
return
|
||||
if self.cachedOutdated.formulae.isEmpty {
|
||||
upgradeCasksThenRefresh()
|
||||
} else {
|
||||
self.runBrew("upgrade --formula") { _ in
|
||||
upgradeCasksThenRefresh()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -413,63 +289,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
statusMenuItem.title = "Upgrading \(next)..."
|
||||
upgradeCask(next) {
|
||||
// an explicitly named cask upgrades even when self-updating, no --greedy needed
|
||||
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _ in
|
||||
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 : [])
|
||||
|
||||
func confirmUpgrade() -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
|
||||
if packages.isEmpty {
|
||||
if cachedOutdated.isEmpty {
|
||||
alert.messageText = "Upgrade all outdated packages?"
|
||||
} else {
|
||||
alert.messageText = "Upgrade \(packages.count) package\(packages.count == 1 ? "" : "s")?"
|
||||
alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?"
|
||||
}
|
||||
|
||||
// warn only about apps that are actually running right now, by name
|
||||
var lines: [String] = []
|
||||
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.")
|
||||
var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first."
|
||||
if !cachedOutdated.isEmpty {
|
||||
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !packages.isEmpty {
|
||||
lines.append(packages.map(\.label).joined(separator: "\n"))
|
||||
}
|
||||
alert.informativeText = lines.joined(separator: "\n\n")
|
||||
alert.informativeText = info
|
||||
|
||||
alert.addButton(withTitle: "Upgrade")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
|
@ -488,15 +328,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
// --greedy also lists casks that self-update (brew skips them by default)
|
||||
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
runBrew(command) { output in
|
||||
let outdated = BrewParser.parseOutdatedJSON(output)
|
||||
|
||||
self.cachedOutdated = outdated
|
||||
|
|
@ -510,18 +342,9 @@ 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
|
||||
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)
|
||||
let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
|
||||
if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
|
||||
self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -553,19 +376,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func runAutoUpgrade(formulae: Bool, caskNames: [String]) {
|
||||
func runAutoUpgrade(formulae: Bool, casks: Bool) {
|
||||
statusMenuItem.title = "Auto-upgrading..."
|
||||
setMenuBarIcon("brewbar-updating")
|
||||
failedUpgrades = []
|
||||
|
||||
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
|
||||
|
||||
upgradeFormulaeSequentially(formulaNames) {
|
||||
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
|
||||
let upgradeCasksThenRefresh = {
|
||||
self.upgradeCasksSequentially(caskNames) {
|
||||
self.cleanupAfterUpgradeIfEnabled {
|
||||
self.fetchOutdated(allowAutoUpgrade: false)
|
||||
}
|
||||
}
|
||||
|
||||
if formulae {
|
||||
runBrew("upgrade --formula") { _ in
|
||||
upgradeCasksThenRefresh()
|
||||
}
|
||||
} else {
|
||||
upgradeCasksThenRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -573,13 +399,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
outdatedSubmenu.removeAllItems()
|
||||
outdatedItem.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 {
|
||||
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
|
||||
|
|
@ -597,13 +416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
func updateStatus(count: Int) {
|
||||
// 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 {
|
||||
if count == 0 {
|
||||
statusMenuItem.title = "✅ All up to date"
|
||||
} else {
|
||||
statusMenuItem.title = "⚠️ \(count) outdated"
|
||||
|
|
@ -611,75 +424,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
@objc func upgradeSingle(_ sender: NSMenuItem) {
|
||||
guard let name = sender.representedObject as? String else { return }
|
||||
guard let formula = sender.representedObject as? String else { return }
|
||||
|
||||
statusMenuItem.title = "Upgrading \(name)..."
|
||||
setMenuBarIcon("brewbar-updating")
|
||||
failedUpgrades = []
|
||||
|
||||
let finish = {
|
||||
self.cleanupAfterUpgradeIfEnabled {
|
||||
runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in
|
||||
self.refreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
// casks go through upgradeCask for the reinstall fallback; a bare
|
||||
// "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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
runBrew("update") { _ in
|
||||
self.fetchOutdated() // ✅ ONLY place calling outdated
|
||||
}
|
||||
}
|
||||
|
||||
/// ✅ Brew version (correct)
|
||||
func refreshBrewVersion() {
|
||||
runBrew("--version") { output, _ in
|
||||
runBrew("--version") { output in
|
||||
let firstLine = BrewParser.parseVersion(output)
|
||||
self.versionItem.title = "🏷 \(firstLine)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,8 @@ struct OutdatedPackage: Codable, Equatable {
|
|||
|
||||
/// Menu label, e.g. "wget 1.21 → 1.22"
|
||||
var label: String {
|
||||
let installed = installedVersions.map(Self.displayVersion).joined(separator: ", ")
|
||||
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])
|
||||
let installed = installedVersions.joined(separator: ", ")
|
||||
return "\(name) \(installed) → \(currentVersion)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
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,7 +12,6 @@ enum Settings {
|
|||
UserDefaults.standard.register(defaults: [
|
||||
"includeGreedyCasks": true,
|
||||
"confirmBeforeUpgradeAll": true,
|
||||
"skipRunningCasksAuto": true,
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -45,23 +44,4 @@ enum Settings {
|
|||
get { UserDefaults.standard.bool(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,9 +22,6 @@ class SettingsWindowController: NSWindowController {
|
|||
var notifyCheckbox: NSButton!
|
||||
var showCountCheckbox: NSButton!
|
||||
var confirmCheckbox: NSButton!
|
||||
var cleanupCheckbox: NSButton!
|
||||
var skipRunningAutoCheckbox: NSButton!
|
||||
var skipRunningManualCheckbox: NSButton!
|
||||
|
||||
convenience init(appDelegate: AppDelegate) {
|
||||
let window = NSWindow(
|
||||
|
|
@ -110,26 +107,6 @@ 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,
|
||||
|
|
@ -143,17 +120,11 @@ class SettingsWindowController: NSWindowController {
|
|||
)
|
||||
|
||||
confirmCheckbox = NSButton(
|
||||
checkboxWithTitle: "Ask for confirmation before bulk upgrades",
|
||||
checkboxWithTitle: "Ask for confirmation before Upgrade All",
|
||||
target: self,
|
||||
action: #selector(toggleConfirm(_:))
|
||||
)
|
||||
|
||||
cleanupCheckbox = NSButton(
|
||||
checkboxWithTitle: "Run brew cleanup after upgrades",
|
||||
target: self,
|
||||
action: #selector(toggleCleanup(_:))
|
||||
)
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
frequencyRow,
|
||||
customRow,
|
||||
|
|
@ -164,15 +135,9 @@ class SettingsWindowController: NSWindowController {
|
|||
autoUpgradeCasksCheckbox,
|
||||
caskWarningRow,
|
||||
separator(),
|
||||
runningAppsHeader,
|
||||
skipRunningAutoCheckbox,
|
||||
skipRunningManualCheckbox,
|
||||
runningAppsNote,
|
||||
separator(),
|
||||
notifyCheckbox,
|
||||
showCountCheckbox,
|
||||
confirmCheckbox,
|
||||
cleanupCheckbox,
|
||||
launchAtLoginCheckbox,
|
||||
])
|
||||
stack.orientation = .vertical
|
||||
|
|
@ -214,9 +179,6 @@ class SettingsWindowController: NSWindowController {
|
|||
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
|
||||
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 {
|
||||
|
|
@ -349,18 +311,6 @@ class SettingsWindowController: NSWindowController {
|
|||
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) {
|
||||
do {
|
||||
if sender.state == .on {
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
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,18 +1,6 @@
|
|||
@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
|
||||
|
||||
|
|
@ -64,15 +52,6 @@ final class BrewParserTests: XCTestCase {
|
|||
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
|
||||
|
||||
func testParseVersion_picksFirstLine() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
cask "brewbar" do
|
||||
version "0.3"
|
||||
sha256 "d87a744e034c2ce0f92a24c3ebba167533419f948337a2dbedd393152d08d13d"
|
||||
version "0.1"
|
||||
sha256 "5b37d2457d12ca42f61d3d95476987e965b3d56927c35be68abc91b047294a88"
|
||||
|
||||
url "https://forgejo.socheleau.fr/maxsoch/BrewBar/releases/download/v#{version}/BrewBar-#{version}.zip"
|
||||
name "BrewBar"
|
||||
|
|
|
|||
Loading…
Reference in a new issue