@@ -1,899 +0,0 @@
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−}