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 a metric 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 metric observation — used for Hyper balance, Claude 5-hour %,
33/// and z.ai 5-hour % alike.
34struct MetricPoint: Equatable {
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.
42 /// `UserDefaults` key for the refresh period, in minutes.
43 static let defaultsKey = "refreshIntervalMinutes"
45 /// The intervals offered in the settings picker.
46 static let options = [1, 5, 15, 30]
48 /// Used when nothing valid is stored.
49 static let defaultMinutes = 5
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 }
60/// The view model that drives the menu bar display and the popover content.
62final class ViewModel: ObservableObject {
63 /// The last known balance. `nil` when there has never been a successful fetch.
64 @Published var balance: Int?
66 /// `true` while a balance request is in flight.
67 @Published var isLoading = false
69 /// A human-readable error message, shown when the balance can't be fetched.
70 @Published var errorMessage: String?
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?
77 /// A human-readable error message from the Claude fetch. Independent of
78 /// `errorMessage`: the two services fail separately.
79 @Published var claudeError: String?
81 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
82 @Published var claudePlan: String?
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?
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?
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 = ""
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
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 = ""
106 /// Timestamp of the last successful balance fetch.
107 @Published var lastUpdated: Date?
109 /// Brief "✓ Saved" confirmation shown after saving the API key.
110 @Published var savedConfirmation: Bool = false
112 /// Brief "✓ Copied" confirmation shown after copying the balance.
113 @Published var copiedConfirmation: Bool = false
115 /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116 @Published var copiedClaudeConfirmation: Bool = false
118 /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119 @Published var copiedZaiConfirmation: Bool = false
121 /// Successful balance observations from the last 24 hours, oldest first.
122 @Published private(set) var history: [MetricPoint] = []
124 /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125 @Published private(set) var claudeHistory: [MetricPoint] = []
127 /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128 @Published private(set) var zaiHistory: [MetricPoint] = []
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 {
135 guard refreshIntervalMinutes != oldValue else { return }
136 defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
137 NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
141 /// Whether launch-at-login is enabled.
142 @Published var launchAtLogin: Bool {
144 guard !isInitializingLaunchAtLogin else { return }
145 updateLaunchAtLogin(launchAtLogin)
149 /// Flag to suppress the didSet during initial setup.
150 private var isInitializingLaunchAtLogin = true
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
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
163 private let checker: CreditsChecking
164 private let claudeChecker: ClaudeUsageChecking
165 private let zaiChecker: ZaiUsageChecking
166 private let defaults: UserDefaults
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>?
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>?
176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177 private var savedResetTask: Task<Void, Never>?
179 /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180 private var copiedClaudeResetTask: Task<Void, Never>?
182 /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183 private var copiedZaiResetTask: Task<Void, Never>?
185 // MARK: - History Configuration
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
192 /// The threshold above which a usage percentage triggers a notification.
193 private static let highUsageThreshold = 90
196 checker: CreditsChecking = CreditsChecker(),
197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198 zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199 defaults: UserDefaults = .standard
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
221 /// Cached number formatter for balance display.
222 private static let balanceFormatter: NumberFormatter = {
223 let formatter = NumberFormatter()
224 formatter.numberStyle = .decimal
228 /// Cached relative time formatter for "Updated Xm ago" text.
229 private static let relativeFormatter: RelativeDateTimeFormatter = {
230 let formatter = RelativeDateTimeFormatter()
231 formatter.unitsStyle = .short
235 /// Formats an integer with thousands grouping separator.
236 private static func formatBalance(_ value: Int) -> String {
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
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.
245 /// Example: `⚡42 · 🅲62% 8% · 🅉12% 3%`
247 /// Takes its values as parameters rather than reading the properties: `@Published`
248 /// publishes in `willSet`, so a Combine subscriber that called back into the view
249 /// model would see the *pre-change* value and render a title one update behind.
250 static func statusBarText(
253 hyperConfigured: Bool,
254 claudeFiveHourPercent: Int?,
255 claudeSevenDayPercent: Int?,
256 zaiFiveHourPercent: Int?,
257 zaiWeeklyPercent: Int?
259 var segments: [String] = []
262 if hyperConfigured, let balance = balance {
263 segments.append("⚡\(formatBalance(balance))")
266 // Claude — provider emoji + bare percentages
267 var claudeWindows: [String] = []
268 if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
269 claudeWindows.append("\(fiveHour)%")
271 if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
272 claudeWindows.append("\(sevenDay)%")
274 if !claudeWindows.isEmpty {
275 segments.append("🅲" + claudeWindows.joined(separator: " "))
278 // z.ai — provider emoji + bare percentages
279 var zaiWindows: [String] = []
280 if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
281 zaiWindows.append("\(zaiFiveHour)%")
283 if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
284 zaiWindows.append("\(zaiWeekly)%")
286 if !zaiWindows.isEmpty {
287 segments.append("🅉" + zaiWindows.joined(separator: " "))
290 if segments.isEmpty {
291 // No service is configured or has anything to show. While loading,
292 // show an ellipsis; otherwise the title is empty.
293 return isLoading ? "…" : ""
295 return segments.joined(separator: " · ")
298 /// The text to show in the menu bar for the current state.
299 var statusBarItemText: String {
302 isLoading: isLoading,
303 hyperConfigured: hyperConfigured,
304 claudeFiveHourPercent: claudeFiveHourPercent,
305 claudeSevenDayPercent: claudeSevenDayPercent,
306 zaiFiveHourPercent: zaiFiveHourPercent,
307 zaiWeeklyPercent: zaiWeeklyPercent
311 /// The formatted balance string for display in the popover.
312 var formattedBalance: String {
313 guard let balance = balance else { return "?" }
314 return Self.formatBalance(balance)
317 /// The color for the balance display based on thresholds.
318 var balanceColor: Color {
319 guard let balance = balance else { return .secondary }
320 if balance >= 100 { return .green }
321 if balance >= 10 { return .yellow }
325 /// Relative time string for the last successful update, or nil if never updated.
326 var lastUpdatedText: String? {
327 guard let lastUpdated = lastUpdated else { return nil }
328 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
331 // MARK: - History (Hyper)
333 /// The last 24 hours of successful balance fetches, oldest first.
334 var balanceHistory: [(date: Date, balance: Int)] {
335 history.map { (date: $0.date, balance: $0.value) }
338 /// The direction of travel across the most recent handful of observations.
339 /// `.unknown` until there are at least two points to compare.
340 var balanceTrend: MetricTrend {
344 /// The sparkline's data for the Hyper balance.
345 var balanceSparklineValues: [Int] {
346 history.map { $0.value }
349 // MARK: - History (Claude)
351 /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
352 var claudeSparklineValues: [Int] {
353 claudeHistory.map { $0.value }
356 /// The direction of travel for the Claude 5-hour %.
357 var claudeTrend: MetricTrend {
358 metricTrend(claudeHistory)
361 // MARK: - History (z.ai)
363 /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
364 var zaiSparklineValues: [Int] {
365 zaiHistory.map { $0.value }
368 /// The direction of travel for the z.ai 5-hour %.
369 var zaiTrend: MetricTrend {
370 metricTrend(zaiHistory)
373 // MARK: - History Helpers
375 /// Computes the trend from a generic history array.
376 private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
377 guard points.count >= 2 else { return .unknown }
378 let recent = points.suffix(5)
379 guard let first = recent.first, let last = recent.last else { return .unknown }
380 if last.value > first.value { return .increasing }
381 if last.value < first.value { return .decreasing }
385 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
386 private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
387 array.append(MetricPoint(date: date, value: value))
389 let cutoff = date.addingTimeInterval(-Self.historyWindow)
390 array.removeAll { $0.date < cutoff }
392 if array.count > Self.maxHistoryEntries {
393 array.removeFirst(array.count - Self.maxHistoryEntries)
399 /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
401 /// The three are independent: any can fail, or not be configured at all, without
402 /// touching the others. Cancels any previously in-flight refresh to prevent races.
404 refreshTask?.cancel()
406 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
407 let key = activeAPIKey
408 let zaiKey = activeZaiAPIKey
410 // Nothing to fetch, and nothing worth keeping: the balance, its history and
411 // any error all belonged to a key that is no longer there. Claude and z.ai
412 // are left alone — they don't depend on the Hyper key.
419 refreshTask = Task { @MainActor in
420 // Check cancellation before setting isLoading — a previous
421 // refresh() may have cancelled us and already set isLoading = false.
422 guard !Task.isCancelled else { return }
423 // Loading state covers all fetches: the popover's loading view depends
424 // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
426 // Don't clear the errors here — keep showing the last one
427 // until we have a successful result.
429 // All three requests go out at once; none waits on the others.
430 async let balanceOutcome = fetchBalance(key: key)
431 async let claudeOutcome = fetchClaudeUsage()
432 async let zaiOutcome = fetchZaiUsage(key: zaiKey)
434 let balanceResult = await balanceOutcome
435 let claudeResult = await claudeOutcome
436 let zaiResult = await zaiOutcome
437 guard !Task.isCancelled else { return }
439 applyBalance(balanceResult)
440 applyClaude(claudeResult)
446 /// The balance fetch, or `nil` when there is no key to fetch with.
447 private func fetchBalance(key: String) async -> Result<Int, Error>? {
448 guard !key.isEmpty else { return nil }
450 return .success(try await checker.fetchBalance(apiKey: key))
452 return .failure(error)
456 private func applyBalance(_ result: Result<Int, Error>?) {
457 guard let result else { return }
460 case .success(let newBalance):
461 // Capture the previous balance before overwriting it: the low-balance
462 // notification fires on the crossing, not on the value.
463 let previousBalance = balance
468 recordMetric(newBalance, into: &history, at: now)
470 // Low-balance notification: on threshold crossing (≥10 → <10)
471 // OR on first successful fetch if already below threshold.
472 if let previous = previousBalance, previous >= 10, newBalance < 10 {
473 sendLowBalanceNotification(balance: newBalance)
474 } else if previousBalance == nil && newBalance < 10 {
475 sendLowBalanceNotification(balance: newBalance)
478 case .failure(let error):
479 // Keep the stale balance value — don't wipe it on error.
480 // Only set balance = nil if there was never a successful fetch
481 // (it's already nil in that case, so nothing to do).
482 errorMessage = error.localizedDescription
486 // MARK: - Claude Usage
488 /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
489 /// plenty of people don't use Claude Code, and an error telling them so would be
490 /// noise rather than news.
491 private enum ClaudeOutcome {
493 case usage(ClaudeUsageReport)
497 private func fetchClaudeUsage() async -> ClaudeOutcome {
499 return .usage(try await claudeChecker.fetchUsage())
500 } catch ClaudeUsageError.noCredentials {
501 return .notConfigured
503 return .failure(error.localizedDescription)
507 private func applyClaude(_ outcome: ClaudeOutcome) {
513 claudeHistory.removeAll()
515 case .usage(let report):
516 // Capture the previous 5-hour % for threshold-crossing notification.
517 let previousPercent = claudeFiveHourPercent
518 claudeUsage = report.usage
519 claudePlan = report.subscriptionType
522 // Record history and fire high-usage notification based on the 5-hour %.
523 if let percent = claudeFiveHourPercent {
525 recordMetric(percent, into: &claudeHistory, at: now)
527 // High-usage notification: on threshold crossing (<90 → ≥90)
528 // OR on first successful fetch if already above threshold.
529 if let previous = previousPercent, previous < Self.highUsageThreshold,
530 percent >= Self.highUsageThreshold {
531 sendHighUsageNotification(provider: "Claude Code", percent: percent)
532 } else if previousPercent == nil, percent >= Self.highUsageThreshold {
533 sendHighUsageNotification(provider: "Claude Code", percent: percent)
537 case .failure(let message):
538 // As with the balance: keep the last good numbers on screen and say what
539 // went wrong, rather than blanking the section on a transient failure.
540 claudeError = message
544 /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
545 var claudeFiveHourPercent: Int? {
546 guard let fiveHour = claudeUsage?.fiveHour else { return nil }
547 return Int(fiveHour.utilization.rounded())
550 /// The 7-day window percentage for the menu bar title, or `nil` when absent.
551 /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
552 /// has been used — on Max plans the Opus cap is the one that binds first.
553 var claudeSevenDayPercent: Int? {
554 if let sevenDay = claudeUsage?.sevenDay {
555 return Int(sevenDay.utilization.rounded())
557 if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
558 return Int(opus.utilization.rounded())
563 /// Display label for the Claude plan, e.g. "Pro" or "Max".
564 var claudePlanLabel: String? {
565 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
566 !plan.isEmpty else { return nil }
568 switch plan.lowercased() {
569 case "free": return "Free"
570 case "pro": return "Pro"
571 case "max": return "Max"
572 case "team": return "Team"
573 case "enterprise": return "Enterprise"
574 default: return plan.capitalized
578 // MARK: - z.ai Usage
580 /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
581 /// plenty of people don't use z.ai, and an error telling them so would be noise.
582 private enum ZaiOutcome {
584 case usage(ZaiUsageReport)
588 private func fetchZaiUsage(key: String) async -> ZaiOutcome {
589 guard !key.isEmpty else { return .notConfigured }
591 return .usage(try await zaiChecker.fetchUsage(apiKey: key))
592 } catch ZaiUsageError.noAPIKey {
593 return .notConfigured
595 return .failure(error.localizedDescription)
599 private func applyZai(_ outcome: ZaiOutcome) {
604 zaiHistory.removeAll()
606 case .usage(let report):
607 // Capture the previous 5-hour % for threshold-crossing notification.
608 let previousPercent = zaiFiveHourPercent
612 // Record history and fire high-usage notification based on the 5-hour %.
613 if let percent = zaiFiveHourPercent {
615 recordMetric(percent, into: &zaiHistory, at: now)
617 // High-usage notification: on threshold crossing (<90 → ≥90)
618 // OR on first successful fetch if already above threshold.
619 if let previous = previousPercent, previous < Self.highUsageThreshold,
620 percent >= Self.highUsageThreshold {
621 sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
622 } else if previousPercent == nil, percent >= Self.highUsageThreshold {
623 sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
627 case .failure(let message):
628 // As with the balance and Claude: keep the last good numbers on screen
629 // and say what went wrong, rather than blanking the section on a
630 // transient failure.
635 /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
636 var zaiFiveHourPercent: Int? {
637 zaiUsage?.fiveHourPercent
640 /// The weekly window percentage for the popover, or `nil` when absent.
641 var zaiWeeklyPercent: Int? {
642 zaiUsage?.weeklyPercent
645 /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
646 /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
647 var zaiPlanLabel: String? {
648 guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
649 !level.isEmpty else { return nil }
651 // The subscription endpoint returns a product name like "GLM Coding Max";
652 // extract the tier from it. The quota endpoint returns just the tier.
653 let lowered = level.lowercased()
654 if lowered.contains("max") { return "Max" }
655 if lowered.contains("pro") { return "Pro" }
656 if lowered.contains("lite") { return "Lite" }
657 return level.capitalized
662 /// Copies the current balance to the clipboard as plain digits (no grouping
663 /// separator), and shows a brief "✓ Copied" confirmation.
664 func copyBalanceToClipboard() {
665 guard let balance = balance else { return }
667 let pasteboard = NSPasteboard.general
668 pasteboard.clearContents()
669 pasteboard.setString(String(balance), forType: .string)
671 copiedConfirmation = true
672 copiedResetTask?.cancel()
673 copiedResetTask = Task { @MainActor in
674 try? await Task.sleep(nanoseconds: 1_500_000_000)
675 guard !Task.isCancelled else { return }
676 copiedConfirmation = false
680 /// Copies the current Claude 5-hour percentage to the clipboard.
681 func copyClaudePercentToClipboard() {
682 guard let percent = claudeFiveHourPercent else { return }
684 let pasteboard = NSPasteboard.general
685 pasteboard.clearContents()
686 pasteboard.setString("\(percent)%", forType: .string)
688 copiedClaudeConfirmation = true
689 copiedClaudeResetTask?.cancel()
690 copiedClaudeResetTask = Task { @MainActor in
691 try? await Task.sleep(nanoseconds: 1_500_000_000)
692 guard !Task.isCancelled else { return }
693 copiedClaudeConfirmation = false
697 /// Copies the current z.ai 5-hour percentage to the clipboard.
698 func copyZaiPercentToClipboard() {
699 guard let percent = zaiFiveHourPercent else { return }
701 let pasteboard = NSPasteboard.general
702 pasteboard.clearContents()
703 pasteboard.setString("\(percent)%", forType: .string)
705 copiedZaiConfirmation = true
706 copiedZaiResetTask?.cancel()
707 copiedZaiResetTask = Task { @MainActor in
708 try? await Task.sleep(nanoseconds: 1_500_000_000)
709 guard !Task.isCancelled else { return }
710 copiedZaiConfirmation = false
714 // MARK: - Notifications
716 private func sendLowBalanceNotification(balance: Int) {
717 // Build the body here, on the main actor. `getNotificationSettings` calls back on
718 // a background queue, and the shared `balanceFormatter` is main-actor state — the
719 // status bar title formats against the very same instance.
720 let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
722 let center = UNUserNotificationCenter.current()
723 center.getNotificationSettings { settings in
724 guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
727 let content = UNMutableNotificationContent()
728 content.title = "Low Usage Menubar"
730 content.sound = .default
732 // A fixed identifier makes a later alert replace the delivered one in place,
733 // which updates the text without ever notifying the user again.
734 let request = UNNotificationRequest(
735 identifier: "low-balance-alert-\(UUID().uuidString)",
743 /// Sends a notification when a usage percentage crosses the high-usage threshold.
744 private func sendHighUsageNotification(provider: String, percent: Int) {
745 let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
747 let center = UNUserNotificationCenter.current()
748 center.getNotificationSettings { settings in
749 guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
752 let content = UNMutableNotificationContent()
753 content.title = "High Usage Menubar"
755 content.sound = .default
757 let request = UNNotificationRequest(
758 identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
768 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
769 /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
771 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
773 let deleted = KeychainHelper.delete()
775 errorMessage = "Could not delete API key from Keychain. It may be locked."
780 hyperConfigured = false
781 // refresh() with an empty key clears balance, error, history, and
782 // cancels any in-flight task from the previous key.
785 let saved = KeychainHelper.save(key)
787 errorMessage = "Could not save API key to Keychain. It may be locked."
790 // A different key is a different account, so everything derived from the old
791 // one has to go — not just the history behind the sparkline and trend. A
792 // carried-over balance would be displayed as the new account's until the
793 // fetch lands, and it would stand in as `previousBalance` for the low-balance
794 // threshold check, which would then compare two unrelated accounts.
795 if key != activeAPIKey {
801 // Show the trimmed key that was actually stored.
804 hyperConfigured = true
808 // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
809 // Cancel any previous reset so rapid Save clicks don't hide the
810 // confirmation prematurely.
811 savedConfirmation = true
812 savedResetTask?.cancel()
813 savedResetTask = Task { @MainActor in
814 try? await Task.sleep(nanoseconds: 2_000_000_000)
815 guard !Task.isCancelled else { return }
816 savedConfirmation = false
820 // MARK: - z.ai API Key
822 /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
823 /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
824 func saveZaiAPIKey() {
825 let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
827 let deleted = ZaiKeychainHelper.delete()
829 zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
834 // refresh() with an empty key clears zai usage and error, and
835 // cancels any in-flight task from the previous key.
838 let saved = ZaiKeychainHelper.save(key)
840 zaiError = "Could not save z.ai API key to Keychain. It may be locked."
843 // A different key is a different account, so everything derived from the
844 // old one has to go.
845 if key != activeZaiAPIKey {
848 zaiHistory.removeAll()
850 // Show the trimmed key that was actually stored.
852 activeZaiAPIKey = key
856 // Reuse the same "✓ Saved" confirmation — the user sees one per save action,
857 // regardless of which key it was for.
858 savedConfirmation = true
859 savedResetTask?.cancel()
860 savedResetTask = Task { @MainActor in
861 try? await Task.sleep(nanoseconds: 2_000_000_000)
862 guard !Task.isCancelled else { return }
863 savedConfirmation = false
867 // MARK: - Launch at Login
869 func updateLaunchAtLogin(_ enabled: Bool) {
872 try SMAppService.mainApp.register()
874 try SMAppService.mainApp.unregister()
877 errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
878 + error.localizedDescription
879 // Snap the switch back to what the system actually did, suppressing the
880 // didSet so this doesn't recurse into another register/unregister attempt.
881 isInitializingLaunchAtLogin = true
882 launchAtLogin = SMAppService.mainApp.status == .enabled
883 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