4import ServiceManagement
5import UserNotifications
7/// Posted when the user changes the refresh interval, so the app delegate can
8/// restart its timer with the new period.
9extension Notification.Name {
10 static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
13/// The direction the balance has moved over the most recent history points.
18 /// Not enough history to say.
21 /// SF Symbol for the trend, or `nil` when there is nothing worth showing.
22 var symbolName: String? {
24 case .increasing: return "arrow.up.right"
25 case .decreasing: return "arrow.down.right"
26 case .stable: return "arrow.right"
27 case .unknown: return nil
32/// A single balance observation.
33struct BalancePoint: Equatable {
38/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
39/// the app delegate can read the options without hopping onto the main actor.
41 /// `UserDefaults` key for the refresh period, in minutes.
42 static let defaultsKey = "refreshIntervalMinutes"
44 /// The intervals offered in the settings picker.
45 static let options = [1, 5, 15, 30]
47 /// Used when nothing valid is stored.
48 static let defaultMinutes = 5
50 /// Reads the stored interval, falling back to the default when unset or when
51 /// the stored value isn't one we offer.
52 static func stored(in defaults: UserDefaults = .standard) -> Int {
53 let minutes = defaults.integer(forKey: defaultsKey)
54 guard options.contains(minutes) else { return defaultMinutes }
59/// The view model that drives the menu bar display and the popover content.
61final class ViewModel: ObservableObject {
62 /// The last known balance. `nil` when there has never been a successful fetch.
63 @Published var balance: Int?
65 /// `true` while a balance request is in flight.
66 @Published var isLoading = false
68 /// A human-readable error message, shown when the balance can't be fetched.
69 @Published var errorMessage: String?
71 /// Claude Code's subscription usage. `nil` when Claude Code has never signed in on
72 /// this machine, in which case the popover leaves the section out entirely rather
73 /// than nagging someone who doesn't use it.
74 @Published var claudeUsage: ClaudeUsage?
76 /// A human-readable error message from the Claude fetch. Independent of
77 /// `errorMessage`: the two services fail separately.
78 @Published var claudeError: String?
80 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
81 @Published var claudePlan: String?
83 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
84 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
85 @Published var apiKeyInput: String = ""
87 /// Timestamp of the last successful balance fetch.
88 @Published var lastUpdated: Date?
90 /// Brief "✓ Saved" confirmation shown after saving the API key.
91 @Published var savedConfirmation: Bool = false
93 /// Brief "✓ Copied" confirmation shown after copying the balance.
94 @Published var copiedConfirmation: Bool = false
96 /// Successful balance observations from the last 24 hours, oldest first.
97 @Published private(set) var history: [BalancePoint] = []
99 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
100 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
101 /// restart its timer.
102 @Published var refreshIntervalMinutes: Int {
104 guard refreshIntervalMinutes != oldValue else { return }
105 defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
106 NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
110 /// Whether launch-at-login is enabled.
111 @Published var launchAtLogin: Bool {
113 guard !isInitializingLaunchAtLogin else { return }
114 updateLaunchAtLogin(launchAtLogin)
118 /// Flag to suppress the didSet during initial setup.
119 private var isInitializingLaunchAtLogin = true
121 /// The key requests actually go out with: what is in the Keychain, not what is
122 /// currently in the text field. Keeping these separate stops a timer or wake
123 /// refresh from firing with a half-typed key — or, if the field has been cleared
124 /// to paste a new one, from taking the empty-key path and wiping the balance,
125 /// last-updated time and history out from under the user.
126 private var activeAPIKey: String
128 private let checker: CreditsChecking
129 private let claudeChecker: ClaudeUsageChecking
130 private let defaults: UserDefaults
132 /// The currently in-flight refresh task, if any. Used to cancel
133 /// overlapping refreshes (timer + user click + wake).
134 private var refreshTask: Task<Void, Never>?
136 /// Resets `copiedConfirmation`; held so rapid clicks re-arm the delay
137 /// instead of the first click's reset firing early.
138 private var copiedResetTask: Task<Void, Never>?
140 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
141 private var savedResetTask: Task<Void, Never>?
143 // MARK: - History Configuration
145 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
146 /// count cap and the age cutoff both apply; whichever bites first wins.
147 private static let maxHistoryEntries = 300
148 private static let historyWindow: TimeInterval = 24 * 60 * 60
151 checker: CreditsChecking = CreditsChecker(),
152 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
153 defaults: UserDefaults = .standard
155 self.checker = checker
156 self.claudeChecker = claudeChecker
157 self.defaults = defaults
158 let storedKey = KeychainHelper.load() ?? ""
159 apiKeyInput = storedKey
160 activeAPIKey = storedKey
161 // Assigning in init doesn't fire didSet, so this load doesn't re-post
162 // the change notification or write back to defaults.
163 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
164 launchAtLogin = SMAppService.mainApp.status == .enabled
165 isInitializingLaunchAtLogin = false
170 /// Cached number formatter for balance display.
171 private static let balanceFormatter: NumberFormatter = {
172 let formatter = NumberFormatter()
173 formatter.numberStyle = .decimal
177 /// Cached relative time formatter for "Updated Xm ago" text.
178 private static let relativeFormatter: RelativeDateTimeFormatter = {
179 let formatter = RelativeDateTimeFormatter()
180 formatter.unitsStyle = .short
184 /// Formats an integer with thousands grouping separator.
185 private static func formatBalance(_ value: Int) -> String {
186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
189 /// The text to show in the menu bar: `⚡{balance} · {percent}%` with both services,
190 /// and just the half that is there when only one of them is — an unconfigured or
191 /// failing service drops out of the title rather than taking a slot to say nothing.
192 /// With neither, the bolt stands in alone: `⚡…` while the first fetch is in flight,
193 /// `⚡?` once it has come back empty-handed.
195 /// Takes its values as parameters rather than reading the properties: `@Published`
196 /// publishes in `willSet`, so a Combine subscriber that called back into the view
197 /// model would see the *pre-change* value and render a title one update behind.
198 static func statusBarText(balance: Int?, isLoading: Bool, claudePercent: Int?) -> String {
199 var parts: [String] = []
200 if let balance = balance {
201 parts.append("⚡\(formatBalance(balance))")
203 if let claudePercent = claudePercent {
204 parts.append("\(claudePercent)%")
207 return isLoading ? "⚡…" : "⚡?"
209 return parts.joined(separator: " · ")
212 /// The text to show in the menu bar for the current state.
213 var statusBarItemText: String {
216 isLoading: isLoading,
217 claudePercent: claudeStatusBarPercent
221 /// The formatted balance string for display in the popover.
222 var formattedBalance: String {
223 guard let balance = balance else { return "?" }
224 return Self.formatBalance(balance)
227 /// The color for the balance display based on thresholds.
228 var balanceColor: Color {
229 guard let balance = balance else { return .secondary }
230 if balance >= 100 { return .green }
231 if balance >= 10 { return .yellow }
235 /// Relative time string for the last successful update, or nil if never updated.
236 var lastUpdatedText: String? {
237 guard let lastUpdated = lastUpdated else { return nil }
238 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
243 /// The last 24 hours of successful fetches, oldest first.
244 var balanceHistory: [(date: Date, balance: Int)] {
245 history.map { (date: $0.date, balance: $0.balance) }
248 /// The direction of travel across the most recent handful of observations.
249 /// `.unknown` until there are at least two points to compare.
250 var balanceTrend: BalanceTrend {
251 guard history.count >= 2 else { return .unknown }
252 let recent = history.suffix(5)
253 guard let first = recent.first, let last = recent.last else { return .unknown }
254 if last.balance > first.balance { return .increasing }
255 if last.balance < first.balance { return .decreasing }
259 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
260 private func recordHistory(balance: Int, at date: Date) {
261 history.append(BalancePoint(date: date, balance: balance))
263 let cutoff = date.addingTimeInterval(-Self.historyWindow)
264 history.removeAll { $0.date < cutoff }
266 if history.count > Self.maxHistoryEntries {
267 history.removeFirst(history.count - Self.maxHistoryEntries)
273 /// Refreshes the Hyper balance and the Claude usage, both at once.
275 /// The two are independent: either can fail, or not be configured at all, without
276 /// touching the other. Cancels any previously in-flight refresh to prevent races.
278 refreshTask?.cancel()
280 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
281 let key = activeAPIKey
283 // Nothing to fetch, and nothing worth keeping: the balance, its history and
284 // any error all belonged to a key that is no longer there. Claude is left
285 // alone — it doesn't depend on the Hyper key.
292 refreshTask = Task { @MainActor in
293 // Check cancellation before setting isLoading — a previous
294 // refresh() may have cancelled us and already set isLoading = false.
295 guard !Task.isCancelled else { return }
296 // Loading state covers both fetches: the popover's loading view depends
297 // on it, and Claude usage is fetched even when there's no Hyper key.
299 // Don't clear the errors here — keep showing the last one
300 // until we have a successful result.
302 // Both requests go out at once; neither waits on the other.
303 async let balanceOutcome = fetchBalance(key: key)
304 async let claudeOutcome = fetchClaudeUsage()
306 let balanceResult = await balanceOutcome
307 let claudeResult = await claudeOutcome
308 guard !Task.isCancelled else { return }
310 applyBalance(balanceResult)
311 applyClaude(claudeResult)
316 /// The balance fetch, or `nil` when there is no key to fetch with.
317 private func fetchBalance(key: String) async -> Result<Int, Error>? {
318 guard !key.isEmpty else { return nil }
320 return .success(try await checker.fetchBalance(apiKey: key))
322 return .failure(error)
326 private func applyBalance(_ result: Result<Int, Error>?) {
327 guard let result else { return }
330 case .success(let newBalance):
331 // Capture the previous balance before overwriting it: the low-balance
332 // notification fires on the crossing, not on the value.
333 let previousBalance = balance
338 recordHistory(balance: newBalance, at: now)
340 // Low-balance notification: on threshold crossing (≥10 → <10)
341 // OR on first successful fetch if already below threshold.
342 if let previous = previousBalance, previous >= 10, newBalance < 10 {
343 sendLowBalanceNotification(balance: newBalance)
344 } else if previousBalance == nil && newBalance < 10 {
345 sendLowBalanceNotification(balance: newBalance)
348 case .failure(let error):
349 // Keep the stale balance value — don't wipe it on error.
350 // Only set balance = nil if there was never a successful fetch
351 // (it's already nil in that case, so nothing to do).
352 errorMessage = error.localizedDescription
356 // MARK: - Claude Usage
358 /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
359 /// plenty of people don't use Claude Code, and an error telling them so would be
360 /// noise rather than news.
361 private enum ClaudeOutcome {
363 case usage(ClaudeUsageReport)
367 private func fetchClaudeUsage() async -> ClaudeOutcome {
369 return .usage(try await claudeChecker.fetchUsage())
370 } catch ClaudeUsageError.noCredentials {
371 return .notConfigured
373 return .failure(error.localizedDescription)
377 private func applyClaude(_ outcome: ClaudeOutcome) {
384 case .usage(let report):
385 claudeUsage = report.usage
386 claudePlan = report.subscriptionType
389 case .failure(let message):
390 // As with the balance: keep the last good numbers on screen and say what
391 // went wrong, rather than blanking the section on a transient failure.
392 claudeError = message
396 /// The number Claude gets judged by: whichever limit the server itself flags as
397 /// binding, falling back to the 5-hour window when it flags none. `nil` when there
398 /// is no usage to summarize.
400 /// Shared by the popover and the menu bar title so the two can never disagree about
401 /// which of the windows is the one that matters.
402 static func claudeHeadline(for usage: ClaudeUsage?) -> (percent: Int, resetsIn: String?)? {
403 guard let usage = usage else { return nil }
404 if let active = usage.activeLimit {
405 return (active.percent, active.resetsInFormatted)
407 if let fiveHour = usage.fiveHour {
408 return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
413 /// The Claude percentage for the menu bar title, or `nil` when Claude Code isn't
414 /// signed in on this machine — in which case the title is Hyper's alone.
415 var claudeStatusBarPercent: Int? {
416 Self.claudeHeadline(for: claudeUsage)?.percent
419 /// Display label for the Claude plan, e.g. "Pro" or "Max".
420 var claudePlanLabel: String? {
421 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
422 !plan.isEmpty else { return nil }
424 switch plan.lowercased() {
425 case "free": return "Free"
426 case "pro": return "Pro"
427 case "max": return "Max"
428 case "team": return "Team"
429 case "enterprise": return "Enterprise"
430 default: return plan.capitalized
436 /// Copies the current balance to the clipboard as plain digits (no grouping
437 /// separator), and shows a brief "✓ Copied" confirmation.
438 func copyBalanceToClipboard() {
439 guard let balance = balance else { return }
441 let pasteboard = NSPasteboard.general
442 pasteboard.clearContents()
443 pasteboard.setString(String(balance), forType: .string)
445 copiedConfirmation = true
446 copiedResetTask?.cancel()
447 copiedResetTask = Task { @MainActor in
448 try? await Task.sleep(nanoseconds: 1_500_000_000)
449 guard !Task.isCancelled else { return }
450 copiedConfirmation = false
454 // MARK: - Low Balance Notification
456 private func sendLowBalanceNotification(balance: Int) {
457 // Build the body here, on the main actor. `getNotificationSettings` calls back on
458 // a background queue, and the shared `balanceFormatter` is main-actor state — the
459 // status bar title formats against the very same instance.
460 let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
462 let center = UNUserNotificationCenter.current()
463 center.getNotificationSettings { settings in
464 guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
467 let content = UNMutableNotificationContent()
468 content.title = "Low Usage Menubar"
470 content.sound = .default
472 // A fixed identifier makes a later alert replace the delivered one in place,
473 // which updates the text without ever notifying the user again.
474 let request = UNNotificationRequest(
475 identifier: "low-balance-alert-\(UUID().uuidString)",
485 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
486 /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
488 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
490 let deleted = KeychainHelper.delete()
492 errorMessage = "Could not delete API key from Keychain. It may be locked."
497 // refresh() with an empty key clears balance, error, history, and
498 // cancels any in-flight task from the previous key.
501 let saved = KeychainHelper.save(key)
503 errorMessage = "Could not save API key to Keychain. It may be locked."
506 // A different key is a different account, so everything derived from the old
507 // one has to go — not just the history behind the sparkline and trend. A
508 // carried-over balance would be displayed as the new account's until the
509 // fetch lands, and it would stand in as `previousBalance` for the low-balance
510 // threshold check, which would then compare two unrelated accounts.
511 if key != activeAPIKey {
517 // Show the trimmed key that was actually stored.
523 // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
524 // Cancel any previous reset so rapid Save clicks don't hide the
525 // confirmation prematurely.
526 savedConfirmation = true
527 savedResetTask?.cancel()
528 savedResetTask = Task { @MainActor in
529 try? await Task.sleep(nanoseconds: 2_000_000_000)
530 guard !Task.isCancelled else { return }
531 savedConfirmation = false
535 // MARK: - Launch at Login
537 func updateLaunchAtLogin(_ enabled: Bool) {
540 try SMAppService.mainApp.register()
542 try SMAppService.mainApp.unregister()
545 errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
546 + error.localizedDescription
547 // Snap the switch back to what the system actually did, suppressing the
548 // didSet so this doesn't recurse into another register/unregister attempt.
549 isInitializingLaunchAtLogin = true
550 launchAtLogin = SMAppService.mainApp.status == .enabled
551 isInitializingLaunchAtLogin = false
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