Compare commits
No commits in common. "main" and "v0.1" have entirely different histories.
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 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() {
|
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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
@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() {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue