Skip to content

Commit e87173f

Browse files
committed
Add badge to tray menu icon
1 parent a413cac commit e87173f

File tree

2 files changed

+105
-57
lines changed

2 files changed

+105
-57
lines changed

SupportCompanion/AppDelegate.swift

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import UserNotifications
1111
import SwiftUI
1212
import Combine
1313

14-
class AppDelegate: NSObject, NSApplicationDelegate {
14+
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
1515
var popover: NSPopover!
1616
var statusItem: NSStatusItem?
1717
var windowController: NSWindowController?
@@ -22,8 +22,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2222
static var shouldExit = false
2323
private var notificationDelegate: NotificationDelegate?
2424
private var cancellables: Set<AnyCancellable> = []
25+
private var trayManager: TrayMenuManager { TrayMenuManager.shared }
26+
2527
@AppStorage("isDarkMode") private var isDarkMode: Bool = false
2628

29+
var hasUpdatesAvailable: Bool {
30+
appStateManager.pendingUpdatesCount > 0 || appStateManager.systemUpdateCache.count > 0
31+
}
32+
2733
func application(_ application: NSApplication, open urls: [URL]) {
2834
guard let url = urls.first else { return }
2935
switch url.host?.lowercased() {
@@ -46,10 +52,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4652
if !AppDelegate.shouldExit {
4753
setupTrayMenu()
4854
}
49-
let icon = NSImage(named: "MenuIcon")
50-
icon?.size = NSSize(width: 16, height: 16)
51-
statusItem?.button?.image = icon
52-
statusItem?.button?.image?.isTemplate = true
5355

5456
popover = NSPopover()
5557
popover.behavior = .transient // Closes when clicking outside
@@ -60,6 +62,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6062
)
6163
.environmentObject(AppStateManager.shared)
6264
)
65+
popover.delegate = self
66+
6367
configureAppUpdateNotificationCommand(mode: appStateManager.preferences.mode)
6468

6569
appStateManager.showWindowCallback = { [weak self] in
@@ -83,87 +87,106 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8387
UNUserNotificationCenter.current().delegate = notificationDelegate
8488
appStateManager.startBackgroundTasks()
8589
appStateManager.refreshAll()
86-
87-
/*appStateManager.preferences.$actions
88-
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
89-
.sink { [weak self] _ in
90-
self?.setupTrayMenu()
91-
}
92-
.store(in: &cancellables)*/
93-
9490
}
9591

96-
/*private func setupTrayMenu() {
97-
// Initialize status item only if it doesn't already exist
92+
private func setupTrayMenu() {
93+
let trayManager = TrayMenuManager.shared
9894
if statusItem == nil {
9995
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
100-
if let button = statusItem?.button {
101-
let icon = NSImage(named: "MenuIcon")
102-
icon?.size = NSSize(width: 16, height: 16)
103-
button.image = icon
104-
button.image?.isTemplate = true
96+
97+
setupTrayMenuIconBinding()
98+
99+
if let button = trayManager.getStatusItem().button {
100+
button.action = #selector(togglePopover)
101+
button.target = self
105102
}
106103
}
104+
}
107105

108-
// Update the menu
109-
let menu = NSMenu()
106+
func setupTrayMenuIconBinding() {
107+
appStateManager.$pendingUpdatesCount
108+
.combineLatest(appStateManager.$systemUpdateCache)
109+
.map { pendingUpdatesCount, systemUpdateCache in
110+
pendingUpdatesCount > 0 || systemUpdateCache.count > 0
111+
}
112+
.sink { hasUpdates in
113+
TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates)
114+
}
115+
.store(in: &cancellables)
116+
}
110117

111-
menu.addItem(NSMenuItem(title: Constants.TrayMenu.openApp, action: #selector(showWindow), keyEquivalent: "o"))
112-
menu.addItem(NSMenuItem.separator())
118+
class TrayMenuManager {
119+
static let shared = TrayMenuManager()
120+
121+
private var statusItem: NSStatusItem
113122

114-
// Create Actions submenu
115-
let actionsSubmenu = NSMenu()
116-
for action in appStateManager.preferences.actions {
117-
let actionItem = NSMenuItem(title: action.name, action: #selector(runAction), keyEquivalent: "")
118-
actionItem.representedObject = action
119-
actionsSubmenu.addItem(actionItem)
123+
private init() {
124+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
125+
updateTrayIcon(hasUpdates: false) // Default state
120126
}
121127

122-
let actionsMenuItem = NSMenuItem(title: Constants.CardTitle.actions, action: nil, keyEquivalent: "")
123-
menu.setSubmenu(actionsSubmenu, for: actionsMenuItem)
124-
menu.addItem(actionsMenuItem)
125-
126-
menu.addItem(NSMenuItem.separator())
127-
menu.addItem(NSMenuItem(title: Constants.TrayMenu.quitApp, action: #selector(quitApp), keyEquivalent: "q"))
128-
129-
// Assign the updated menu to the status item
130-
statusItem?.menu = menu
131-
}*/
128+
func updateTrayIcon(hasUpdates: Bool) {
129+
let iconName = "MenuIcon"
130+
guard let baseIcon = NSImage(named: iconName) else {
131+
print("Error: \(iconName) not found")
132+
return
133+
}
132134

133-
private func setupTrayMenu() {
134-
if statusItem == nil {
135-
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
136-
137-
if let button = statusItem?.button {
138-
let icon = NSImage(named: "MenuIcon")
139-
icon?.size = NSSize(width: 16, height: 16)
140-
button.image = icon
141-
button.image?.isTemplate = true
142-
143-
// Connect the button action
144-
button.action = #selector(togglePopover)
145-
button.target = self
135+
if hasUpdates {
136+
// Add a red dot badge
137+
Logger.shared.logDebug("Updates available, adding badge to tray icon")
138+
let iconWithBadge = baseIcon.compositeWithBadge(color: .red, badgeSize: 8)
139+
statusItem.button?.image = iconWithBadge
140+
baseIcon.size = NSSize(width: 16, height: 16)
141+
} else {
142+
// Use the base icon as is
143+
Logger.shared.logDebug("No updates available, showing base tray icon")
144+
baseIcon.isTemplate = true
145+
baseIcon.size = NSSize(width: 16, height: 16)
146+
statusItem.button?.image = baseIcon
146147
}
147148
}
149+
150+
func getStatusItem() -> NSStatusItem {
151+
return statusItem
152+
}
148153
}
149154

150155
@objc private func togglePopover() {
151-
guard let button = statusItem?.button else { return }
156+
guard let button = trayManager.getStatusItem().button else {
157+
print("Error: TrayMenuManager's statusItem.button is nil")
158+
return
159+
}
152160

153161
if popover.isShown {
154162
popover.performClose(nil)
155163
} else {
156-
// Show the popover relative to the status bar button
164+
// Dynamically set the popover content
165+
popover.contentViewController = NSHostingController(
166+
rootView: TrayMenuView(
167+
viewModel: CardGridViewModel(appState: AppStateManager.shared)
168+
)
169+
.environmentObject(AppStateManager.shared)
170+
)
171+
172+
// Anchor the popover to the status item's button
157173
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
158174

159-
// Bring the application and popover window to the front
175+
// Ensure the popover window is brought to the front
160176
if let popoverWindow = popover.contentViewController?.view.window {
161177
popoverWindow.makeKeyAndOrderFront(nil)
162-
NSApp.activate(ignoringOtherApps: true) // Ensure app gets focus
178+
NSApp.activate(ignoringOtherApps: true)
163179
}
164180
}
165181
}
166182

183+
func popoverDidClose(_ notification: Notification) {
184+
Logger.shared.logDebug("Popover closed, cleaning up...")
185+
186+
// Cleanup logic: release the popover or its content
187+
popover.contentViewController = nil
188+
}
189+
167190
@objc func showWindow() {
168191
if windowController == nil {
169192
NSApp.setActivationPolicy(.regular)

SupportCompanion/Extensions/Extensions.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,28 @@ extension Color {
9696
// Red shades
9797
static let redLight = Color(hue: 0.02, saturation: 0.8, brightness: 0.7) // Softer red for light mode
9898
}
99+
100+
extension NSImage {
101+
func compositeWithBadge(color: NSColor, badgeSize: CGFloat) -> NSImage? {
102+
let size = self.size
103+
let newImage = NSImage(size: size)
104+
105+
newImage.lockFocus()
106+
// Draw the base icon (template behavior applies)
107+
self.draw(at: .zero, from: NSRect(origin: .zero, size: size), operation: .sourceOver, fraction: 1.0)
108+
109+
// Draw the red dot badge
110+
color.setFill()
111+
let badgeRect = NSRect(
112+
x: size.width - badgeSize,
113+
y: 0,
114+
width: badgeSize,
115+
height: badgeSize
116+
)
117+
let badgePath = NSBezierPath(ovalIn: badgeRect)
118+
badgePath.fill()
119+
120+
newImage.unlockFocus()
121+
return newImage
122+
}
123+
}

0 commit comments

Comments
 (0)