Compare commits

..

9 commits

Author SHA1 Message Date
maxsoch 9f848be875 hide Upgrade All when there is nothing to upgrade
Same treatment as the Outdated item: upgradeAllItem becomes a property,
starts hidden, and updateOutdatedMenu shows it only when the fetch
returns outdated packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:40:50 +02:00
maxsoch 15034951fc hide the Outdated menu item when everything is up to date
Promote outdatedItem to a property and toggle isHidden from
updateOutdatedMenu: hidden at launch and whenever a fetch returns no
outdated packages, visible otherwise. The "All up to date" placeholder
inside the submenu is dropped — the status line already says it, and
the item is invisible when the list is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:38:50 +02:00
maxsoch 9ab8af79a4 remove redundant "Refresh List" from the Outdated submenu
The main Refresh action already refetches everything; the submenu
button duplicated it. Its only handler, forceRefreshOutdated, is
removed too as nothing else referenced it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:38:10 +02:00
maxsoch e41d7ac008 fix: exec brew directly instead of interpreting a shell string
runBrew built a command string and ran it through zsh -c, so the shell
interpreted the whole line — any metacharacter in an interpolated
package name (;, $(), backticks) would have been executed. Names come
from brew itself so exploitation was unlikely, but the injection class
is now gone: Process gets an argument array via /usr/bin/env, which
resolves brew on PATH (covering the bare "brew" fallback) and execs it
with no shell in between. Also slightly faster per invocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:34:28 +02:00
maxsoch 99c243a51c fix: cap logBuffer at 100k chars to prevent unbounded growth
Every brew command appended its full output to logBuffer and nothing
ever trimmed it, so with the hourly refresh timer the buffer grew
forever. Memory aside, each log() call re-renders the entire buffer
into the log window's NSTextView, so logging got slower over time.

Keep only the most recent 100k characters, dropping the oldest content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:33:32 +02:00
maxsoch d1f4c0cb79 cleanup: remove dead refreshOutdatedList and unused lastOutdatedFetch
refreshOutdatedList (the 5-second cache path) was never called by any
menu item or code path, so the cache logic never ran. lastOutdatedFetch
was only written. cachedOutdated stays: the Upgrade All confirmation
dialog uses it to list pending packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:30:12 +02:00
maxsoch e47455b3f8 fix: confirm before Upgrade All, warn that running apps may close
brew upgrade also upgrades casks, and replacing a cask quits the running
app (e.g. the browser) with no warning. Upgrade All now shows an NSAlert
listing the outdated packages and warning that running apps may be
closed, with a Cancel option.

NSApp.activate is needed because BrewBar is an accessory app (no Dock
icon) — without it the modal alert can appear behind other windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:29:23 +02:00
maxsoch e579ac1d56 fix: show a GUI password dialog when brew needs sudo
Brew runs inside a Process with no terminal attached, so any sudo prompt
(cask upgrades, some installers) would hang or fail silently — there was
nowhere to type the password.

Set SUDO_ASKPASS to a small helper script written to Application Support
at launch. When sudo detects no tty, it runs the helper, which shows a
native macOS password dialog via osascript and prints the answer for
sudo to consume. Cancelling the dialog makes sudo fail cleanly instead
of hanging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:27:45 +02:00
maxsoch 7390d68a33 fix: deliver runBrew completion on the main thread
Completions were invoked on a background queue. Most call sites wrapped
their UI work in DispatchQueue.main.async, but upgradeSingle's completion
calls refreshAll(), which sets statusMenuItem.title directly — an AppKit
mutation off the main thread. It also raced on cachedOutdated /
lastOutdatedFetch (written from background, read from main).

runBrew now always dispatches its completion to the main queue, so every
caller can safely touch UI, and the now-redundant inner main.async hops
are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:26:14 +02:00

View file

@ -14,6 +14,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem! var statusItem: NSStatusItem!
var statusMenuItem: NSMenuItem! var statusMenuItem: NSMenuItem!
var outdatedItem: NSMenuItem!
var upgradeAllItem: NSMenuItem!
var outdatedSubmenu: NSMenu! var outdatedSubmenu: NSMenu!
var versionItem: NSMenuItem! var versionItem: NSMenuItem!
@ -35,9 +37,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
// cache to prevent duplicate outdated calls /// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: [String] = [] var cachedOutdated: [String] = []
var lastOutdatedFetch: Date?
// MARK: - Menu // MARK: - Menu
@ -53,11 +54,13 @@ 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"))
menu.addItem(NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")) upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")
let outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "") upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
menu.addItem(upgradeAllItem)
outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "")
outdatedSubmenu = NSMenu() outdatedSubmenu = NSMenu()
outdatedSubmenu.addItem(NSMenuItem(title: "Refresh List", action: #selector(forceRefreshOutdated), keyEquivalent: ""))
outdatedItem.submenu = outdatedSubmenu outdatedItem.submenu = outdatedSubmenu
outdatedItem.isHidden = true // shown once a fetch finds outdated packages
menu.addItem(outdatedItem) menu.addItem(outdatedItem)
menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l")) menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l"))
@ -174,17 +177,40 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return "brew" return "brew"
} }
/// Written once per launch; sudo runs it to show a GUI password dialog
/// whenever brew needs admin rights (no terminal is attached to Process).
static let askpassURL: URL = {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("BrewBar", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("askpass.sh")
let script = """
#!/bin/zsh
osascript -e 'display dialog "BrewBar needs your administrator password to continue:" default answer "" with hidden answer with title "BrewBar" with icon caution buttons {"Cancel", "OK"} default button "OK"' -e 'text returned of result'
"""
try? script.write(to: url, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
return url
}()
func runBrew(_ command: String, completion: @escaping (String) -> Void = { _ in }) { func runBrew(_ command: String, completion: @escaping (String) -> Void = { _ in }) {
DispatchQueue.global().async { DispatchQueue.global().async {
let brew = self.resolveBrewPath() let brew = self.resolveBrewPath()
let cleaned = command.replacingOccurrences(of: "brew ", with: "") let cleaned = command.replacingOccurrences(of: "brew ", with: "")
let fullCommand = "\(brew) \(cleaned)" let arguments = [brew] + cleaned.split(separator: " ").map(String.init)
self.log("\(fullCommand)\n") self.log("\(arguments.joined(separator: " "))\n")
// env resolves brew via PATH (covers the bare "brew" fallback) and
// execs it directly no shell, so no metacharacter interpretation
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["-c", fullCommand] process.arguments = arguments
var environment = ProcessInfo.processInfo.environment
environment["SUDO_ASKPASS"] = Self.askpassURL.path
process.environment = environment
let pipe = Pipe() let pipe = Pipe()
process.standardOutput = pipe process.standardOutput = pipe
@ -194,7 +220,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try process.run() try process.run()
} catch { } catch {
self.log("ERROR: \(error)\n") self.log("ERROR: \(error)\n")
completion("") DispatchQueue.main.async { completion("") }
return return
} }
@ -204,7 +230,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)
completion(output) // completion always on main: callers update AppKit UI, which is main-thread only
DispatchQueue.main.async { completion(output) }
} }
} }
@ -215,6 +242,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc func upgradeAll() { @objc func upgradeAll() {
guard confirmUpgrade() else { return }
statusMenuItem.title = "Upgrading..." statusMenuItem.title = "Upgrading..."
runBrew("update") { _ in runBrew("update") { _ in
@ -224,20 +253,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// SMART refresh (uses cache) func confirmUpgrade() -> Bool {
@objc func refreshOutdatedList() { let alert = NSAlert()
// if fetched in last 5s uses cache alert.alertStyle = .warning
if let last = lastOutdatedFetch, Date().timeIntervalSince(last) < 5 {
updateOutdatedMenu(with: cachedOutdated) if cachedOutdated.isEmpty {
return alert.messageText = "Upgrade all outdated packages?"
} else {
alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?"
} }
fetchOutdated() 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.joined(separator: "\n")
} }
alert.informativeText = info
/// FORCE refresh (menu button) alert.addButton(withTitle: "Upgrade")
@objc func forceRefreshOutdated() { alert.addButton(withTitle: "Cancel")
fetchOutdated()
// accessory app: bring the alert to the front, otherwise it can appear behind other windows
NSApp.activate(ignoringOtherApps: true)
return alert.runModal() == .alertFirstButtonReturn
} }
func fetchOutdated() { func fetchOutdated() {
@ -247,9 +284,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let lines = BrewParser.parseOutdated(output) let lines = BrewParser.parseOutdated(output)
self.cachedOutdated = lines self.cachedOutdated = lines
self.lastOutdatedFetch = Date()
DispatchQueue.main.async {
self.updateOutdatedMenu(with: lines) self.updateOutdatedMenu(with: lines)
self.updateStatus(count: lines.count) self.updateStatus(count: lines.count)
if lines.isEmpty { if lines.isEmpty {
@ -259,16 +294,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
} }
}
func updateOutdatedMenu(with lines: [String]) { func updateOutdatedMenu(with lines: [String]) {
outdatedSubmenu.removeAllItems() outdatedSubmenu.removeAllItems()
outdatedSubmenu.addItem(NSMenuItem(title: "Refresh List", action: #selector(forceRefreshOutdated), keyEquivalent: "")) outdatedItem.isHidden = lines.isEmpty
upgradeAllItem.isHidden = lines.isEmpty
if lines.isEmpty {
outdatedSubmenu.addItem(NSMenuItem(title: "✅ All up to date", action: nil, keyEquivalent: ""))
return
}
for formula in lines { for formula in lines {
let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "") let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
@ -305,18 +335,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func refreshBrewVersion() { func refreshBrewVersion() {
runBrew("--version") { output in runBrew("--version") { output in
let firstLine = BrewParser.parseVersion(output) let firstLine = BrewParser.parseVersion(output)
DispatchQueue.main.async {
self.versionItem.title = "🏷 \(firstLine)" self.versionItem.title = "🏷 \(firstLine)"
} }
} }
}
// MARK: - Logs // MARK: - Logs
/// oldest content is dropped past this size, or the buffer grows forever
/// and every log() re-renders an ever-larger string into the text view
let maxLogLength = 100_000
func log(_ text: String) { func log(_ text: String) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.logBuffer += text self.logBuffer += text
if self.logBuffer.count > self.maxLogLength {
self.logBuffer = String(self.logBuffer.suffix(self.maxLogLength))
}
self.logWindow?.update(text: self.logBuffer) self.logWindow?.update(text: self.logBuffer)
} }
} }