BrewBar/BrewBar/SettingsWindowController.swift
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

332 lines
12 KiB
Swift

import Cocoa
import ServiceManagement
import UserNotifications
class SettingsWindowController: NSWindowController {
weak var appDelegate: AppDelegate?
let presets: [(label: String, interval: TimeInterval)] = [
("30 minutes", 1800),
("1 hour", 3600),
("6 hours", 21600),
("12 hours", 43200),
("24 hours", 86400),
]
var intervalPopup: NSPopUpButton!
var customMinutesField: NSTextField!
var launchAtLoginCheckbox: NSButton!
var greedyCheckbox: NSButton!
var autoUpgradeFormulaeCheckbox: NSButton!
var autoUpgradeCasksCheckbox: NSButton!
var notifyCheckbox: NSButton!
var showCountCheckbox: NSButton!
var confirmCheckbox: NSButton!
convenience init(appDelegate: AppDelegate) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 200),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "BrewBar Settings"
// 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)
self.appDelegate = appDelegate
buildUI()
syncUI()
window.center()
}
// MARK: - Layout
func buildUI() {
guard let contentView = window?.contentView else { return }
intervalPopup = NSPopUpButton()
for preset in presets {
intervalPopup.addItem(withTitle: preset.label)
}
intervalPopup.addItem(withTitle: "Custom")
intervalPopup.target = self
intervalPopup.action = #selector(presetPicked(_:))
customMinutesField = NSTextField()
customMinutesField.placeholderString = "60"
customMinutesField.alignment = .right
customMinutesField.target = self
customMinutesField.action = #selector(customMinutesEntered(_:))
customMinutesField.widthAnchor.constraint(equalToConstant: 60).isActive = true
let frequencyRow = NSStackView(views: [
NSTextField(labelWithString: "Check for updates every:"),
intervalPopup,
])
frequencyRow.orientation = .horizontal
let customRow = NSStackView(views: [
customMinutesField,
NSTextField(labelWithString: "minutes"),
])
customRow.orientation = .horizontal
launchAtLoginCheckbox = NSButton(
checkboxWithTitle: "Launch BrewBar at login",
target: self,
action: #selector(toggleLaunchAtLogin(_:))
)
greedyCheckbox = NSButton(
checkboxWithTitle: "Include self-updating casks (--greedy)",
target: self,
action: #selector(toggleGreedy(_:))
)
let greedyNote = noteRow(
"Some apps (Chrome, Firefox…) update themselves, so Homebrew normally ignores them. When enabled, BrewBar also lists them as outdated and includes them in Upgrade All and auto-upgrades."
)
autoUpgradeFormulaeCheckbox = NSButton(
checkboxWithTitle: "Automatically upgrade formulae (command-line tools)",
target: self,
action: #selector(toggleAutoUpgradeFormulae(_:))
)
autoUpgradeCasksCheckbox = NSButton(
checkboxWithTitle: "Automatically upgrade casks (apps)",
target: self,
action: #selector(toggleAutoUpgradeCasks(_:))
)
let caskWarningRow = noteRow(
"⚠️ Upgrading a cask can close and replace the app while it is running (e.g. your browser), without warning."
)
notifyCheckbox = NSButton(
checkboxWithTitle: "Notify when new updates are found",
target: self,
action: #selector(toggleNotify(_:))
)
showCountCheckbox = NSButton(
checkboxWithTitle: "Show outdated count in the menu bar",
target: self,
action: #selector(toggleShowCount(_:))
)
confirmCheckbox = NSButton(
checkboxWithTitle: "Ask for confirmation before Upgrade All",
target: self,
action: #selector(toggleConfirm(_:))
)
let stack = NSStackView(views: [
frequencyRow,
customRow,
greedyCheckbox,
greedyNote,
separator(),
autoUpgradeFormulaeCheckbox,
autoUpgradeCasksCheckbox,
caskWarningRow,
separator(),
notifyCheckbox,
showCountCheckbox,
confirmCheckbox,
launchAtLoginCheckbox,
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
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)
}
/// Push current values into the controls. Called on every open, since
/// values may have changed while the window was closed.
func syncUI() {
guard let appDelegate else { return }
let interval = appDelegate.refreshInterval
if let presetIndex = presets.firstIndex(where: { $0.interval == interval }) {
intervalPopup.selectItem(at: presetIndex)
} else {
intervalPopup.selectItem(at: presets.count) // Custom
}
customMinutesField.stringValue = "\(Int(interval / 60))"
// SMAppService is the source of truth the user may have changed
// this in System Settings > Login Items while we weren't looking
launchAtLoginCheckbox.state = SMAppService.mainApp.status == .enabled ? .on : .off
greedyCheckbox.state = Settings.includeGreedyCasks ? .on : .off
autoUpgradeFormulaeCheckbox.state = Settings.autoUpgradeFormulae ? .on : .off
autoUpgradeCasksCheckbox.state = Settings.autoUpgradeCasks ? .on : .off
notifyCheckbox.state = Settings.notifyOnNewUpdates ? .on : .off
showCountCheckbox.state = Settings.showCountInMenuBar ? .on : .off
confirmCheckbox.state = Settings.confirmBeforeUpgradeAll ? .on : .off
}
func separator() -> NSBox {
let box = NSBox()
box.boxType = .separator
return box
}
/// Small gray explanatory text, indented to read as belonging to the
/// checkbox above it.
func noteRow(_ text: String) -> NSStackView {
let label = NSTextField(wrappingLabelWithString: text)
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
label.textColor = .secondaryLabelColor
label.preferredMaxLayoutWidth = 320
let spacer = NSView()
spacer.widthAnchor.constraint(equalToConstant: 18).isActive = true
let row = NSStackView(views: [spacer, label])
row.orientation = .horizontal
return row
}
// MARK: - Actions
@objc func presetPicked(_ sender: NSPopUpButton) {
guard sender.indexOfSelectedItem < presets.count else {
window?.makeFirstResponder(customMinutesField)
return
}
applyInterval(presets[sender.indexOfSelectedItem].interval)
}
@objc func customMinutesEntered(_ sender: NSTextField) {
guard let minutes = Int(sender.stringValue), minutes > 0 else {
syncUI() // reject invalid input by restoring the current value
return
}
applyInterval(TimeInterval(minutes * 60))
}
func applyInterval(_ interval: TimeInterval) {
appDelegate?.refreshInterval = interval
appDelegate?.restartTimer()
syncUI()
}
@objc func toggleGreedy(_ sender: NSButton) {
Settings.includeGreedyCasks = sender.state == .on
appDelegate?.fetchOutdated() // the list contents just changed meaning
}
@objc func toggleAutoUpgradeFormulae(_ sender: NSButton) {
Settings.autoUpgradeFormulae = sender.state == .on
}
@objc func toggleAutoUpgradeCasks(_ sender: NSButton) {
Settings.autoUpgradeCasks = sender.state == .on
}
@objc func toggleNotify(_ sender: NSButton) {
guard sender.state == .on else {
Settings.notifyOnNewUpdates = false
return
}
// 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
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 {
Settings.notifyOnNewUpdates = granted
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) {
Settings.showCountInMenuBar = sender.state == .on
appDelegate?.updateMenuBarCount() // reflect immediately, no refetch needed
}
@objc func toggleConfirm(_ sender: NSButton) {
Settings.confirmBeforeUpgradeAll = sender.state == .on
}
@objc func toggleLaunchAtLogin(_ sender: NSButton) {
do {
if sender.state == .on {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
sender.state = sender.state == .on ? .off : .on // revert on failure
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Could not change login item"
alert.informativeText = error.localizedDescription
alert.runModal()
}
}
}