Compare commits

..

No commits in common. "main" and "v0.1" have entirely different histories.
main ... v0.1

9 changed files with 58 additions and 518 deletions

View file

@ -352,7 +352,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.3; MARKETING_VERSION = 0.1;
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.3; MARKETING_VERSION = 0.1;
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.3; MARKETING_VERSION = 0.1;
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.3; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.MaxSoch.BrewBarTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx; SDKROOT = macosx;

View file

@ -1,5 +1,4 @@
import Cocoa import Cocoa
import Network
import UserNotifications import UserNotifications
@main @main
@ -18,15 +17,11 @@ 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?
@ -47,14 +42,6 @@ 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
@ -81,39 +68,19 @@ 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 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 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)
@ -124,16 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusItem.menu = menu statusItem.menu = menu
// no refreshAll() here: the monitor fires once immediately after refreshAll()
// 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
@ -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() { 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
@ -176,13 +128,6 @@ 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
@ -199,7 +144,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, self.tipsWindow?.window] let ourWindows = [self.logWindow?.window, self.settingsWindow?.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)
@ -260,7 +205,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 (_ output: String, _ success: Bool) -> Void = { _, _ in }) { func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> 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: "")
@ -286,7 +231,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("", false) } DispatchQueue.main.async { completion("") }
return return
} }
@ -296,12 +241,8 @@ 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, success) } DispatchQueue.main.async { completion(output) }
} }
} }
@ -311,96 +252,31 @@ 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(formulae: formulae, casks: casks) else { return } guard confirmUpgrade() 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 formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : [] let caskNames = self.cachedOutdated.casks.map(\.name)
var caskNames = casks ? self.cachedOutdated.casks.map(\.name) : [] let upgradeCasksThenRefresh = {
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) {
self.cleanupAfterUpgradeIfEnabled { // everything was just upgraded; don't chain an auto-upgrade
// a manual upgrade just ran; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false) self.fetchOutdated(allowAutoUpgrade: false)
} }
} }
}
}
}
/// Formulae also upgrade one at a time: a single broken formula (no if self.cachedOutdated.formulae.isEmpty {
/// bottle for this macOS, deprecated, ) makes brew's batch upgrade upgradeCasksThenRefresh()
/// error out, taking every other pending formula down with it. } else {
func upgradeFormulaeSequentially(_ names: [String], completion: @escaping () -> Void) { self.runBrew("upgrade --formula") { _ in
guard let next = names.first else { upgradeCasksThenRefresh()
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)
} }
} }
@ -413,63 +289,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
statusMenuItem.title = "Upgrading \(next)..." 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.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
} }
} }
/// Self-updating apps (Zen, Chrome, ) can drift from what brew func confirmUpgrade() -> Bool {
/// 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 packages.isEmpty { if cachedOutdated.isEmpty {
alert.messageText = "Upgrade all outdated packages?" alert.messageText = "Upgrade all outdated packages?"
} else { } 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 info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first."
var lines: [String] = [] if !cachedOutdated.isEmpty {
if casks { info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
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")
@ -488,15 +328,7 @@ 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, success in runBrew(command) { output 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
@ -510,18 +342,9 @@ 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
var autoCaskNames = Settings.autoUpgradeCasks ? outdated.casks.map(\.name) : [] let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
// filtered before the trigger decision: a permanently running app if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
// must not start an upgrade cycle that would skip it anyway self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
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
} }
@ -553,19 +376,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request)
} }
func runAutoUpgrade(formulae: Bool, caskNames: [String]) { func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..." statusMenuItem.title = "Auto-upgrading..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : [] let caskNames = casks ? cachedOutdated.casks.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()
} }
} }
@ -573,13 +399,6 @@ 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: "")
@ -597,13 +416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
func updateStatus(count: Int) { func updateStatus(count: Int) {
// surface what the last upgrade run could not handle brew only if count == 0 {
// 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"
@ -611,75 +424,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc func upgradeSingle(_ sender: NSMenuItem) { @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)..." runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let finish = {
self.cleanupAfterUpgradeIfEnabled {
self.refreshAll() 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 offlineonline
/// 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() { func refreshAll() {
guard isOnline else {
statusMenuItem.title = "📡 Offline"
return
}
statusMenuItem.title = "Updating..." statusMenuItem.title = "Updating..."
setMenuBarIcon("brewbar-updating")
// NWPathMonitor can't see a network that is up but broken (captive runBrew("update") { _ in
// 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)"
} }

View file

@ -7,15 +7,8 @@ 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.map(Self.displayVersion).joined(separator: ", ") let installed = installedVersions.joined(separator: ", ")
return "\(name) \(installed)\(Self.displayVersion(currentVersion))" return "\(name) \(installed)\(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])
} }
} }

View file

@ -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) }
}
}
}

View file

@ -12,7 +12,6 @@ enum Settings {
UserDefaults.standard.register(defaults: [ UserDefaults.standard.register(defaults: [
"includeGreedyCasks": true, "includeGreedyCasks": true,
"confirmBeforeUpgradeAll": true, "confirmBeforeUpgradeAll": true,
"skipRunningCasksAuto": true,
]) ])
} }
@ -45,23 +44,4 @@ 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") }
}
} }

View file

@ -22,9 +22,6 @@ 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(
@ -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." "⚠️ 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,
@ -143,17 +120,11 @@ class SettingsWindowController: NSWindowController {
) )
confirmCheckbox = NSButton( confirmCheckbox = NSButton(
checkboxWithTitle: "Ask for confirmation before bulk upgrades", checkboxWithTitle: "Ask for confirmation before Upgrade All",
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,
@ -164,15 +135,9 @@ 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
@ -214,9 +179,6 @@ 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 {
@ -349,18 +311,6 @@ 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 {

View file

@ -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)
}
}

View file

@ -1,18 +1,6 @@
@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
@ -64,15 +52,6 @@ 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() {

View file

@ -1,6 +1,6 @@
cask "brewbar" do cask "brewbar" do
version "0.3" version "0.1"
sha256 "d87a744e034c2ce0f92a24c3ebba167533419f948337a2dbedd393152d08d13d" sha256 "5b37d2457d12ca42f61d3d95476987e965b3d56927c35be68abc91b047294a88"
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"