@@ -0,0 +1,899 @@
1+import Foundation
2+import SwiftUI
3+import Combine
4+import ServiceManagement
5+import UserNotifications
6+
7+/// Posted when the user changes the refresh interval, so the app delegate can
8+/// restart its timer with the new period.
9+extension Notification.Name {
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
11+}
12+
13+/// The direction a metric has moved over the most recent history points.
14+enum MetricTrend {
15+ case increasing
16+ case decreasing
17+ case stable
18+ /// Not enough history to say.
19+ case unknown
20+
21+ /// SF Symbol for the trend, or `nil` when there is nothing worth showing.
22+ var symbolName: String? {
23+ switch self {
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
28+ }
29+ }
30+}
31+
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
34+struct MetricPoint: Equatable {
35+ let date: Date
36+ let value: Int
37+}
38+
39+/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
40+/// the app delegate can read the options without hopping onto the main actor.
41+enum RefreshInterval {
42+ /// `UserDefaults` key for the refresh period, in minutes.
43+ static let defaultsKey = "refreshIntervalMinutes"
44+
45+ /// The intervals offered in the settings picker.
46+ static let options = [1, 5, 15, 30]
47+
48+ /// Used when nothing valid is stored.
49+ static let defaultMinutes = 5
50+
51+ /// Reads the stored interval, falling back to the default when unset or when
52+ /// the stored value isn't one we offer.
53+ static func stored(in defaults: UserDefaults = .standard) -> Int {
54+ let minutes = defaults.integer(forKey: defaultsKey)
55+ guard options.contains(minutes) else { return defaultMinutes }
56+ return minutes
57+ }
58+}
59+
60+/// The view model that drives the menu bar display and the popover content.
61+@MainActor
62+final class ViewModel: ObservableObject {
63+ /// The last known balance. `nil` when there has never been a successful fetch.
64+ @Published var balance: Int?
65+
66+ /// `true` while a balance request is in flight.
67+ @Published var isLoading = false
68+
69+ /// A human-readable error message, shown when the balance can't be fetched.
70+ @Published var errorMessage: String?
71+
72+ /// Claude Code's subscription usage. `nil` when Claude Code has never signed in on
73+ /// this machine, in which case the popover leaves the section out entirely rather
74+ /// than nagging someone who doesn't use it.
75+ @Published var claudeUsage: ClaudeUsage?
76+
77+ /// A human-readable error message from the Claude fetch. Independent of
78+ /// `errorMessage`: the two services fail separately.
79+ @Published var claudeError: String?
80+
81+ /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
82+ @Published var claudePlan: String?
83+
84+ /// z.ai Coding Plan usage. `nil` when no z.ai API key has been entered, in which
85+ /// case the popover leaves the section out entirely.
86+ @Published var zaiUsage: ZaiUsageReport?
87+
88+ /// A human-readable error message from the z.ai fetch. Independent of
89+ /// `errorMessage` and `claudeError`: the three services fail separately.
90+ @Published var zaiError: String?
91+
92+ /// The z.ai API key draft bound to the text field. Two-way bound to the
93+ /// `SecureField`, so it changes on every keystroke — never fetch with it, use
94+ /// `activeZaiAPIKey`.
95+ @Published var zaiAPIKeyInput: String = ""
96+
97+ /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
98+ /// Drives whether the `⚡` segment appears in the menu bar at all: an
99+ /// unconfigured service should not take up space in the title.
100+ @Published private(set) var hyperConfigured = false
101+
102+ /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
103+ /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
104+ @Published var apiKeyInput: String = ""
105+
106+ /// Timestamp of the last successful balance fetch.
107+ @Published var lastUpdated: Date?
108+
109+ /// Brief "✓ Saved" confirmation shown after saving the API key.
110+ @Published var savedConfirmation: Bool = false
111+
112+ /// Brief "✓ Copied" confirmation shown after copying the balance.
113+ @Published var copiedConfirmation: Bool = false
114+
115+ /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116+ @Published var copiedClaudeConfirmation: Bool = false
117+
118+ /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119+ @Published var copiedZaiConfirmation: Bool = false
120+
121+ /// Successful balance observations from the last 24 hours, oldest first.
122+ @Published private(set) var history: [MetricPoint] = []
123+
124+ /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125+ @Published private(set) var claudeHistory: [MetricPoint] = []
126+
127+ /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128+ @Published private(set) var zaiHistory: [MetricPoint] = []
129+
130+ /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131+ /// changing it posts `.refreshIntervalDidChange` so the app delegate can
132+ /// restart its timer.
133+ @Published var refreshIntervalMinutes: Int {
134+ didSet {
135+ guard refreshIntervalMinutes != oldValue else { return }
136+ defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
137+ NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
138+ }
139+ }
140+
141+ /// Whether launch-at-login is enabled.
142+ @Published var launchAtLogin: Bool {
143+ didSet {
144+ guard !isInitializingLaunchAtLogin else { return }
145+ updateLaunchAtLogin(launchAtLogin)
146+ }
147+ }
148+
149+ /// Flag to suppress the didSet during initial setup.
150+ private var isInitializingLaunchAtLogin = true
151+
152+ /// The key requests actually go out with: what is in the Keychain, not what is
153+ /// currently in the text field. Keeping these separate stops a timer or wake
154+ /// refresh from firing with a half-typed key — or, if the field has been cleared
155+ /// to paste a new one, from taking the empty-key path and wiping the balance,
156+ /// last-updated time and history out from under the user.
157+ private var activeAPIKey: String
158+
159+ /// The z.ai key requests actually go out with: what is in the Keychain, not what
160+ /// is currently in the text field. Same rationale as `activeAPIKey`.
161+ private var activeZaiAPIKey: String
162+
163+ private let checker: CreditsChecking
164+ private let claudeChecker: ClaudeUsageChecking
165+ private let zaiChecker: ZaiUsageChecking
166+ private let defaults: UserDefaults
167+
168+ /// The currently in-flight refresh task, if any. Used to cancel
169+ /// overlapping refreshes (timer + user click + wake).
170+ private var refreshTask: Task<Void, Never>?
171+
172+ /// Resets `copiedConfirmation`; held so rapid clicks re-arm the delay
173+ /// instead of the first click's reset firing early.
174+ private var copiedResetTask: Task<Void, Never>?
175+
176+ /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177+ private var savedResetTask: Task<Void, Never>?
178+
179+ /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180+ private var copiedClaudeResetTask: Task<Void, Never>?
181+
182+ /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183+ private var copiedZaiResetTask: Task<Void, Never>?
184+
185+ // MARK: - History Configuration
186+
187+ /// 24 hours at the shortest (1 minute) interval would overflow this, so the
188+ /// count cap and the age cutoff both apply; whichever bites first wins.
189+ private static let maxHistoryEntries = 300
190+ private static let historyWindow: TimeInterval = 24 * 60 * 60
191+
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
195+ init(
196+ checker: CreditsChecking = CreditsChecker(),
197+ claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198+ zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199+ defaults: UserDefaults = .standard
200+ ) {
201+ self.checker = checker
202+ self.claudeChecker = claudeChecker
203+ self.zaiChecker = zaiChecker
204+ self.defaults = defaults
205+ let storedKey = KeychainHelper.load() ?? ""
206+ apiKeyInput = storedKey
207+ activeAPIKey = storedKey
208+ hyperConfigured = !storedKey.isEmpty
209+ let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210+ zaiAPIKeyInput = storedZaiKey
211+ activeZaiAPIKey = storedZaiKey
212+ // Assigning in init doesn't fire didSet, so this load doesn't re-post
213+ // the change notification or write back to defaults.
214+ refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
215+ launchAtLogin = SMAppService.mainApp.status == .enabled
216+ isInitializingLaunchAtLogin = false
217+ }
218+
219+ // MARK: - Balance
220+
221+ /// Cached number formatter for balance display.
222+ private static let balanceFormatter: NumberFormatter = {
223+ let formatter = NumberFormatter()
224+ formatter.numberStyle = .decimal
225+ return formatter
226+ }()
227+
228+ /// Cached relative time formatter for "Updated Xm ago" text.
229+ private static let relativeFormatter: RelativeDateTimeFormatter = {
230+ let formatter = RelativeDateTimeFormatter()
231+ formatter.unitsStyle = .short
232+ return formatter
233+ }()
234+
235+ /// Formats an integer with thousands grouping separator.
236+ private static func formatBalance(_ value: Int) -> String {
237+ balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238+ }
239+
240+ /// The text to show in the menu bar. Each configured provider contributes a
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
242+ /// (middle dot). Within a segment, windows are listed as bare percentages
243+ /// separated by spaces. An unconfigured service produces no segment.
244+ /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245+ ///
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247+ ///
248+ /// Takes its values as parameters rather than reading the properties: `@Published`
249+ /// publishes in `willSet`, so a Combine subscriber that called back into the view
250+ /// model would see the *pre-change* value and render a title one update behind.
251+ static func statusBarText(
252+ balance: Int?,
253+ isLoading: Bool,
254+ hyperConfigured: Bool,
255+ claudeFiveHourPercent: Int?,
256+ claudeSevenDayPercent: Int?,
257+ zaiFiveHourPercent: Int?,
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
260+ ) -> String {
261+ var segments: [String] = []
262+
263+ // Hyper
264+ if hyperConfigured, let balance = balance {
265+ segments.append("⚡\(formatBalance(balance))")
266+ }
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
272+ }
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
275+ }
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
278+ }
279+
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
284+ }
285+ if let zaiWeekly = zaiWeeklyPercent {
286+ zaiWindows.append("\(zaiWeekly)%")
287+ }
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
294+ }
295+
296+ if segments.isEmpty {
297+ // No service is configured or has anything to show. While loading,
298+ // show an ellipsis; otherwise the title is empty.
299+ return isLoading ? "…" : ""
300+ }
301+ return segments.joined(separator: " · ")
302+ }
303+
304+ /// The text to show in the menu bar for the current state.
305+ var statusBarItemText: String {
306+ Self.statusBarText(
307+ balance: balance,
308+ isLoading: isLoading,
309+ hyperConfigured: hyperConfigured,
310+ claudeFiveHourPercent: claudeFiveHourPercent,
311+ claudeSevenDayPercent: claudeSevenDayPercent,
312+ zaiFiveHourPercent: zaiFiveHourPercent,
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
315+ )
316+ }
317+
318+ /// Whether z.ai is currently in peak hours (06:00–10:00 UTC).
319+ static var zaiInPeakHours: Bool {
320+ let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321+ return hour >= 6 && hour < 10
322+ }
323+
324+ /// The formatted balance string for display in the popover.
325+ var formattedBalance: String {
326+ guard let balance = balance else { return "?" }
327+ return Self.formatBalance(balance)
328+ }
329+
330+ /// The color for the balance display based on thresholds.
331+ var balanceColor: Color {
332+ guard let balance = balance else { return .secondary }
333+ if balance >= 100 { return .green }
334+ if balance >= 10 { return .yellow }
335+ return .red
336+ }
337+
338+ /// Relative time string for the last successful update, or nil if never updated.
339+ var lastUpdatedText: String? {
340+ guard let lastUpdated = lastUpdated else { return nil }
341+ return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342+ }
343+
344+ // MARK: - History (Hyper)
345+
346+ /// The last 24 hours of successful balance fetches, oldest first.
347+ var balanceHistory: [(date: Date, balance: Int)] {
348+ history.map { (date: $0.date, balance: $0.value) }
349+ }
350+
351+ /// The direction of travel across the most recent handful of observations.
352+ /// `.unknown` until there are at least two points to compare.
353+ var balanceTrend: MetricTrend {
354+ metricTrend(history)
355+ }
356+
357+ /// The sparkline's data for the Hyper balance.
358+ var balanceSparklineValues: [Int] {
359+ history.map { $0.value }
360+ }
361+
362+ // MARK: - History (Claude)
363+
364+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365+ var claudeSparklineValues: [Int] {
366+ claudeHistory.map { $0.value }
367+ }
368+
369+ /// The direction of travel for the Claude 5-hour %.
370+ var claudeTrend: MetricTrend {
371+ metricTrend(claudeHistory)
372+ }
373+
374+ // MARK: - History (z.ai)
375+
376+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377+ var zaiSparklineValues: [Int] {
378+ zaiHistory.map { $0.value }
379+ }
380+
381+ /// The direction of travel for the z.ai 5-hour %.
382+ var zaiTrend: MetricTrend {
383+ metricTrend(zaiHistory)
384+ }
385+
386+ // MARK: - History Helpers
387+
388+ /// Computes the trend from a generic history array.
389+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390+ guard points.count >= 2 else { return .unknown }
391+ let recent = points.suffix(5)
392+ guard let first = recent.first, let last = recent.last else { return .unknown }
393+ if last.value > first.value { return .increasing }
394+ if last.value < first.value { return .decreasing }
395+ return .stable
396+ }
397+
398+ /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400+ array.append(MetricPoint(date: date, value: value))
401+
402+ let cutoff = date.addingTimeInterval(-Self.historyWindow)
403+ array.removeAll { $0.date < cutoff }
404+
405+ if array.count > Self.maxHistoryEntries {
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
407+ }
408+ }
409+
410+ // MARK: - Refresh
411+
412+ /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
413+ ///
414+ /// The three are independent: any can fail, or not be configured at all, without
415+ /// touching the others. Cancels any previously in-flight refresh to prevent races.
416+ func refresh() {
417+ refreshTask?.cancel()
418+
419+ // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
420+ let key = activeAPIKey
421+ let zaiKey = activeZaiAPIKey
422+ if key.isEmpty {
423+ // Nothing to fetch, and nothing worth keeping: the balance, its history and
424+ // any error all belonged to a key that is no longer there. Claude and z.ai
425+ // are left alone — they don't depend on the Hyper key.
426+ balance = nil
427+ errorMessage = nil
428+ lastUpdated = nil
429+ history.removeAll()
430+ }
431+
432+ refreshTask = Task { @MainActor in
433+ // Check cancellation before setting isLoading — a previous
434+ // refresh() may have cancelled us and already set isLoading = false.
435+ guard !Task.isCancelled else { return }
436+ // Loading state covers all fetches: the popover's loading view depends
437+ // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
438+ isLoading = true
439+ // Don't clear the errors here — keep showing the last one
440+ // until we have a successful result.
441+
442+ // All three requests go out at once; none waits on the others.
443+ async let balanceOutcome = fetchBalance(key: key)
444+ async let claudeOutcome = fetchClaudeUsage()
445+ async let zaiOutcome = fetchZaiUsage(key: zaiKey)
446+
447+ let balanceResult = await balanceOutcome
448+ let claudeResult = await claudeOutcome
449+ let zaiResult = await zaiOutcome
450+ guard !Task.isCancelled else { return }
451+
452+ applyBalance(balanceResult)
453+ applyClaude(claudeResult)
454+ applyZai(zaiResult)
455+ isLoading = false
456+ }
457+ }
458+
459+ /// The balance fetch, or `nil` when there is no key to fetch with.
460+ private func fetchBalance(key: String) async -> Result<Int, Error>? {
461+ guard !key.isEmpty else { return nil }
462+ do {
463+ return .success(try await checker.fetchBalance(apiKey: key))
464+ } catch {
465+ return .failure(error)
466+ }
467+ }
468+
469+ private func applyBalance(_ result: Result<Int, Error>?) {
470+ guard let result else { return }
471+
472+ switch result {
473+ case .success(let newBalance):
474+ // Capture the previous balance before overwriting it: the low-balance
475+ // notification fires on the crossing, not on the value.
476+ let previousBalance = balance
477+ balance = newBalance
478+ errorMessage = nil
479+ let now = Date()
480+ lastUpdated = now
481+ recordMetric(newBalance, into: &history, at: now)
482+
483+ // Low-balance notification: on threshold crossing (≥10 → <10)
484+ // OR on first successful fetch if already below threshold.
485+ if let previous = previousBalance, previous >= 10, newBalance < 10 {
486+ sendLowBalanceNotification(balance: newBalance)
487+ } else if previousBalance == nil && newBalance < 10 {
488+ sendLowBalanceNotification(balance: newBalance)
489+ }
490+
491+ case .failure(let error):
492+ // Keep the stale balance value — don't wipe it on error.
493+ // Only set balance = nil if there was never a successful fetch
494+ // (it's already nil in that case, so nothing to do).
495+ errorMessage = error.localizedDescription
496+ }
497+ }
498+
499+ // MARK: - Claude Usage
500+
501+ /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
502+ /// plenty of people don't use Claude Code, and an error telling them so would be
503+ /// noise rather than news.
504+ private enum ClaudeOutcome {
505+ case notConfigured
506+ case usage(ClaudeUsageReport)
507+ case failure(String)
508+ }
509+
510+ private func fetchClaudeUsage() async -> ClaudeOutcome {
511+ do {
512+ return .usage(try await claudeChecker.fetchUsage())
513+ } catch ClaudeUsageError.noCredentials {
514+ return .notConfigured
515+ } catch {
516+ return .failure(error.localizedDescription)
517+ }
518+ }
519+
520+ private func applyClaude(_ outcome: ClaudeOutcome) {
521+ switch outcome {
522+ case .notConfigured:
523+ claudeUsage = nil
524+ claudePlan = nil
525+ claudeError = nil
526+ claudeHistory.removeAll()
527+
528+ case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
531+ claudeUsage = report.usage
532+ claudePlan = report.subscriptionType
533+ claudeError = nil
534+
535+ // Record history and fire high-usage notification based on the 5-hour %.
536+ if let percent = claudeFiveHourPercent {
537+ let now = Date()
538+ recordMetric(percent, into: &claudeHistory, at: now)
539+
540+ // High-usage notification: on threshold crossing (<90 → ≥90)
541+ // OR on first successful fetch if already above threshold.
542+ if let previous = previousPercent, previous < Self.highUsageThreshold,
543+ percent >= Self.highUsageThreshold {
544+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
545+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
546+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
547+ }
548+ }
549+
550+ case .failure(let message):
551+ // As with the balance: keep the last good numbers on screen and say what
552+ // went wrong, rather than blanking the section on a transient failure.
553+ claudeError = message
554+ }
555+ }
556+
557+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
558+ var claudeFiveHourPercent: Int? {
559+ guard let fiveHour = claudeUsage?.fiveHour else { return nil }
560+ return Int(fiveHour.utilization.rounded())
561+ }
562+
563+ /// The 7-day window percentage for the menu bar title, or `nil` when absent.
564+ /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
565+ /// has been used — on Max plans the Opus cap is the one that binds first.
566+ var claudeSevenDayPercent: Int? {
567+ if let sevenDay = claudeUsage?.sevenDay {
568+ return Int(sevenDay.utilization.rounded())
569+ }
570+ if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571+ return Int(opus.utilization.rounded())
572+ }
573+ return nil
574+ }
575+
576+ /// Display label for the Claude plan, e.g. "Pro" or "Max".
577+ var claudePlanLabel: String? {
578+ guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
579+ !plan.isEmpty else { return nil }
580+
581+ switch plan.lowercased() {
582+ case "free": return "Free"
583+ case "pro": return "Pro"
584+ case "max": return "Max"
585+ case "team": return "Team"
586+ case "enterprise": return "Enterprise"
587+ default: return plan.capitalized
588+ }
589+ }
590+
591+ // MARK: - z.ai Usage
592+
593+ /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
594+ /// plenty of people don't use z.ai, and an error telling them so would be noise.
595+ private enum ZaiOutcome {
596+ case notConfigured
597+ case usage(ZaiUsageReport)
598+ case failure(String)
599+ }
600+
601+ private func fetchZaiUsage(key: String) async -> ZaiOutcome {
602+ guard !key.isEmpty else { return .notConfigured }
603+ do {
604+ return .usage(try await zaiChecker.fetchUsage(apiKey: key))
605+ } catch ZaiUsageError.noAPIKey {
606+ return .notConfigured
607+ } catch {
608+ return .failure(error.localizedDescription)
609+ }
610+ }
611+
612+ private func applyZai(_ outcome: ZaiOutcome) {
613+ switch outcome {
614+ case .notConfigured:
615+ zaiUsage = nil
616+ zaiError = nil
617+ zaiHistory.removeAll()
618+
619+ case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
622+ zaiUsage = report
623+ zaiError = nil
624+
625+ // Record history and fire high-usage notification based on the 5-hour %.
626+ if let percent = zaiFiveHourPercent {
627+ let now = Date()
628+ recordMetric(percent, into: &zaiHistory, at: now)
629+
630+ // High-usage notification: on threshold crossing (<90 → ≥90)
631+ // OR on first successful fetch if already above threshold.
632+ if let previous = previousPercent, previous < Self.highUsageThreshold,
633+ percent >= Self.highUsageThreshold {
634+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637+ }
638+ }
639+
640+ case .failure(let message):
641+ // As with the balance and Claude: keep the last good numbers on screen
642+ // and say what went wrong, rather than blanking the section on a
643+ // transient failure.
644+ zaiError = message
645+ }
646+ }
647+
648+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
649+ var zaiFiveHourPercent: Int? {
650+ zaiUsage?.fiveHourPercent
651+ }
652+
653+ /// The weekly window percentage for the popover, or `nil` when absent.
654+ var zaiWeeklyPercent: Int? {
655+ zaiUsage?.weeklyPercent
656+ }
657+
658+ /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
659+ /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
660+ var zaiPlanLabel: String? {
661+ guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
662+ !level.isEmpty else { return nil }
663+
664+ // The subscription endpoint returns a product name like "GLM Coding Max";
665+ // extract the tier from it. The quota endpoint returns just the tier.
666+ let lowered = level.lowercased()
667+ if lowered.contains("max") { return "Max" }
668+ if lowered.contains("pro") { return "Pro" }
669+ if lowered.contains("lite") { return "Lite" }
670+ return level.capitalized
671+ }
672+
673+ // MARK: - Clipboard
674+
675+ /// Copies the current balance to the clipboard as plain digits (no grouping
676+ /// separator), and shows a brief "✓ Copied" confirmation.
677+ func copyBalanceToClipboard() {
678+ guard let balance = balance else { return }
679+
680+ let pasteboard = NSPasteboard.general
681+ pasteboard.clearContents()
682+ pasteboard.setString(String(balance), forType: .string)
683+
684+ copiedConfirmation = true
685+ copiedResetTask?.cancel()
686+ copiedResetTask = Task { @MainActor in
687+ try? await Task.sleep(nanoseconds: 1_500_000_000)
688+ guard !Task.isCancelled else { return }
689+ copiedConfirmation = false
690+ }
691+ }
692+
693+ /// Copies the current Claude 5-hour percentage to the clipboard.
694+ func copyClaudePercentToClipboard() {
695+ guard let percent = claudeFiveHourPercent else { return }
696+
697+ let pasteboard = NSPasteboard.general
698+ pasteboard.clearContents()
699+ pasteboard.setString("\(percent)%", forType: .string)
700+
701+ copiedClaudeConfirmation = true
702+ copiedClaudeResetTask?.cancel()
703+ copiedClaudeResetTask = Task { @MainActor in
704+ try? await Task.sleep(nanoseconds: 1_500_000_000)
705+ guard !Task.isCancelled else { return }
706+ copiedClaudeConfirmation = false
707+ }
708+ }
709+
710+ /// Copies the current z.ai 5-hour percentage to the clipboard.
711+ func copyZaiPercentToClipboard() {
712+ guard let percent = zaiFiveHourPercent else { return }
713+
714+ let pasteboard = NSPasteboard.general
715+ pasteboard.clearContents()
716+ pasteboard.setString("\(percent)%", forType: .string)
717+
718+ copiedZaiConfirmation = true
719+ copiedZaiResetTask?.cancel()
720+ copiedZaiResetTask = Task { @MainActor in
721+ try? await Task.sleep(nanoseconds: 1_500_000_000)
722+ guard !Task.isCancelled else { return }
723+ copiedZaiConfirmation = false
724+ }
725+ }
726+
727+ // MARK: - Notifications
728+
729+ private func sendLowBalanceNotification(balance: Int) {
730+ // Build the body here, on the main actor. `getNotificationSettings` calls back on
731+ // a background queue, and the shared `balanceFormatter` is main-actor state — the
732+ // status bar title formats against the very same instance.
733+ let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
734+
735+ let center = UNUserNotificationCenter.current()
736+ center.getNotificationSettings { settings in
737+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
738+ return
739+ }
740+ let content = UNMutableNotificationContent()
741+ content.title = "Low Usage Menubar"
742+ content.body = body
743+ content.sound = .default
744+
745+ // A fixed identifier makes a later alert replace the delivered one in place,
746+ // which updates the text without ever notifying the user again.
747+ let request = UNNotificationRequest(
748+ identifier: "low-balance-alert-\(UUID().uuidString)",
749+ content: content,
750+ trigger: nil
751+ )
752+ center.add(request)
753+ }
754+ }
755+
756+ /// Sends a notification when a usage percentage crosses the high-usage threshold.
757+ private func sendHighUsageNotification(provider: String, percent: Int) {
758+ let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
759+
760+ let center = UNUserNotificationCenter.current()
761+ center.getNotificationSettings { settings in
762+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
763+ return
764+ }
765+ let content = UNMutableNotificationContent()
766+ content.title = "High Usage Menubar"
767+ content.body = body
768+ content.sound = .default
769+
770+ let request = UNNotificationRequest(
771+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
772+ content: content,
773+ trigger: nil
774+ )
775+ center.add(request)
776+ }
777+ }
778+
779+ // MARK: - API Key
780+
781+ /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
782+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
783+ func saveAPIKey() {
784+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
785+ if key.isEmpty {
786+ let deleted = KeychainHelper.delete()
787+ guard deleted else {
788+ errorMessage = "Could not delete API key from Keychain. It may be locked."
789+ return
790+ }
791+ apiKeyInput = ""
792+ activeAPIKey = ""
793+ hyperConfigured = false
794+ // refresh() with an empty key clears balance, error, history, and
795+ // cancels any in-flight task from the previous key.
796+ refresh()
797+ } else {
798+ let saved = KeychainHelper.save(key)
799+ guard saved else {
800+ errorMessage = "Could not save API key to Keychain. It may be locked."
801+ return
802+ }
803+ // A different key is a different account, so everything derived from the old
804+ // one has to go — not just the history behind the sparkline and trend. A
805+ // carried-over balance would be displayed as the new account's until the
806+ // fetch lands, and it would stand in as `previousBalance` for the low-balance
807+ // threshold check, which would then compare two unrelated accounts.
808+ if key != activeAPIKey {
809+ balance = nil
810+ lastUpdated = nil
811+ errorMessage = nil
812+ history.removeAll()
813+ }
814+ // Show the trimmed key that was actually stored.
815+ apiKeyInput = key
816+ activeAPIKey = key
817+ hyperConfigured = true
818+ refresh()
819+ }
820+
821+ // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
822+ // Cancel any previous reset so rapid Save clicks don't hide the
823+ // confirmation prematurely.
824+ savedConfirmation = true
825+ savedResetTask?.cancel()
826+ savedResetTask = Task { @MainActor in
827+ try? await Task.sleep(nanoseconds: 2_000_000_000)
828+ guard !Task.isCancelled else { return }
829+ savedConfirmation = false
830+ }
831+ }
832+
833+ // MARK: - z.ai API Key
834+
835+ /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
836+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
837+ func saveZaiAPIKey() {
838+ let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
839+ if key.isEmpty {
840+ let deleted = ZaiKeychainHelper.delete()
841+ guard deleted else {
842+ zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
843+ return
844+ }
845+ zaiAPIKeyInput = ""
846+ activeZaiAPIKey = ""
847+ // refresh() with an empty key clears zai usage and error, and
848+ // cancels any in-flight task from the previous key.
849+ refresh()
850+ } else {
851+ let saved = ZaiKeychainHelper.save(key)
852+ guard saved else {
853+ zaiError = "Could not save z.ai API key to Keychain. It may be locked."
854+ return
855+ }
856+ // A different key is a different account, so everything derived from the
857+ // old one has to go.
858+ if key != activeZaiAPIKey {
859+ zaiUsage = nil
860+ zaiError = nil
861+ zaiHistory.removeAll()
862+ }
863+ // Show the trimmed key that was actually stored.
864+ zaiAPIKeyInput = key
865+ activeZaiAPIKey = key
866+ refresh()
867+ }
868+
869+ // Reuse the same "✓ Saved" confirmation — the user sees one per save action,
870+ // regardless of which key it was for.
871+ savedConfirmation = true
872+ savedResetTask?.cancel()
873+ savedResetTask = Task { @MainActor in
874+ try? await Task.sleep(nanoseconds: 2_000_000_000)
875+ guard !Task.isCancelled else { return }
876+ savedConfirmation = false
877+ }
878+ }
879+
880+ // MARK: - Launch at Login
881+
882+ func updateLaunchAtLogin(_ enabled: Bool) {
883+ do {
884+ if enabled {
885+ try SMAppService.mainApp.register()
886+ } else {
887+ try SMAppService.mainApp.unregister()
888+ }
889+ } catch {
890+ errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
891+ + error.localizedDescription
892+ // Snap the switch back to what the system actually did, suppressing the
893+ // didSet so this doesn't recurse into another register/unregister attempt.
894+ isInitializingLaunchAtLogin = true
895+ launchAtLogin = SMAppService.mainApp.status == .enabled
896+ isInitializingLaunchAtLogin = false
897+ }
898+ }
899+}