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) to update the menu bar title.
50 // CombineLatest fires when either published property changes.
52 // The title is built from the values the publisher hands us, not by reading the
53 // view model back: `@Published` emits in `willSet`, so the property still holds
54 // its old value while this closure runs.
56 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
57 // whose Combine scheduler only runs in the default run loop mode — the title
58 // would freeze while a menu or a scroll was tracking.
59 statusTextCancellable = Publishers.CombineLatest(viewModel.$balance, viewModel.$isLoading)
60 .map { balance, isLoading in
61 ViewModel.statusBarText(balance: balance, isLoading: isLoading)
64 .receive(on: DispatchQueue.main)
65 .sink { [weak self] text in
66 self?.statusItem.button?.title = text
69 // Register for sleep/wake notifications
70 registerSleepWakeNotifications()
72 // Restart the timer whenever the user picks a new interval in the popover.
73 NotificationCenter.default.addObserver(
75 selector: #selector(refreshIntervalDidChange(_:)),
76 name: .refreshIntervalDidChange,
83 // Start periodic refresh
87 func applicationWillTerminate(_ notification: Notification) {
88 refreshTimer?.invalidate()
89 statusTextCancellable?.cancel()
90 NotificationCenter.default.removeObserver(self)
91 NSWorkspace.shared.notificationCenter.removeObserver(self)
94 // MARK: - Refresh Timer
96 /// (Re)starts the periodic refresh using the interval stored in the view model.
97 private func startRefreshTimer() {
98 refreshTimer?.invalidate()
100 let minutes = max(1, viewModel.refreshIntervalMinutes)
101 let interval = TimeInterval(minutes * 60)
103 let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
104 Task { @MainActor in self?.viewModel.refresh() }
106 RunLoop.main.add(timer, forMode: .common)
110 @objc private func refreshIntervalDidChange(_ notification: Notification) {
114 // MARK: - Notification Permission
116 private func requestNotificationPermissionIfNeeded() {
117 let center = UNUserNotificationCenter.current()
118 center.getNotificationSettings { settings in
119 guard settings.authorizationStatus == .notDetermined else { return }
120 center.requestAuthorization(options: [.alert, .sound]) { _, _ in
121 // Permission result is handled silently; notifications are optional.
126 // MARK: - Sleep / Wake
128 private func registerSleepWakeNotifications() {
129 let workspace = NSWorkspace.shared
130 let notificationCenter = workspace.notificationCenter
132 notificationCenter.addObserver(
134 selector: #selector(systemWillSleep(_:)),
135 name: NSWorkspace.willSleepNotification,
139 notificationCenter.addObserver(
141 selector: #selector(systemDidWake(_:)),
142 name: NSWorkspace.didWakeNotification,
147 @objc private func systemWillSleep(_ notification: Notification) {
148 // Nothing to do on sleep; timer will pause naturally.
151 @objc private func systemDidWake(_ notification: Notification) {
152 // Refresh after wake to get a fresh balance.
156 // MARK: - Status Item Click
158 /// Left-click toggles the popover; right-click (or control-click) opens the context menu.
159 @objc private func statusItemClicked(_ sender: Any?) {
160 let event = NSApp.currentEvent
161 let isRightClick = event?.type == .rightMouseUp
162 || event?.type == .rightMouseDown
163 || event?.modifierFlags.contains(.control) == true
172 // MARK: - Context Menu
174 private func showContextMenu() {
176 popover.performClose(nil)
181 let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu(_:)), keyEquivalent: "r")
182 refreshItem.target = self
183 menu.addItem(refreshItem)
185 let openItem = NSMenuItem(title: "Open hyper.charm.land", action: #selector(openWebsite(_:)), keyEquivalent: "")
186 openItem.target = self
187 menu.addItem(openItem)
189 menu.addItem(.separator())
191 let quitItem = NSMenuItem(title: "Quit UsageMenubar", action: #selector(quit(_:)), keyEquivalent: "q")
192 quitItem.target = self
193 menu.addItem(quitItem)
195 // Attaching the menu makes the next click present it (with the correct
196 // highlight + placement), then we detach so left-clicks keep firing our
197 // action instead of reopening the menu.
198 statusItem.menu = menu
199 statusItem.button?.performClick(nil)
200 statusItem.menu = nil
203 @objc private func refreshFromMenu(_ sender: Any?) {
207 @objc private func openWebsite(_ sender: Any?) {
208 NSWorkspace.shared.open(Self.websiteURL)
211 @objc private func quit(_ sender: Any?) {
212 NSApplication.shared.terminate(nil)
217 /// Shows the popover if it isn't already visible.
219 /// We don't toggle: the popover has `.transient` behavior, which closes it
220 /// automatically when the user clicks outside it — including on the status
221 /// item. By the time our `.leftMouseUp` action fires, a previously-shown
222 /// popover has already closed, so `isShown` is `false` and a toggle would
223 /// reopen it instead of dismissing it. Always showing means the first click
224 /// opens and `.transient` handles the rest.
225 private func togglePopover() {
226 guard let button = statusItem.button else { return }
227 guard !popover.isShown else { return }
228 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
229 NSApp.activate(ignoringOtherApps: true)
233// MARK: - App Entry Point
236struct UsageMenubarApp: App {
237 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
239 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