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>
332 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|