3import UserNotifications
5/// Main app delegate that manages the menu bar item, the refresh timer,
6/// the SwiftUI popover hosting `MenuView`, and sleep/wake handling.
8/// Marked `@MainActor` because `ViewModel` is `@MainActor`-isolated and the
9/// delegate accesses it directly. All `NSApplicationDelegate` methods are
10/// called on the main thread regardless, so this is semantically correct.
12final class AppDelegate: NSObject, NSApplicationDelegate {
13 private var statusItem: NSStatusItem!
14 private var popover: NSPopover!
15 private var refreshTimer: Timer?
17 /// The shared view model. Shared between the menu bar and the popover.
18 let viewModel = ViewModel()
20 /// Combine cancellables for observing published changes.
21 private var statusTextCancellable: AnyCancellable?
23 /// The site the "Open" menu item points at.
24 private static let websiteURL = URL(string: "https://hyper.charm.land")!
28 func applicationDidFinishLaunching(_ notification: Notification) {
29 // Only prompt if the user has never answered; re-asking after a denial
30 // does nothing, and after an approval it's pointless.
31 requestNotificationPermissionIfNeeded()
33 // Create status bar item
34 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
36 if let button = statusItem.button {
40 button.action = #selector(statusItemClicked(_:))
41 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
44 // Create popover with SwiftUI content
46 popover.behavior = .transient
47 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
49 // Observe published changes (balance + isLoading + hyperConfigured +
50 // Claude usage + z.ai usage) to update the menu bar title. CombineLatest
51 // fires when any of them changes.
53 // The title is built from the values the publisher hands us, not by reading the
54 // view model back: `@Published` emits in `willSet`, so the property still holds
55 // its old value while this closure runs.
57 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
58 // whose Combine scheduler only runs in the default run loop mode — the title
59 // would freeze while a menu or a scroll was tracking.
60 // Combine has CombineLatest4 but not 5, so nest: outer combines
61 // balance + isLoading + hyperConfigured with an inner that merges
62 // claudeUsage + zaiUsage. The inner Just(()) is a dummy third stream
63 // to satisfy CombineLatest3 — it fires once and never again, so the
64 // inner effectively republishes claudeUsage and zaiUsage changes.
65 statusTextCancellable = Publishers.CombineLatest4(
68 viewModel.$hyperConfigured,
69 Publishers.CombineLatest3(
70 viewModel.$claudeUsage,
75 .map { balance, isLoading, hyperConfigured, claudeAndZai in
76 let (claudeUsage, zaiUsage, _) = claudeAndZai
77 let fiveHour = claudeUsage?.fiveHour
78 .map { Int($0.utilization.rounded()) }
79 let sevenDay = claudeUsage?.sevenDay
80 .map { Int($0.utilization.rounded()) }
81 ?? claudeUsage?.sevenDayOpus
82 .map { Int($0.utilization.rounded()) }
83 let zaiFiveHour = zaiUsage?.fiveHourPercent
84 let zaiWeekly = zaiUsage?.weeklyPercent
85 return ViewModel.statusBarText(
88 hyperConfigured: hyperConfigured,
89 claudeFiveHourPercent: fiveHour,
90 claudeSevenDayPercent: sevenDay,
91 zaiFiveHourPercent: zaiFiveHour,
92 zaiWeeklyPercent: zaiWeekly
96 .receive(on: DispatchQueue.main)
97 .sink { [weak self] text in
98 self?.statusItem.button?.title = text
101 // Register for sleep/wake notifications
102 registerSleepWakeNotifications()
104 // Restart the timer whenever the user picks a new interval in the popover.
105 NotificationCenter.default.addObserver(
107 selector: #selector(refreshIntervalDidChange(_:)),
108 name: .refreshIntervalDidChange,
115 // Start periodic refresh
119 func applicationWillTerminate(_ notification: Notification) {
120 refreshTimer?.invalidate()
121 statusTextCancellable?.cancel()
122 NotificationCenter.default.removeObserver(self)
123 NSWorkspace.shared.notificationCenter.removeObserver(self)
126 // MARK: - Refresh Timer
128 /// (Re)starts the periodic refresh using the interval stored in the view model.
129 private func startRefreshTimer() {
130 refreshTimer?.invalidate()
132 let minutes = max(1, viewModel.refreshIntervalMinutes)
133 let interval = TimeInterval(minutes * 60)
135 let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
136 Task { @MainActor in self?.viewModel.refresh() }
138 RunLoop.main.add(timer, forMode: .common)
142 @objc private func refreshIntervalDidChange(_ notification: Notification) {
146 // MARK: - Notification Permission
148 private func requestNotificationPermissionIfNeeded() {
149 let center = UNUserNotificationCenter.current()
150 center.getNotificationSettings { settings in
151 guard settings.authorizationStatus == .notDetermined else { return }
152 center.requestAuthorization(options: [.alert, .sound]) { _, _ in
153 // Permission result is handled silently; notifications are optional.
158 // MARK: - Sleep / Wake
160 private func registerSleepWakeNotifications() {
161 let workspace = NSWorkspace.shared
162 let notificationCenter = workspace.notificationCenter
164 notificationCenter.addObserver(
166 selector: #selector(systemWillSleep(_:)),
167 name: NSWorkspace.willSleepNotification,
171 notificationCenter.addObserver(
173 selector: #selector(systemDidWake(_:)),
174 name: NSWorkspace.didWakeNotification,
179 @objc private func systemWillSleep(_ notification: Notification) {
180 // Nothing to do on sleep; timer will pause naturally.
183 @objc private func systemDidWake(_ notification: Notification) {
184 // Refresh after wake to get a fresh balance.
188 // MARK: - Status Item Click
190 /// Left-click toggles the popover; right-click (or control-click) opens the context menu.
191 @objc private func statusItemClicked(_ sender: Any?) {
192 let event = NSApp.currentEvent
193 let isRightClick = event?.type == .rightMouseUp
194 || event?.type == .rightMouseDown
195 || event?.modifierFlags.contains(.control) == true
204 // MARK: - Context Menu
206 private func showContextMenu() {
208 popover.performClose(nil)
213 let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu(_:)), keyEquivalent: "r")
214 refreshItem.target = self
215 menu.addItem(refreshItem)
217 let openItem = NSMenuItem(title: "Open hyper.charm.land", action: #selector(openWebsite(_:)), keyEquivalent: "")
218 openItem.target = self
219 menu.addItem(openItem)
221 menu.addItem(.separator())
223 let quitItem = NSMenuItem(title: "Quit UsageMenubar", action: #selector(quit(_:)), keyEquivalent: "q")
224 quitItem.target = self
225 menu.addItem(quitItem)
227 // Attaching the menu makes the next click present it (with the correct
228 // highlight + placement), then we detach so left-clicks keep firing our
229 // action instead of reopening the menu.
230 statusItem.menu = menu
231 statusItem.button?.performClick(nil)
232 statusItem.menu = nil
235 @objc private func refreshFromMenu(_ sender: Any?) {
239 @objc private func openWebsite(_ sender: Any?) {
240 NSWorkspace.shared.open(Self.websiteURL)
243 @objc private func quit(_ sender: Any?) {
244 NSApplication.shared.terminate(nil)
249 /// Shows the popover if it isn't already visible.
251 /// We don't toggle: the popover has `.transient` behavior, which closes it
252 /// automatically when the user clicks outside it — including on the status
253 /// item. By the time our `.leftMouseUp` action fires, a previously-shown
254 /// popover has already closed, so `isShown` is `false` and a toggle would
255 /// reopen it instead of dismissing it. Always showing means the first click
256 /// opens and `.transient` handles the rest.
257 private func togglePopover() {
258 guard let button = statusItem.button else { return }
259 guard !popover.isShown else { return }
260 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
261 NSApp.activate(ignoringOtherApps: true)
265// MARK: - App Entry Point
268struct UsageMenubarApp: App {
269 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
271 var body: some Scene {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list