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)",
"@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;

View file

@ -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 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() {
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)"
}

View file

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

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: [
"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") }
}
}

View file

@ -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 {

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
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() {

View file

@ -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"