@@ -11,7 +11,8 @@ import UserNotifications
11
11
import SwiftUI
12
12
import Combine
13
13
14
- class AppDelegate : NSObject , NSApplicationDelegate {
14
+ class AppDelegate : NSObject , NSApplicationDelegate , NSPopoverDelegate {
15
+ var popover : NSPopover !
15
16
var statusItem : NSStatusItem ?
16
17
var windowController : NSWindowController ?
17
18
var transparentWindowController : TransparentWindowController ?
@@ -21,14 +22,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
21
22
static var shouldExit = false
22
23
private var notificationDelegate : NotificationDelegate ?
23
24
private var cancellables : Set < AnyCancellable > = [ ]
25
+ private var trayManager : TrayMenuManager { TrayMenuManager . shared }
26
+
24
27
@AppStorage ( " isDarkMode " ) private var isDarkMode : Bool = false
25
28
29
+ var hasUpdatesAvailable : Bool {
30
+ appStateManager. pendingUpdatesCount > 0 || appStateManager. systemUpdateCache. count > 0
31
+ }
26
32
27
33
func application( _ application: NSApplication , open urls: [ URL ] ) {
28
34
guard let url = urls. first else { return }
29
35
switch url. host? . lowercased ( ) {
30
36
case nil :
31
37
AppDelegate . shouldExit = true
38
+ if let statusItem = statusItem {
39
+ Logger . shared. logDebug ( " Removing status item " )
40
+ NSStatusBar . system. removeStatusItem ( statusItem)
41
+ self . statusItem = nil
42
+ }
32
43
default :
33
44
AppDelegate . shouldExit = false
34
45
}
@@ -38,14 +49,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
38
49
}
39
50
40
51
func applicationDidFinishLaunching( _ notification: Notification ) {
41
- setupTrayMenu ( )
42
- let icon = NSImage ( named: " MenuIcon " )
43
- icon? . size = NSSize ( width: 16 , height: 16 )
44
- statusItem? . button? . image = icon
45
- statusItem? . button? . image? . isTemplate = true
46
-
47
- appStateManager. refreshAll ( )
52
+ if !AppDelegate. shouldExit {
53
+ setupTrayMenu ( )
54
+ }
55
+
56
+ popover = NSPopover ( )
57
+ popover. behavior = . transient // Closes when clicking outside
58
+ popover. contentSize = NSSize ( width: 500 , height: 520 )
59
+ popover. contentViewController = NSHostingController (
60
+ rootView: TrayMenuView (
61
+ viewModel: CardGridViewModel ( appState: AppStateManager . shared)
62
+ )
63
+ . environmentObject ( AppStateManager . shared)
64
+ )
65
+ popover. delegate = self
66
+
48
67
configureAppUpdateNotificationCommand ( mode: appStateManager. preferences. mode)
68
+
69
+ appStateManager. showWindowCallback = { [ weak self] in
70
+ self ? . showWindow ( )
71
+ }
49
72
50
73
if appStateManager. preferences. showDesktopInfo {
51
74
// Initialize transparent window
@@ -63,67 +86,142 @@ class AppDelegate: NSObject, NSApplicationDelegate {
63
86
notificationDelegate = NotificationDelegate ( )
64
87
UNUserNotificationCenter . current ( ) . delegate = notificationDelegate
65
88
appStateManager. startBackgroundTasks ( )
66
-
67
- appStateManager. preferences. $actions
68
- . debounce ( for: . seconds( 0.5 ) , scheduler: RunLoop . main)
69
- . sink { [ weak self] _ in
70
- self ? . setupTrayMenu ( )
71
- }
72
- . store ( in: & cancellables)
73
-
89
+ appStateManager. refreshAll ( )
74
90
}
75
91
76
92
private func setupTrayMenu( ) {
77
- // Initialize status item only if it doesn't already exist
93
+ let trayManager = TrayMenuManager . shared
78
94
if statusItem == nil {
79
95
statusItem = NSStatusBar . system. statusItem ( withLength: NSStatusItem . variableLength)
80
- if let button = statusItem? . button {
81
- let icon = NSImage ( named: " MenuIcon " )
82
- icon? . size = NSSize ( width: 16 , height: 16 )
83
- button. image = icon
84
- button. image? . isTemplate = true
96
+
97
+ setupTrayMenuIconBinding ( )
98
+
99
+ if let button = trayManager. getStatusItem ( ) . button {
100
+ button. action = #selector( togglePopover)
101
+ button. target = self
102
+ }
103
+ }
104
+ }
105
+
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)
85
114
}
115
+ . store ( in: & cancellables)
116
+ }
117
+
118
+ class TrayMenuManager {
119
+ static let shared = TrayMenuManager ( )
120
+
121
+ private var statusItem : NSStatusItem
122
+
123
+ private init ( ) {
124
+ statusItem = NSStatusBar . system. statusItem ( withLength: NSStatusItem . variableLength)
125
+ updateTrayIcon ( hasUpdates: false ) // Default state
86
126
}
87
127
88
- // Update the menu
89
- let menu = NSMenu ( )
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
+ }
134
+
135
+ baseIcon. size = NSSize ( width: 16 , height: 16 )
136
+ baseIcon. isTemplate = true // Ensure base icon respects system appearance
137
+
138
+ if let button = statusItem. button {
139
+ // Clear any existing layers
140
+ button. layer? . sublayers? . forEach { $0. removeFromSuperlayer ( ) }
141
+
142
+ // Set the base icon as the button's image
143
+ button. image = baseIcon
144
+ button. image? . isTemplate = true
145
+
146
+ if hasUpdates {
147
+ Logger . shared. logDebug ( " Updates available, adding badge to tray icon " )
148
+
149
+ // Add badge dynamically as a layer
150
+ let badgeLayer = CALayer ( )
151
+ badgeLayer. backgroundColor = NSColor . red. cgColor
152
+ badgeLayer. frame = CGRect (
153
+ x: button. bounds. width - 15 , // Align to the lower-right corner
154
+ y: 10 , // Small offset from the bottom
155
+ width: 8 ,
156
+ height: 8
157
+ )
158
+ badgeLayer. cornerRadius = 4 // Make it circular
159
+
160
+ // Ensure button has a layer to add sublayers
161
+ if button. layer == nil {
162
+ button. wantsLayer = true
163
+ button. layer = CALayer ( )
164
+ }
165
+
166
+ button. layer? . addSublayer ( badgeLayer)
167
+ }
168
+ }
169
+ }
90
170
91
- menu. addItem ( NSMenuItem ( title: Constants . TrayMenu. openApp, action: #selector( showWindow) , keyEquivalent: " o " ) )
92
- menu. addItem ( NSMenuItem . separator ( ) )
171
+ func getStatusItem( ) -> NSStatusItem {
172
+ return statusItem
173
+ }
174
+ }
93
175
94
- // Create Actions submenu
95
- let actionsSubmenu = NSMenu ( )
96
- for action in appStateManager. preferences. actions {
97
- let actionItem = NSMenuItem ( title: action. name, action: #selector( runAction) , keyEquivalent: " " )
98
- actionItem. representedObject = action
99
- actionsSubmenu. addItem ( actionItem)
176
+ @objc private func togglePopover( ) {
177
+ guard let button = trayManager. getStatusItem ( ) . button else {
178
+ print ( " Error: TrayMenuManager's statusItem.button is nil " )
179
+ return
100
180
}
101
181
102
- let actionsMenuItem = NSMenuItem ( title: Constants . CardTitle. actions, action: nil , keyEquivalent: " " )
103
- menu. setSubmenu ( actionsSubmenu, for: actionsMenuItem)
104
- menu. addItem ( actionsMenuItem)
182
+ if popover. isShown {
183
+ popover. performClose ( nil )
184
+ } else {
185
+ // Dynamically set the popover content
186
+ popover. contentViewController = NSHostingController (
187
+ rootView: TrayMenuView (
188
+ viewModel: CardGridViewModel ( appState: AppStateManager . shared)
189
+ )
190
+ . environmentObject ( AppStateManager . shared)
191
+ )
192
+
193
+ // Anchor the popover to the status item's button
194
+ popover. show ( relativeTo: button. bounds, of: button, preferredEdge: . minY)
105
195
106
- menu. addItem ( NSMenuItem . separator ( ) )
107
- menu. addItem ( NSMenuItem ( title: Constants . TrayMenu. quitApp, action: #selector( quitApp) , keyEquivalent: " q " ) )
196
+ // Ensure the popover window is brought to the front
197
+ if let popoverWindow = popover. contentViewController? . view. window {
198
+ popoverWindow. makeKeyAndOrderFront ( nil )
199
+ NSApp . activate ( ignoringOtherApps: true )
200
+ }
201
+ }
202
+ }
108
203
109
- // Assign the updated menu to the status item
110
- statusItem? . menu = menu
204
+ func popoverDidClose( _ notification: Notification ) {
205
+ Logger . shared. logDebug ( " Popover closed, cleaning up... " )
206
+
207
+ // Cleanup logic: release the popover or its content
208
+ popover. contentViewController = nil
111
209
}
112
210
113
- @objc private func showWindow( ) {
211
+ @objc func showWindow( ) {
114
212
if windowController == nil {
115
213
NSApp . setActivationPolicy ( . regular)
116
214
let contentView = ContentView ( )
117
215
. environmentObject ( AppStateManager . shared)
118
216
. environmentObject ( Preferences ( ) )
119
- . frame ( minWidth: 1500 , minHeight: 900 )
217
+ . frame ( minWidth: 1500 , minHeight: 950 )
120
218
121
219
let hostingController = NSHostingController ( rootView: contentView)
122
220
123
221
let window = NSWindow ( contentViewController: hostingController)
124
- window. setContentSize ( NSSize ( width: 1500 , height: 900 ) )
222
+ window. setContentSize ( NSSize ( width: 1500 , height: 950 ) )
125
223
window. styleMask = [ . titled, . closable, . resizable]
126
- window. minSize = NSSize ( width: 1500 , height: 900 )
224
+ window. minSize = NSSize ( width: 1500 , height: 950 )
127
225
window. title = " "
128
226
window. isReleasedWhenClosed = false
129
227
window. backgroundColor = . clear
0 commit comments