Compare commits
14 commits
9f848be875
...
421ae5c40d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421ae5c40d | ||
|
|
c296c03ee5 | ||
|
|
813fc47461 | ||
|
|
7b3468126a | ||
|
|
3a5a7ee623 | ||
|
|
741f9572b7 | ||
|
|
0bc18b79b8 | ||
|
|
29b9412a34 | ||
|
|
32d81423ab | ||
|
|
7c53413e40 | ||
|
|
bd19bee52e | ||
|
|
617e07d53d | ||
|
|
98d8cbe2a9 | ||
|
|
be49480732 |
|
|
@ -1,4 +1,5 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
@ -20,6 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var versionItem: NSMenuItem!
|
var versionItem: NSMenuItem!
|
||||||
|
|
||||||
var logWindow: LogWindowController?
|
var logWindow: LogWindowController?
|
||||||
|
var settingsWindow: SettingsWindowController?
|
||||||
var logBuffer: String = ""
|
var logBuffer: String = ""
|
||||||
|
|
||||||
var brewPath: String?
|
var brewPath: String?
|
||||||
|
|
@ -38,12 +40,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// last known outdated list, shown in the Upgrade All confirmation
|
/// last known outdated list, shown in the Upgrade All confirmation
|
||||||
var cachedOutdated: [String] = []
|
var cachedOutdated: OutdatedPackages = .none
|
||||||
|
var lastNotifiedOutdated: Set<String> = []
|
||||||
|
|
||||||
// MARK: - Menu
|
// MARK: - Menu
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_: Notification) {
|
func applicationDidFinishLaunching(_: Notification) {
|
||||||
|
Settings.registerDefaults()
|
||||||
|
|
||||||
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
|
||||||
setMenuBarIcon("brewbar-uptodate")
|
setMenuBarIcon("brewbar-uptodate")
|
||||||
|
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
|
|
@ -66,40 +72,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
let intervalItem = NSMenuItem(title: "⏱ Refresh Interval", action: nil, keyEquivalent: "")
|
menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))
|
||||||
let intervalSubmenu = NSMenu()
|
|
||||||
|
|
||||||
let options: [(String, TimeInterval)] = [
|
|
||||||
("1 heure", 3600),
|
|
||||||
("6 heures", 21600),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (label, interval) in options {
|
|
||||||
let item = NSMenuItem(title: label, action: #selector(setRefreshInterval(_:)), keyEquivalent: "")
|
|
||||||
item.representedObject = interval
|
|
||||||
intervalSubmenu.addItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
let customMenuItem = NSMenuItem()
|
|
||||||
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 30))
|
|
||||||
|
|
||||||
let textField = NSTextField(frame: NSRect(x: 10, y: 4, width: 120, height: 22))
|
|
||||||
textField.placeholderString = "Secondes..."
|
|
||||||
textField.stringValue = "\(Int(refreshInterval))"
|
|
||||||
|
|
||||||
let confirmButton = NSButton(frame: NSRect(x: 138, y: 4, width: 52, height: 22))
|
|
||||||
confirmButton.title = "✓ Set"
|
|
||||||
confirmButton.bezelStyle = .rounded
|
|
||||||
confirmButton.target = self
|
|
||||||
confirmButton.action = #selector(applyCustomInterval(_:))
|
|
||||||
|
|
||||||
customView.addSubview(textField)
|
|
||||||
customView.addSubview(confirmButton)
|
|
||||||
customMenuItem.view = customView
|
|
||||||
intervalSubmenu.addItem(customMenuItem)
|
|
||||||
|
|
||||||
intervalItem.submenu = intervalSubmenu
|
|
||||||
menu.addItem(intervalItem)
|
|
||||||
|
|
||||||
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
|
versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
|
||||||
menu.addItem(versionItem)
|
menu.addItem(versionItem)
|
||||||
|
|
@ -139,20 +112,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setRefreshInterval(_ sender: NSMenuItem) {
|
@objc func showSettings() {
|
||||||
guard let interval = sender.representedObject as? TimeInterval else { return }
|
if settingsWindow == nil {
|
||||||
refreshInterval = interval // ← le set sauvegarde dans UserDefaults
|
settingsWindow = SettingsWindowController(appDelegate: self)
|
||||||
restartTimer()
|
|
||||||
}
|
}
|
||||||
|
settingsWindow?.syncUI()
|
||||||
@objc func applyCustomInterval(_ sender: NSButton) {
|
settingsWindow?.showWindow(nil)
|
||||||
guard let view = sender.superview,
|
// accessory app: without activation the window can open behind others
|
||||||
let textField = view.subviews.first(where: { $0 is NSTextField }) as? NSTextField,
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
let seconds = Int(textField.stringValue), seconds > 0 else { return }
|
settingsWindow?.window?.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
refreshInterval = TimeInterval(seconds)
|
|
||||||
restartTimer()
|
|
||||||
statusItem.menu?.cancelTracking()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveBrewPath() -> String {
|
func resolveBrewPath() -> String {
|
||||||
|
|
@ -242,13 +210,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func upgradeAll() {
|
@objc func upgradeAll() {
|
||||||
|
if Settings.confirmBeforeUpgradeAll {
|
||||||
guard confirmUpgrade() else { return }
|
guard confirmUpgrade() else { return }
|
||||||
|
}
|
||||||
|
|
||||||
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("upgrade") { _ in
|
self.runBrew(command) { _ in
|
||||||
self.fetchOutdated()
|
// everything was just upgraded; don't chain an auto-upgrade
|
||||||
|
self.fetchOutdated(allowAutoUpgrade: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +240,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, 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 {
|
if !cachedOutdated.isEmpty {
|
||||||
info += "\n\n" + cachedOutdated.joined(separator: "\n")
|
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
|
||||||
}
|
}
|
||||||
alert.informativeText = info
|
alert.informativeText = info
|
||||||
|
|
||||||
|
|
@ -277,17 +252,38 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
return alert.runModal() == .alertFirstButtonReturn
|
return alert.runModal() == .alertFirstButtonReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchOutdated() {
|
/// allowAutoUpgrade guards against an upgrade loop: packages can stay
|
||||||
|
/// outdated after an upgrade (pinned formulae, greedy casks brew declines),
|
||||||
|
/// so the fetch that follows an auto-upgrade must not trigger another one.
|
||||||
|
func fetchOutdated(allowAutoUpgrade: Bool = true) {
|
||||||
setMenuBarIcon("brewbar-updating")
|
setMenuBarIcon("brewbar-updating")
|
||||||
|
|
||||||
runBrew("outdated") { output in
|
// --greedy also lists casks that self-update (brew skips them by default)
|
||||||
let lines = BrewParser.parseOutdated(output)
|
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
|
||||||
|
|
||||||
self.cachedOutdated = lines
|
runBrew(command) { output in
|
||||||
|
let outdated = BrewParser.parseOutdatedJSON(output)
|
||||||
|
|
||||||
self.updateOutdatedMenu(with: lines)
|
self.cachedOutdated = outdated
|
||||||
self.updateStatus(count: lines.count)
|
|
||||||
if lines.isEmpty {
|
// reflect what was found before any auto-upgrade starts, so the
|
||||||
|
// menu shows the real list while the upgrade is running
|
||||||
|
self.updateOutdatedMenu(with: outdated)
|
||||||
|
self.updateStatus(count: outdated.count)
|
||||||
|
self.updateMenuBarCount()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
|
||||||
|
if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
|
||||||
|
self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify only about what auto-upgrade didn't (or couldn't) handle
|
||||||
|
self.notifyIfNeeded(outdated: outdated.all.map(\.name))
|
||||||
|
if outdated.isEmpty {
|
||||||
self.setMenuBarIcon("brewbar-uptodate")
|
self.setMenuBarIcon("brewbar-uptodate")
|
||||||
} else {
|
} else {
|
||||||
self.setMenuBarIcon("brewbar-outdated")
|
self.setMenuBarIcon("brewbar-outdated")
|
||||||
|
|
@ -295,18 +291,67 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOutdatedMenu(with lines: [String]) {
|
/// Notifies only when packages appear that weren't in the last notified
|
||||||
outdatedSubmenu.removeAllItems()
|
/// set — an unchanged list stays silent instead of pinging every refresh.
|
||||||
outdatedItem.isHidden = lines.isEmpty
|
func notifyIfNeeded(outdated: [String]) {
|
||||||
upgradeAllItem.isHidden = lines.isEmpty
|
let current = Set(outdated)
|
||||||
|
defer { lastNotifiedOutdated = current }
|
||||||
|
|
||||||
for formula in lines {
|
guard Settings.notifyOnNewUpdates,
|
||||||
let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
|
!current.subtracting(lastNotifiedOutdated).isEmpty else { return }
|
||||||
item.representedObject = formula
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Homebrew updates available"
|
||||||
|
let shown = outdated.prefix(4).joined(separator: ", ")
|
||||||
|
content.body = outdated.count > 4 ? "\(shown) and \(outdated.count - 4) more" : shown
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAutoUpgrade(formulae: Bool, casks: Bool) {
|
||||||
|
statusMenuItem.title = "Auto-upgrading..."
|
||||||
|
|
||||||
|
let upgradeCasksThenRefresh = {
|
||||||
|
if casks {
|
||||||
|
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
|
||||||
|
self.runBrew(command) { _ in
|
||||||
|
self.fetchOutdated(allowAutoUpgrade: false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.fetchOutdated(allowAutoUpgrade: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if formulae {
|
||||||
|
runBrew("upgrade --formula") { _ in
|
||||||
|
upgradeCasksThenRefresh()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upgradeCasksThenRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOutdatedMenu(with outdated: OutdatedPackages) {
|
||||||
|
outdatedSubmenu.removeAllItems()
|
||||||
|
outdatedItem.isHidden = outdated.isEmpty
|
||||||
|
upgradeAllItem.isHidden = outdated.isEmpty
|
||||||
|
|
||||||
|
for package in outdated.all {
|
||||||
|
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
|
||||||
|
item.representedObject = package.name // the title has versions; brew needs the bare name
|
||||||
outdatedSubmenu.addItem(item)
|
outdatedSubmenu.addItem(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateMenuBarCount() {
|
||||||
|
if Settings.showCountInMenuBar, !cachedOutdated.isEmpty {
|
||||||
|
statusItem.button?.title = " \(cachedOutdated.count)"
|
||||||
|
} else {
|
||||||
|
statusItem.button?.title = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updateStatus(count: Int) {
|
func updateStatus(count: Int) {
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
statusMenuItem.title = "✅ All up to date"
|
statusMenuItem.title = "✅ All up to date"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,50 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct OutdatedPackage: Codable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let installedVersions: [String]
|
||||||
|
let currentVersion: String
|
||||||
|
|
||||||
|
/// Menu label, e.g. "wget 1.21 → 1.22"
|
||||||
|
var label: String {
|
||||||
|
let installed = installedVersions.joined(separator: ", ")
|
||||||
|
return "\(name) \(installed) → \(currentVersion)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OutdatedPackages: Codable, Equatable {
|
||||||
|
let formulae: [OutdatedPackage]
|
||||||
|
let casks: [OutdatedPackage]
|
||||||
|
|
||||||
|
var all: [OutdatedPackage] {
|
||||||
|
formulae + casks
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
formulae.isEmpty && casks.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
formulae.count + casks.count
|
||||||
|
}
|
||||||
|
|
||||||
|
static let none = OutdatedPackages(formulae: [], casks: [])
|
||||||
|
}
|
||||||
|
|
||||||
enum BrewParser {
|
enum BrewParser {
|
||||||
static func parseOutdated(_ output: String) -> [String] {
|
/// Parses `brew outdated --json` output. Returns .none for anything
|
||||||
output.split(separator: "\n")
|
/// undecodable (brew error text, empty output) — same effect as an
|
||||||
.map { String($0) }
|
/// empty result, and the raw output is already in the log window.
|
||||||
.filter { !$0.isEmpty }
|
static func parseOutdatedJSON(_ output: String) -> OutdatedPackages {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase // installed_versions → installedVersions
|
||||||
|
|
||||||
|
guard let data = output.data(using: .utf8),
|
||||||
|
let parsed = try? decoder.decode(OutdatedPackages.self, from: data)
|
||||||
|
else {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseVersion(_ output: String) -> String {
|
static func parseVersion(_ output: String) -> String {
|
||||||
|
|
|
||||||
47
BrewBar/Settings.swift
Normal file
47
BrewBar/Settings.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// UserDefaults-backed app settings. Booleans read false for unset keys,
|
||||||
|
/// which covers the disabled-by-default settings; enabled-by-default ones
|
||||||
|
/// get their fallback via registerDefaults() (consulted only when the user
|
||||||
|
/// has never changed the value — nothing is written to disk).
|
||||||
|
///
|
||||||
|
/// Launch-at-login is intentionally absent: SMAppService is its source of
|
||||||
|
/// truth, so storing a copy here could only drift out of sync.
|
||||||
|
enum Settings {
|
||||||
|
static func registerDefaults() {
|
||||||
|
UserDefaults.standard.register(defaults: [
|
||||||
|
"includeGreedyCasks": true,
|
||||||
|
"confirmBeforeUpgradeAll": true,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static var autoUpgradeFormulae: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "autoUpgradeFormulae") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "autoUpgradeFormulae") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var autoUpgradeCasks: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "autoUpgradeCasks") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "autoUpgradeCasks") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var notifyOnNewUpdates: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "notifyOnNewUpdates") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "notifyOnNewUpdates") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var showCountInMenuBar: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "showCountInMenuBar") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "showCountInMenuBar") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var includeGreedyCasks: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "includeGreedyCasks") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "includeGreedyCasks") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var confirmBeforeUpgradeAll: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "confirmBeforeUpgradeAll") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "confirmBeforeUpgradeAll") }
|
||||||
|
}
|
||||||
|
}
|
||||||
284
BrewBar/SettingsWindowController.swift
Normal file
284
BrewBar/SettingsWindowController.swift
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
|
||||||
|
// callback arrives on a background queue; UI needs main
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
Settings.notifyOnNewUpdates = granted
|
||||||
|
sender.state = granted ? .on : .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,24 +2,54 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class BrewParserTests: XCTestCase {
|
final class BrewParserTests: XCTestCase {
|
||||||
// MARK: - parseOutdated
|
// MARK: - parseOutdatedJSON
|
||||||
|
|
||||||
func testParseOutdated_multiplePackages() {
|
func testParseOutdatedJSON_formulaeAndCasks() {
|
||||||
let output = "wget\nffmpeg\ngit\n"
|
let output = """
|
||||||
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget", "ffmpeg", "git"])
|
{
|
||||||
|
"formulae": [
|
||||||
|
{
|
||||||
|
"name": "wget",
|
||||||
|
"installed_versions": ["1.21.3"],
|
||||||
|
"current_version": "1.21.4",
|
||||||
|
"pinned": false,
|
||||||
|
"pinned_version": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"casks": [
|
||||||
|
{
|
||||||
|
"name": "tailscale-app",
|
||||||
|
"installed_versions": ["1.98.5"],
|
||||||
|
"current_version": "1.98.8",
|
||||||
|
"pinned": false,
|
||||||
|
"pinned_version": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let result = BrewParser.parseOutdatedJSON(output)
|
||||||
|
XCTAssertEqual(result.formulae.map(\.name), ["wget"])
|
||||||
|
XCTAssertEqual(result.casks.map(\.name), ["tailscale-app"])
|
||||||
|
XCTAssertEqual(result.count, 2)
|
||||||
|
XCTAssertFalse(result.isEmpty)
|
||||||
|
XCTAssertEqual(result.all.map(\.name), ["wget", "tailscale-app"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testParseOutdated_emptyOutput() {
|
func testParseOutdatedJSON_nothingOutdated() {
|
||||||
XCTAssertEqual(BrewParser.parseOutdated(""), [])
|
let result = BrewParser.parseOutdatedJSON("{\"formulae\": [], \"casks\": []}")
|
||||||
|
XCTAssertTrue(result.isEmpty)
|
||||||
|
XCTAssertEqual(result.count, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testParseOutdated_singlePackage() {
|
func testParseOutdatedJSON_invalidInputReturnsNone() {
|
||||||
XCTAssertEqual(BrewParser.parseOutdated("wget\n"), ["wget"])
|
XCTAssertEqual(BrewParser.parseOutdatedJSON(""), .none)
|
||||||
|
XCTAssertEqual(BrewParser.parseOutdatedJSON("Error: some brew failure"), .none)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testParseOutdated_trailingNewlinesIgnored() {
|
func testParseOutdatedJSON_packageLabel() {
|
||||||
let output = "wget\n\n\n"
|
let package = OutdatedPackage(name: "wget", installedVersions: ["1.21.3"], currentVersion: "1.21.4")
|
||||||
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"])
|
XCTAssertEqual(package.label, "wget 1.21.3 → 1.21.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - parseVersion
|
// MARK: - parseVersion
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue