Compare commits

..

11 commits

Author SHA1 Message Date
maxsoch ec33fbcf6c Merge branch 'serial-brew': brew commands run one at a time
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:10:54 +02:00
maxsoch 9575339209 Merge branch 'notifications-fix': explain notification permission state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:08:55 +02:00
maxsoch 323f64f921 Merge branch 'log-window-ui': monospace log window with smarter autoscroll
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:07:28 +02:00
maxsoch ecb6728f42 Merge branch 'window-activation': windows front and center, Cmd-Tab entry while open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:06:20 +02:00
maxsoch fe469d9157 fix: explain notification permission state instead of failing silently
requestAuthorization only shows the system prompt once ever; when
permission is already denied it silently returns false and the checkbox
just snapped off with no explanation. toggleNotify now checks the
authorization status first: already-authorized enables directly,
not-determined triggers the system prompt (errors surfaced in an
alert), and denied shows an alert with an Open System Settings button
pointing at the Notifications pane.

Also adds a UNUserNotificationCenterDelegate so banners are shown even
while BrewBar is the active app — otherwise macOS suppresses them,
which reads as "notifications don't work" right after enabling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:00:45 +02:00
maxsoch 5b1820b7e1 fix: run brew commands on a serial queue, one at a time
runBrew dispatched onto the global concurrent queue, so overlapping
operations (refresh timer firing mid-upgrade, quick manual actions)
ran multiple brew processes at once — each able to summon its own sudo
password dialog, hence duplicate password windows. A private serial
queue makes every brew command wait for the previous one, so a second
dialog cannot appear while one is open. Log output no longer
interleaves either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:59:48 +02:00
maxsoch 0309bd38d7 improve log window: monospace, scrollbar, smarter autoscroll
Monospaced font and semantic colors (dark-mode correct), visible
vertical scroller (was missing), text inset, centered window with a
proper title. Autoscroll now only follows the tail when already at the
bottom instead of yanking the scroll position on every log line.

Also sets isReleasedWhenClosed = false, fixing a latent crash on
close-then-reopen (the settings window already had this guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:59:11 +02:00
maxsoch 59683ee8a9 fix: bring windows to front and into Cmd-Tab while open
Accessory apps never appear in the Cmd-Tab switcher and macOS does not
activate them when they open windows — the log window opened behind
other apps (settings only worked via a manual activate call).

presentWindow(of:) now switches the activation policy to .regular
while a BrewBar window is open, giving a Dock icon and a Cmd-Tab entry;
a willClose observer drops back to .accessory once the last window is
gone. Used by both showLogs and showSettings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:58:23 +02:00
maxsoch 25e4476317 Merge branch 'per-cask-upgrades': sudo prompts name the app being upgraded
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:56:40 +02:00
maxsoch 0020316dab upgrade casks one at a time so sudo prompts name the app
Batched cask upgrades give no per-package hook, so the password dialog
could only echo the whole command. upgradeCasksSequentially chains one
"upgrade --cask <name>" per outdated cask (from the typed cache), each
with an askpass message naming that cask; the status line follows along
("Upgrading tailscale-app..."). Used by both Upgrade All and
auto-upgrade; formulae stay batched since they don't need sudo.

Explicitly named casks upgrade even when self-updating, so the
per-cask commands need no --greedy. Casks that turn outdated only
after the preceding brew update surface in the final refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:45:35 +02:00
maxsoch 0def852416 let brew callers customize the sudo dialog message
runBrew gains an optional askpassMessage; the dialog reads "BrewBar
needs your administrator password to <message>." and defaults to naming
the brew command. Groundwork for per-cask upgrade prompts that name the
app instead of the command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 05:44:32 +02:00
3 changed files with 162 additions and 29 deletions

View file

@ -47,6 +47,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_: Notification) { func applicationDidFinishLaunching(_: Notification) {
Settings.registerDefaults() Settings.registerDefaults()
UNUserNotificationCenter.current().delegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(windowWillClose(_:)),
name: NSWindow.willCloseNotification,
object: nil
)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.imagePosition = .imageLeft // icon stays visible next to the count statusItem.button?.imagePosition = .imageLeft // icon stays visible next to the count
@ -117,10 +125,31 @@ class AppDelegate: NSObject, NSApplicationDelegate {
settingsWindow = SettingsWindowController(appDelegate: self) settingsWindow = SettingsWindowController(appDelegate: self)
} }
settingsWindow?.syncUI() settingsWindow?.syncUI()
settingsWindow?.showWindow(nil) presentWindow(of: settingsWindow)
// accessory app: without activation the window can open behind others }
// MARK: - Window presentation
/// Accessory apps never appear in Cmd-Tab and their windows can open
/// behind other apps. While one of our windows is open we temporarily
/// become a regular app (Dock icon + Cmd-Tab entry); windowWillClose
/// drops back to accessory once the last window is gone.
func presentWindow(of controller: NSWindowController?) {
NSApp.setActivationPolicy(.regular)
controller?.showWindow(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
settingsWindow?.window?.makeKeyAndOrderFront(nil) controller?.window?.makeKeyAndOrderFront(nil)
}
@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]
let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
if !stillOpen {
NSApp.setActivationPolicy(.accessory)
}
}
} }
func resolveBrewPath() -> String { func resolveBrewPath() -> String {
@ -150,27 +179,34 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// prompt whenever brew needs admin rights (no terminal is attached to /// prompt whenever brew needs admin rights (no terminal is attached to
/// Process). If two brew commands overlap, the later one's text wins /// Process). If two brew commands overlap, the later one's text wins
/// harmless, since prompts realistically only appear during upgrades. /// harmless, since prompts realistically only appear during upgrades.
static func writeAskpassScript(for command: String) -> URL { static func writeAskpassScript(message: String) -> URL {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("BrewBar", isDirectory: true) .appendingPathComponent("BrewBar", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// the text lands inside shell single quotes AND an AppleScript string; // the text lands inside shell single quotes AND an AppleScript string;
// whitelist characters that cannot break out of either // whitelist characters that cannot break out of either
let safeCommand = command.filter { $0.isLetter || $0.isNumber || " ._@+=:/-".contains($0) } let safeMessage = message.filter { $0.isLetter || $0.isNumber || " ._@+=:/-".contains($0) }
let url = dir.appendingPathComponent("askpass.sh") let url = dir.appendingPathComponent("askpass.sh")
let script = """ let script = """
#!/bin/zsh #!/bin/zsh
osascript -e 'display dialog "BrewBar needs your administrator password to run:\\n\\nbrew \(safeCommand)" default answer "" with hidden answer with title "BrewBar" with icon caution buttons {"Cancel", "OK"} default button "OK"' -e 'text returned of result' osascript -e 'display dialog "BrewBar needs your administrator password to \(safeMessage)." 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? script.write(to: url, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
return url return url
} }
func runBrew(_ command: String, completion: @escaping (String) -> Void = { _ in }) { /// Serial: brew commands run strictly one after another. Concurrent brew
DispatchQueue.global().async { /// processes could each summon their own sudo password dialog (and their
/// log output interleaved); queueing them prevents both.
static let brewQueue = DispatchQueue(label: "fr.socheleau.BrewBar.brew")
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
/// defaults to naming the brew command.
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) {
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: "")
let arguments = [brew] + cleaned.split(separator: " ").map(String.init) let arguments = [brew] + cleaned.split(separator: " ").map(String.init)
@ -184,7 +220,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
process.arguments = arguments process.arguments = arguments
var environment = ProcessInfo.processInfo.environment var environment = ProcessInfo.processInfo.environment
environment["SUDO_ASKPASS"] = Self.writeAskpassScript(for: cleaned).path environment["SUDO_ASKPASS"] = Self.writeAskpassScript(message: askpassMessage ?? "run: brew \(cleaned)").path
process.environment = environment process.environment = environment
let pipe = Pipe() let pipe = Pipe()
@ -223,16 +259,40 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Upgrading..." statusMenuItem.title = "Upgrading..."
// must match the outdated listing: greedy-listed casks are skipped by
// plain "upgrade" and would stay outdated forever
let command = Settings.includeGreedyCasks ? "upgrade --greedy" : "upgrade"
runBrew("update") { _ in runBrew("update") { _ in
self.runBrew(command) { _ in // packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
let caskNames = self.cachedOutdated.casks.map(\.name)
let upgradeCasksThenRefresh = {
self.upgradeCasksSequentially(caskNames) {
// everything was just upgraded; don't chain an auto-upgrade // everything was just upgraded; don't chain an auto-upgrade
self.fetchOutdated(allowAutoUpgrade: false) self.fetchOutdated(allowAutoUpgrade: false)
} }
} }
if self.cachedOutdated.formulae.isEmpty {
upgradeCasksThenRefresh()
} else {
self.runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
}
}
}
}
/// Upgrades casks one at a time so each sudo prompt can name the app it
/// is for a batched "upgrade --cask" gives no per-package hook.
func upgradeCasksSequentially(_ names: [String], completion: @escaping () -> Void) {
guard let next = names.first else {
completion()
return
}
statusMenuItem.title = "Upgrading \(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)
}
} }
func confirmUpgrade() -> Bool { func confirmUpgrade() -> Bool {
@ -319,13 +379,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func runAutoUpgrade(formulae: Bool, casks: Bool) { func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..." statusMenuItem.title = "Auto-upgrading..."
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = { let upgradeCasksThenRefresh = {
if casks { self.upgradeCasksSequentially(caskNames) {
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
self.runBrew(command) { _ in
self.fetchOutdated(allowAutoUpgrade: false)
}
} else {
self.fetchOutdated(allowAutoUpgrade: false) self.fetchOutdated(allowAutoUpgrade: false)
} }
} }
@ -370,7 +426,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func upgradeSingle(_ sender: NSMenuItem) { @objc func upgradeSingle(_ sender: NSMenuItem) {
guard let formula = sender.representedObject as? String else { return } guard let formula = sender.representedObject as? String else { return }
runBrew("upgrade \(formula)") { _ in runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in
self.refreshAll() self.refreshAll()
} }
} }
@ -411,7 +467,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if logWindow == nil { if logWindow == nil {
logWindow = LogWindowController() logWindow = LogWindowController()
} }
logWindow?.showWindow(nil)
logWindow?.update(text: logBuffer) logWindow?.update(text: logBuffer)
presentWindow(of: logWindow)
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
/// Without this, macOS suppresses banners while BrewBar is the active
/// app (e.g. right after toggling the setting with the window open).
func userNotificationCenter(_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler([.banner])
} }
} }

View file

@ -14,22 +14,41 @@ class LogWindowController: NSWindowController {
self.init(window: window) self.init(window: window)
window.title = "Logs" window.title = "BrewBar Logs"
// the controller keeps the window alive; without this, closing the
// window would deallocate it and reopening would crash
window.isReleasedWhenClosed = false
window.center()
let scrollView = NSScrollView(frame: window.contentView!.bounds) let scrollView = NSScrollView(frame: window.contentView!.bounds)
scrollView.autoresizingMask = [.width, .height] scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
textView = NSTextView(frame: scrollView.bounds) textView = NSTextView(frame: scrollView.bounds)
textView.isEditable = false textView.isEditable = false
textView.autoresizingMask = [.width, .height] textView.autoresizingMask = [.width, .height]
textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
textView.textColor = .textColor // adapts to light/dark mode
textView.backgroundColor = .textBackgroundColor
textView.textContainerInset = NSSize(width: 8, height: 8)
scrollView.documentView = textView scrollView.documentView = textView
window.contentView?.addSubview(scrollView) window.contentView?.addSubview(scrollView)
} }
func update(text: String) { func update(text: String) {
// only follow the tail if the user is already at the bottom don't
// yank the scroll position away while they are reading older output
let wasAtBottom: Bool = if let scrollView = textView.enclosingScrollView {
scrollView.contentView.bounds.maxY >= textView.frame.height - 30
} else {
true
}
textView.string = text textView.string = text
if wasAtBottom {
textView.scrollToEndOfDocument(nil) textView.scrollToEndOfDocument(nil)
} }
}
} }

View file

@ -246,15 +246,62 @@ class SettingsWindowController: NSWindowController {
return return
} }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in // requestAuthorization only shows the system prompt the first time
// ever; if permission was denied before, it silently returns false.
// Check the status first so the user learns what actually happened.
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { notificationSettings in
// callback arrives on a background queue; UI needs main // callback arrives on a background queue; UI needs main
DispatchQueue.main.async {
switch notificationSettings.authorizationStatus {
case .authorized, .provisional:
Settings.notifyOnNewUpdates = true
case .notDetermined:
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
DispatchQueue.main.async { DispatchQueue.main.async {
Settings.notifyOnNewUpdates = granted Settings.notifyOnNewUpdates = granted
sender.state = granted ? .on : .off sender.state = granted ? .on : .off
if let error {
self.showNotificationProblem(error.localizedDescription)
} }
} }
} }
case .denied:
sender.state = .off
self.showNotificationsDenied()
@unknown default:
sender.state = .off
}
}
}
}
func showNotificationsDenied() {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Notifications are disabled for BrewBar"
alert.informativeText = "macOS has notifications turned off for BrewBar. Enable them in System Settings > Notifications > BrewBar, then flip this switch again."
alert.addButton(withTitle: "Open System Settings")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn,
let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension")
{
NSWorkspace.shared.open(url)
}
}
func showNotificationProblem(_ message: String) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Could not enable notifications"
alert.informativeText = message
alert.runModal()
}
@objc func toggleShowCount(_ sender: NSButton) { @objc func toggleShowCount(_ sender: NSButton) {
Settings.showCountInMenuBar = sender.state == .on Settings.showCountInMenuBar = sender.state == .on
appDelegate?.updateMenuBarCount() // reflect immediately, no refetch needed appDelegate?.updateMenuBarCount() // reflect immediately, no refetch needed