on master help sign in
12 files changed+1790−303
since splitsnapshots
unifiedsplit
CLAUDE.md+42−11 README.md+64−48 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+29−1 UsageMenubar/MenuView.swift+349−159 UsageMenubar/UsageMenubarApp.swift+25−7 UsageMenubar/ViewModel.swift+388−48 UsageMenubar/ZaiUsageClient.swift+388 UsageMenubarTests/CreditsCheckerTests.swift+19 UsageMenubarTests/StatusBarTextTests.swift+244−24 UsageMenubarTests/ZaiUsageClientTests.swift+237 project.yml+4−4
CLAUDE.md+42−11
@@ -1,19 +1,50 @@
11# UsageMenubar
22
33## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5−Claude Code's Pro/Max usage limits when the CLI is signed in on the machine.
4+macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5+visual treatment:
6+- **Hyper** (Charm) — credit balance
7+- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
8+- **z.ai Coding Plan** — usage limits (5-hour, weekly)
9+
10+Each configured provider gets the same popover treatment: a 26pt subhero headline
11+number, bars under every window, a sparkline of recent history, a trend arrow,
12+click-to-copy on the headline, and threshold-crossing notifications.
13+
614- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
7−- `ViewModel.swift` — Observable state: balance, Claude usage, refresh, notifications, history
8−- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, settings)
15+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
16+- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
917- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
1018- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
11−- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper)
19+- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
20+- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
1221- `VersionFormatter.swift` — Version string formatting
1322
14−The status bar title stays `⚡{balance}` (Hyper only); Claude usage lives in the popover.
15−The two fetches are independent — `ViewModel.refresh()` runs them with `async let`, and
16−either can fail or be unconfigured without touching the other.
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
25+(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
26+service is not configured (no API key or no credentials). An unconfigured service
27+produces no placeholder in the menu bar. The three fetches are independent —
28+`ViewModel.refresh()` runs them with `async let`, and any can fail or be
29+unconfigured without touching the others.
30+
31+## Equal provider treatment
32+All three providers share the same visual structure in the popover:
33+1. Section header with icon + plan label
34+2. Headline number (26pt subheroFont) with emoji, color-coded, click-to-copy, trend arrow
35+3. Reset countdown text (Claude/z.ai windows only — Hyper has no windows)
36+4. Bars under EVERY window (5-hour, 7-day, weekly — all get bars)
37+5. Sparkline (each provider tracks its own history via `MetricPoint` arrays)
38+6. Error row if applicable
39+
40+Notifications fire on threshold crossings:
41+- Hyper: balance drops below 10 credits
42+- Claude: 5-hour % crosses above 90%
43+- z.ai: 5-hour % crosses above 90%
44+
45+If a provider isn't configured, its section simply doesn't appear — no placeholder,
46+no error. This is already the case for Claude and z.ai; Hyper now behaves the same
47+way (gated on `hyperConfigured`).
1748
1849## Claude credentials (strictly read-only)
1950`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
@@ -44,9 +75,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
4475- Swift 5.9, macOS 14.0 deployment target
4576
4677## Conventions
47−- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
78+- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
4879- No card-heavy/multi-material/gradient-rich designs
49−- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
80+- Rounded font family at one scale (subhero/section/control/caption/footnote)
5081- Monospaced digits for numbers that change
5182- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
5283 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -54,4 +85,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
5485
5586## VCS
5687- jj colocated with git
57−- Push directly to master — no PRs for this repo
88+- Push directly to master — no PRs for this repo
README.md+64−48
@@ -4,24 +4,33 @@
44
55# UsageMenubar
66
7−A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance — and, if you use the Claude Code CLI, your Claude subscription limits alongside it.
7+A macOS menu bar application that displays usage limits and credit balances for multiple LLM services: [Hyper (Charm)](https://hyper.charm.land), [Claude Code](https://claude.com/claude-code), and [z.ai](https://z.ai) Coding Plans. Each service is independent — configure the ones you use, ignore the ones you don't.
88
99## Features
1010
11−- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
12−- 🤖 **Claude Code limits** — your Pro/Max 5-hour and 7-day usage windows in the popover, with the percentage used and a countdown to the next reset. No setup: if you have signed in with the Claude Code CLI, it just appears. If you haven't, the section doesn't
13−- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
14−- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
15−- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
16−- 🖱️ **Right-click menu** — right-click the menu bar icon for quick access to Refresh, Open hyper.charm.land, and Quit
17−- 🔔 **Low-balance notifications** — alerts you when your balance drops below 10 credits, including on the very first fetch if you are already below the threshold
18−- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
19−- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
20−- 🔐 **Secure input** — API key field is masked
11+- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
14+- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
15+- 🖱️ **Right-click menu** — quick access to Refresh and Quit
16+- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
2117- 🚀 **Launch at login** — optional, using `SMAppService`
22−- 🔑 **Get key link** — quick link to [hyper.charm.land](https://hyper.charm.land) right next to the API key field
2318- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
2419
20+## Menu bar
21+
22+Each service shows an emoji-prefixed segment when configured and above 0%:
23+
24+| Service | Emoji | Example |
25+|---------|-------|---------|
26+| Hyper balance | ⚡ | `⚡42` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29+| z.ai 5-hour | Z | `Z12%` |
30+| z.ai weekly | Z | `Z3%` |
31+
32+Unconfigured services don't appear at all — no placeholder, no icon.
33+
2534## Installation
2635
2736### Homebrew (recommended)
@@ -42,37 +51,47 @@ brew install --cask ilyakooo0/tap/usage-menubar
42511. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
43522. Download the latest `UsageMenubar-*.zip`
44533. Unzip and move `UsageMenubar.app` to `/Applications`
45−4. Launch the app — it will appear in your menu bar as ⚡
54+4. Launch the app — it will appear in your menu bar
4655
4756> **Note:** Releases are **ad-hoc signed** — the CI build applies `codesign -s -` to the app, which gives it a stable code identity but is *not* an Apple Developer ID signature. macOS will still warn you on first launch: right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**. Removing that prompt entirely requires a paid Developer ID certificate and notarization.
4857
49−### Set your API key
58+## Setup
59+
60+Click the menu bar icon to open the popover. Each service is configured independently:
5061
51−1. Click the ⚡ icon in your menu bar
52−2. Enter your Hyper API key (starts with `sk-...`) in the **API Key** field
53−3. Click **Save**
54−4. Your balance will appear immediately
62+### Hyper credits
5563
56−You can get your API key from [hyper.charm.land](https://hyper.charm.land).
64+1. Enter your Hyper API key (starts with `sk-...`) in the **Hyper API Key** field
65+2. Click **Save**
66+3. Your balance appears immediately
5767
58−### Claude Code limits (optional, no setup)
68+Get your key from [hyper.charm.land](https://hyper.charm.land).
5969
60−If you use the [Claude Code CLI](https://claude.com/claude-code), the popover also shows how much of your Pro/Max plan you have used: the limit currently in effect as a headline percentage, the 5-hour and 7-day windows underneath, and how long until the next reset.
70+### Claude Code limits (no setup)
6171
62−There is nothing to configure. The app reads the OAuth credentials Claude Code already stores when you run `claude` and sign in — first from its login-Keychain item, then from `~/.claude/.credentials.json` if the Keychain has nothing. If neither exists, the Claude section simply doesn't appear; it is not treated as an error, and the Hyper balance is unaffected.
72+If you use the [Claude Code CLI](https://claude.com/claude-code), the popover shows how much of your Pro/Max plan you have used: the 5-hour and 7-day windows, and how long until the next reset.
6373
64−Two things worth knowing:
74+There is nothing to configure. The app reads the OAuth credentials Claude Code already stores when you run `claude` and sign in — first from its login-Keychain item, then from `~/.claude/.credentials.json` if the Keychain has nothing. If neither exists, the Claude section doesn't appear.
6575
6676- **macOS will ask once.** Those credentials belong to Claude Code, not to this app, so the first read prompts you to allow access to the `Claude Code-credentials` Keychain item. Choose **Always Allow** and you won't be asked again.
67−- **The credentials are strictly read-only.** The app reads the access token Claude Code stored and spends it as-is. It never refreshes it, and never writes those credentials back — that is the CLI's job alone. (Refreshing rotates the refresh token, so an app doing it behind the CLI's back would race it and sign you out of your own terminal.) If the token has expired, the Claude section says so and asks you to run `claude`; once the CLI has refreshed on its own, the app picks the new token up on its next fetch.
77+- **The credentials are strictly read-only.** The app reads the access token Claude Code stored and spends it as-is. It never refreshes it, and never writes those credentials back — that is the CLI's job alone. If the token has expired, the Claude section says so and asks you to run `claude`.
78+
79+### z.ai Coding Plan
80+
81+1. Enter your z.ai API key in the **z.ai API Key** field
82+2. Click **Save**
83+3. Your 5-hour and weekly quota percentages appear immediately
84+
85+Get your key from [z.ai/manage-apikey](https://z.ai/manage-apikey/coding-plan/personal/my-plan).
6886
6987## Settings
7088
71−Everything is configured from the popover — click the ⚡ icon in the menu bar.
89+Everything is configured from the popover — click the menu bar icon.
7290
7391| Setting | Where | Notes |
7492| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
75−| **API key** | **API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
93+| **Hyper API key** | **Hyper API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
94+| **z.ai API key** | **z.ai API Key** field → **Save** | Stored separately from the Hyper key. |
7695| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
7796| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
7897| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
@@ -138,18 +157,21 @@ usage-menubar/
138157├── project.yml # xcodegen project definition
139158├── UsageMenubar/
140159│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
141−│ ├── ViewModel.swift # Observable state: balance, Claude usage, refresh, notifications
160+│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
142161│ ├── MenuView.swift # SwiftUI popover view
143−│ ├── CreditsChecker.swift # API client (GET /v1/credits)
162+│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
144163│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
145−│ ├── KeychainHelper.swift # Keychain wrapper for API key
164+│ ├── ZaiUsageClient.swift # z.ai API client (GET /monitor/usage/quota/limit)
165+│ ├── KeychainHelper.swift # Keychain wrapper (Hyper + z.ai keys)
146166│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
147167│ ├── Info.plist # LSUIElement=true (agent app)
148−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
168+│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
149169│ └── Assets.xcassets/ # App icon, accent color
150170├── UsageMenubarTests/
151−│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
171+│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
152172│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173+│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174+│ ├── StatusBarTextTests.swift # Menu bar title formatting
153175│ └── VersionFormatterTests.swift # Version formatting tests
154176├── .github/workflows/
155177│ └── release.yml # Build + release on push to master
@@ -160,24 +182,18 @@ usage-menubar/
160182
161183## How it works
162184
163−1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`. Left-click toggles the popover; right-click opens a context menu (Refresh, Open hyper.charm.land, Quit).
164−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and settings.
165−3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures (5xx, timeouts) with exponential backoff, and decodes `{"balance": <int>}` (tolerating fractional values by rounding).
166−4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
167−5. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m) to refresh the balance; the app also refreshes on wake from sleep. Changing the interval in the popover restarts the timer immediately.
168−6. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
169−7. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
170−8. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
171−9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. It is strictly read-only — it never refreshes the token and never writes credentials back. The token endpoint rotates the refresh token, so an app that also refreshed would race the CLI and sign the user out of their own terminal. When the token is spent (401/403), the app says so and points at `claude`; the next fetch (every 1–5 min, credentials re-read each time) picks up whatever the CLI refreshed on its own. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
172−10. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
185+1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button. Left-click toggles the popover; right-click opens a context menu (Refresh, Quit).
186+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with each configured service's data and the settings.
187+3. **Independent fetches**: `ViewModel.refresh()` runs the Hyper, Claude, and z.ai requests concurrently with `async let`. Any can fail, stall, or be unconfigured without affecting the others.
188+4. **Hyper API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures with exponential backoff, and decodes `{"balance": <int>}`.
189+5. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`) and fetches `GET https://api.anthropic.com/api/oauth/usage`. It is strictly read-only — it never refreshes the token and never writes credentials back. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
190+6. **z.ai usage**: `ZaiUsageClient` fetches `GET https://api.z.ai/api/monitor/usage/quota/limit` and `GET https://api.z.ai/api/biz/subscription/list` concurrently with an API key the user enters in settings. Same retry pattern as the other clients.
191+7. **Keychain**: API keys are stored/retrieved via `KeychainHelper` using the macOS Security framework. Hyper and z.ai keys are stored separately.
192+8. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m); the app also refreshes on wake from sleep.
193+9. **Notifications**: When the Hyper balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
194+10. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
17319511. **No sandbox**: Reading credentials that belong to another app means spawning `/usr/bin/security` and reading a file in the user's home directory, neither of which the App Sandbox permits. The app is distributed ad-hoc signed via Homebrew rather than through the App Store, where the sandbox would be mandatory.
174196
175−## Credits
176−
177−- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
178−- **API**: [Hyper by Charm](https://hyper.charm.land)
179−- **Built with**: Swift 5.9, SwiftUI, macOS 14+
180−
181197## License
182198
183−MIT — see [LICENSE](LICENSE).
199+MIT — see [LICENSE](LICENSE).
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+29−1
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -106,3 +106,31 @@ enum KeychainHelper {
106106 store.delete()
107107 }
108108}
109+
110+/// Wrapper around the macOS Keychain for storing the z.ai API key.
111+///
112+/// Uses the same keychain service as the Hyper key but a different account, so the
113+/// two keys coexist without clobbering each other.
114+enum ZaiKeychainHelper {
115+ private static let service = "soy.iko.usage-menubar"
116+ private static let account = "zai-api-key"
117+
118+ private static let store = KeychainStore(service: service, account: account)
119+
120+ /// Saves the given z.ai API key to the Keychain, overwriting any existing entry.
121+ @discardableResult
122+ static func save(_ key: String) -> Bool {
123+ store.save(key)
124+ }
125+
126+ /// Retrieves the stored z.ai API key, or `nil` if none exists.
127+ static func load() -> String? {
128+ store.load()
129+ }
130+
131+ /// Deletes the stored z.ai API key if it exists.
132+ @discardableResult
133+ static func delete() -> Bool {
134+ store.delete()
135+ }
136+}
UsageMenubar/MenuView.swift+349−159
@@ -4,20 +4,15 @@ import SwiftUI
44struct MenuView: View {
55 @ObservedObject var viewModel: ViewModel
66
7− /// Dims the balance while the pointer is over it, hinting that it is clickable.
8− @State private var isHoveringBalance = false
9−
107 private static let websiteURL = URL(string: "https://hyper.charm.land")!
8+ private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
119
1210 // MARK: - Type Scale
1311
14− /// One rounded family, sized so that the balance is the only large element and
15− /// everything else steps down from it — nothing competes with it for attention.
16− /// The Claude percentage is the one exception, and it is deliberately set well
17− /// below the balance so it reads as the second thing on the page, not the first.
18− /// Monospaced digits keep numbers from jittering as their digits change.
19− private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
20− .monospacedDigit()
12+ /// One rounded family, sized so that every provider's headline number reads at the
13+ /// same weight. With three providers in the popover, the 26pt subhero is the right
14+ /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15+ /// numbers from jittering as their digits change.
2116 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
2217 .monospacedDigit()
2318 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -27,11 +22,17 @@ struct MenuView: View {
2722
2823 var body: some View {
2924 VStack(alignment: .leading, spacing: 18) {
30− hyper
25+ if viewModel.hyperConfigured {
26+ hyper
27+ }
3128 if showsClaude {
3229 serviceBreak
3330 claude
3431 }
32+ if showsZai {
33+ serviceBreak
34+ zai
35+ }
3536 hairline
3637 settings
3738 hairline
@@ -46,12 +47,18 @@ struct MenuView: View {
4647 .background(Color(nsColor: .windowBackgroundColor))
4748 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
4849 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
4952 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5053 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5154 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
52− .animation(.easeInOut(duration: 0.3), value: historyValues.count)
55+ .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56+ .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
5358 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5459 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60+ .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
61+ .animation(.easeInOut(duration: 0.2), value: viewModel.zaiError)
5562 }
5663
5764 /// A section rule light enough to read as a pause rather than a border.
@@ -59,15 +66,14 @@ struct MenuView: View {
5966 Divider().opacity(0.3)
6067 }
6168
62− /// The rule between the two services. Wider than the others on purpose: Hyper and
63− /// Claude are two unrelated accounts, and the gap should say so before the headers do.
69+ /// The rule between services. Wider than the others on purpose: the providers are
70+ /// unrelated accounts, and the gap should say so before the headers do.
6471 private var serviceBreak: some View {
6572 hairline.padding(.vertical, 5)
6673 }
6774
6875 /// A section header: an icon and a title, sized to match the "API Key" header below
69− /// so the popover reads as three sections rather than a hero with two appendices.
70− /// The icon is what tells the two services apart at a glance.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
7177 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7278 HStack(spacing: 5) {
7379 Image(systemName: systemImage)
@@ -78,113 +84,62 @@ struct MenuView: View {
7884 }
7985 }
8086
81− // MARK: - Hyper
82−
83− /// The Hyper section: its header, then the balance as the popover's hero.
84− private var hyper: some View {
85− VStack(alignment: .leading, spacing: 12) {
86− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
87−
88− hero
89− }
90− }
91−
92− private var hero: some View {
93− VStack(spacing: 8) {
94− if viewModel.isLoading && viewModel.balance == nil {
95− PulsingBolt()
96− .transition(.opacity)
97− } else if viewModel.balance == nil {
98− VStack(spacing: 6) {
99− Text("⚡?")
100− .font(Self.heroFont)
101− .foregroundColor(.secondary)
87+ /// A reusable headline number row: the provider's main metric at subhero scale,
88+ /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89+ /// copy-confirmation slot so the layout doesn't shift.
90+ private func headlineNumber(
91+ text: String,
92+ color: Color,
93+ trend: MetricTrend,
94+ copiedConfirmation: Bool,
95+ captionText: String,
96+ copyAction: @escaping () -> Void
97+ ) -> some View {
98+ VStack(spacing: 6) {
99+ Button(action: copyAction) {
100+ HStack(alignment: .firstTextBaseline, spacing: 6) {
101+ Text(text)
102+ .font(Self.subheroFont)
103+ .foregroundColor(color)
104+ .contentTransition(.opacity)
102105
103− Text("Enter your API key below")
104− .font(Self.captionFont)
105− .foregroundColor(.secondary)
106− }
107− .transition(.opacity)
108− } else {
109− VStack(spacing: 6) {
110− balanceNumber
111− caption
112− sparkline
106+ if let symbol = trend.symbolName {
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
109+ .foregroundColor(.secondary)
110+ .transition(.opacity)
111+ }
113112 }
114− .transition(.opacity)
115− }
116−
117− if let lastUpdatedText = viewModel.lastUpdatedText {
118− Text(lastUpdatedText)
119− .font(Self.footnoteFont)
120− .foregroundColor(.secondary)
121− .transition(.opacity)
122− }
123−
124− if let error = viewModel.errorMessage {
125− errorRow(error)
126− .transition(.opacity)
113+ .contentShape(Rectangle())
127114 }
128− }
129− .frame(maxWidth: .infinity)
130− }
131−
132− /// The balance itself. Clicking it copies the raw number to the clipboard.
133− private var balanceNumber: some View {
134− Button(action: { viewModel.copyBalanceToClipboard() }) {
135− HStack(alignment: .firstTextBaseline, spacing: 6) {
136− Text(viewModel.formattedBalance)
137− .font(Self.heroFont)
138− .foregroundColor(viewModel.balanceColor)
139− .contentTransition(.opacity)
140−
141− if let symbol = viewModel.balanceTrend.symbolName {
142− Image(systemName: symbol)
143− .font(.system(size: 12, weight: .semibold))
115+ .buttonStyle(.plain)
116+ .help("Click to copy")
117+
118+ // Caption doubles as copy confirmation so the layout doesn't shift.
119+ Group {
120+ if copiedConfirmation {
121+ Text("✓ Copied")
122+ .foregroundColor(.green)
123+ } else {
124+ Text(captionText)
144125 .foregroundColor(.secondary)
145− .transition(.opacity)
146126 }
147127 }
148− // Without this the hover area is only the glyphs themselves.
149− .contentShape(Rectangle())
128+ .font(Self.captionFont)
150129 }
151− .buttonStyle(.plain)
152− .disabled(viewModel.balance == nil)
153− .help("Click to copy")
154− .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
155− .onHover { isHoveringBalance = $0 }
156− .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
157− }
158−
159− /// Doubles as the copy confirmation so the layout doesn't shift.
160− private var caption: some View {
161− Group {
162− if viewModel.copiedConfirmation {
163− Text("✓ Copied")
164− .foregroundColor(.green)
165− } else {
166− Text("credits")
167− .foregroundColor(.secondary)
168− }
169− }
170− .font(Self.captionFont)
171130 }
172131
132+ /// A sparkline shown when there are at least 2 history points.
173133 @ViewBuilder
174− private var sparkline: some View {
175− if historyValues.count >= 2 {
176− Sparkline(values: historyValues, color: viewModel.balanceColor)
134+ private func sparkline(values: [Int], color: Color) -> some View {
135+ if values.count >= 2 {
136+ Sparkline(values: values, color: color)
177137 .frame(height: 26)
178138 .padding(.top, 4)
179139 .transition(.opacity)
180140 }
181141 }
182142
183− /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
184− private var historyValues: [Int] {
185− viewModel.balanceHistory.map { $0.balance }
186− }
187−
188143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
189144 /// message, and a tinted rounded background with no border.
190145 private func errorRow(_ message: String) -> some View {
@@ -209,6 +164,56 @@ struct MenuView: View {
209164 .padding(.top, 2)
210165 }
211166
167+ // MARK: - Hyper
168+
169+ /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170+ /// configured — the section doesn't appear, same as Claude and z.ai.
171+ private var showsHyper: Bool {
172+ viewModel.hyperConfigured
173+ }
174+
175+ /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176+ private var hyper: some View {
177+ VStack(alignment: .leading, spacing: 12) {
178+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
179+
180+ if viewModel.isLoading && viewModel.balance == nil {
181+ Text("…")
182+ .font(Self.subheroFont)
183+ .foregroundColor(.secondary)
184+ } else if let balance = viewModel.balance {
185+ VStack(spacing: 8) {
186+ headlineNumber(
187+ text: "⚡ \(viewModel.formattedBalance)",
188+ color: viewModel.balanceColor,
189+ trend: viewModel.balanceTrend,
190+ copiedConfirmation: viewModel.copiedConfirmation,
191+ captionText: "credits",
192+ copyAction: { viewModel.copyBalanceToClipboard() }
193+ )
194+
195+ sparkline(
196+ values: viewModel.balanceSparklineValues,
197+ color: viewModel.balanceColor
198+ )
199+ }
200+ .transition(.opacity)
201+ }
202+
203+ if let lastUpdatedText = viewModel.lastUpdatedText {
204+ Text(lastUpdatedText)
205+ .font(Self.footnoteFont)
206+ .foregroundColor(.secondary)
207+ .transition(.opacity)
208+ }
209+
210+ if let error = viewModel.errorMessage {
211+ errorRow(error)
212+ .transition(.opacity)
213+ }
214+ }
215+ }
216+
212217 // MARK: - Claude
213218
214219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -220,8 +225,8 @@ struct MenuView: View {
220225 return false
221226 }
222227
223− /// Claude Code's plan limits. The percentage leads, as the balance does above it,
224− /// with the individual windows underneath as quiet supporting detail.
228+ /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229+ /// headline number, bars under every window, a sparkline, and error row.
225230 private var claude: some View {
226231 VStack(alignment: .leading, spacing: 12) {
227232 HStack(spacing: 8) {
@@ -248,23 +253,31 @@ struct MenuView: View {
248253
249254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
250255 VStack(alignment: .leading, spacing: 12) {
251− // The 5-hour window leads as the hero number, since it's the one
252− // most likely to bind and the one shown in the menu bar title.
256+ // The 5-hour window leads as the headline number.
253257 if let fiveHour = usage.fiveHour {
254258 let percent = Int(fiveHour.utilization.rounded())
255− HStack(alignment: .firstTextBaseline, spacing: 8) {
256− Text("🕐 \(percent)%")
257− .font(Self.subheroFont)
258− .foregroundColor(Self.usageColor(percent))
259− .contentTransition(.opacity)
259+ VStack(spacing: 8) {
260+ HStack(alignment: .firstTextBaseline, spacing: 8) {
261+ headlineNumber(
262+ text: "⁕ \\(percent)%",
263+ color: Self.usageColor(percent),
264+ trend: viewModel.claudeTrend,
265+ copiedConfirmation: viewModel.copiedClaudeConfirmation,
266+ captionText: "5-hour usage",
267+ copyAction: { viewModel.copyClaudePercentToClipboard() }
268+ )
260269
261− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
262271
263− if let resets = fiveHour.resetsInFormatted {
264− Text("resets in \(resets)")
265− .font(Self.footnoteFont)
266− .foregroundColor(.secondary)
272+ if let resets = fiveHour.resetsInFormatted {
273+ Text("resets in \(resets)")
274+ .font(Self.footnoteFont)
275+ .foregroundColor(.secondary)
276+ }
267277 }
278+
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
268281 }
269282 }
270283
@@ -278,6 +291,12 @@ struct MenuView: View {
278291 window("7-day Opus", opus)
279292 }
280293 }
294+
295+ // Sparkline for the Claude 5-hour % history.
296+ sparkline(
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
299+ )
281300 }
282301 }
283302
@@ -303,6 +322,11 @@ struct MenuView: View {
303322 }
304323 }
305324
325+ /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326+ private var claudeFiveHourPercent: Int? {
327+ viewModel.claudeFiveHourPercent
328+ }
329+
306330 /// The balance colors read the other way round here, because for a limit more is
307331 /// worse: green with room to spare, yellow closing in, red nearly spent.
308332 private static func usageColor(_ percent: Int) -> Color {
@@ -311,6 +335,176 @@ struct MenuView: View {
311335 return .green
312336 }
313337
338+ // MARK: - z.ai
339+
340+ /// Whether there is anything to say about z.ai. Nothing at all when no API key has
341+ /// been entered — the section doesn't appear, and no error is shown for a service
342+ /// the user may simply not use.
343+ private var showsZai: Bool {
344+ if viewModel.zaiError != nil { return true }
345+ if let usage = viewModel.zaiUsage { return !usage.isEmpty }
346+ return false
347+ }
348+
349+ /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350+ /// a subhero headline number, bars under every window, a sparkline, and error row.
351+ private var zai: some View {
352+ VStack(alignment: .leading, spacing: 12) {
353+ HStack(spacing: 8) {
354+ sectionHeader("z.ai Coding", systemImage: "bolt.horizontal")
355+
356+ Spacer(minLength: 0)
357+
358+ if let plan = viewModel.zaiPlanLabel {
359+ Text(plan)
360+ .font(Self.captionFont)
361+ .foregroundColor(.secondary)
362+ }
363+ }
364+
365+ if let usage = viewModel.zaiUsage, !usage.isEmpty {
366+ zaiUsageDetail(usage)
367+ }
368+
369+ if let error = viewModel.zaiError {
370+ errorRow(error)
371+ }
372+ }
373+ }
374+
375+ private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376+ VStack(alignment: .leading, spacing: 12) {
377+ // The 5-hour window leads as the headline number.
378+ if let fiveHour = usage.fiveHourPercent {
379+ VStack(spacing: 8) {
380+ HStack(alignment: .firstTextBaseline, spacing: 8) {
381+ headlineNumber(
382+ text: "Z \\(fiveHour)%",
383+ color: Self.usageColor(fiveHour),
384+ trend: viewModel.zaiTrend,
385+ copiedConfirmation: viewModel.copiedZaiConfirmation,
386+ captionText: "5-hour usage",
387+ copyAction: { viewModel.copyZaiPercentToClipboard() }
388+ )
389+
390+ Spacer(minLength: 0)
391+
392+ if let resets = usage.fiveHourResetsAt {
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
394+ .font(Self.footnoteFont)
395+ .foregroundColor(.secondary)
396+ }
397+ }
398+
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401+ }
402+ }
403+
404+ VStack(alignment: .leading, spacing: 9) {
405+ if let weekly = usage.weeklyPercent {
406+ zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407+ }
408+ }
409+
410+ // Peak hours notice.
411+ zaiPeakHours
412+
413+ // Sparkline for the z.ai 5-hour % history, with an orange tint
414+ // when currently in peak hours.
415+ if viewModel.zaiSparklineValues.count >= 2 {
416+ Sparkline(
417+ values: viewModel.zaiSparklineValues,
418+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
419+ )
420+ .frame(height: 26)
421+ .padding(.top, 4)
422+ .transition(.opacity)
423+ .background(
424+ ViewModel.zaiInPeakHours
425+ ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426+ .fill(Color.orange.opacity(0.08))
427+ : nil
428+ )
429+ }
430+ }
431+ }
432+
433+ /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434+ private var zaiFiveHourPercent: Int? {
435+ viewModel.zaiFiveHourPercent
436+ }
437+
438+ /// One window row for z.ai: label, percentage, and a hairline bar.
439+ private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440+ VStack(alignment: .leading, spacing: 5) {
441+ HStack(spacing: 6) {
442+ Text(label)
443+ .font(Self.captionFont)
444+ .foregroundColor(.secondary)
445+
446+ Spacer(minLength: 0)
447+
448+ Text("\(percent)%")
449+ .font(Self.captionFont.monospacedDigit())
450+ .foregroundColor(Self.usageColor(percent))
451+ }
452+
453+ UsageBar(percent: percent, color: Self.usageColor(percent))
454+ }
455+ }
456+
457+ /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458+ /// Limits are most likely to bind during this window. Displayed in the
459+ /// user's local timezone so they know when to expect slowdowns.
460+ private var zaiPeakHours: some View {
461+ let inPeak = ViewModel.zaiInPeakHours
462+
463+ return HStack(spacing: 6) {
464+ Circle()
465+ .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466+ .frame(width: 6, height: 6)
467+
468+ Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469+ .font(Self.footnoteFont)
470+ .foregroundColor(inPeak ? .orange : .secondary)
471+ }
472+ }
473+
474+ /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475+ private static var zaiPeakHoursLocal: String {
476+ let formatter = DateFormatter()
477+ formatter.timeZone = TimeZone.current
478+ formatter.dateFormat = "HH:mm"
479+
480+ var utc = DateComponents()
481+ utc.timeZone = TimeZone(identifier: "UTC")
482+ utc.hour = 6
483+ var calendar = Calendar(identifier: .gregorian)
484+ calendar.timeZone = TimeZone(identifier: "UTC")!
485+ let start = calendar.date(from: utc)!
486+
487+ utc.hour = 10
488+ let end = calendar.date(from: utc)!
489+
490+ return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491+ }
492+
493+ /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494+ private static func zaiResetsIn(from date: Date) -> String {
495+ let interval = date.timeIntervalSince(Date())
496+ guard interval > 0 else { return "soon" }
497+
498+ let seconds = Int(interval.rounded())
499+ let days = seconds / 86_400
500+ let hours = (seconds % 86_400) / 3_600
501+ let minutes = (seconds % 3_600) / 60
502+
503+ if days > 0 { return "\(days)d \(hours)h" }
504+ if hours > 0 { return "\(hours)h \(minutes)m" }
505+ return "\(minutes)m"
506+ }
507+
314508 // MARK: - Settings
315509
316510 private var settings: some View {
@@ -346,6 +540,37 @@ struct MenuView: View {
346540 .controlSize(.small)
347541 }
348542
543+ VStack(alignment: .leading, spacing: 8) {
544+ HStack(spacing: 8) {
545+ sectionHeader("z.ai API Key", systemImage: "bolt.horizontal")
546+
547+ Spacer(minLength: 0)
548+
549+ // The confirmation takes the link's place rather than adding a row
550+ // of its own, so saving never resizes the popover.
551+ if viewModel.savedConfirmation {
552+ Text("✓ Saved")
553+ .font(Self.captionFont)
554+ .foregroundColor(.green)
555+ .transition(.opacity)
556+ } else {
557+ Link("Get key →", destination: Self.zaiKeyURL)
558+ .font(Self.captionFont)
559+ .transition(.opacity)
560+ }
561+ }
562+
563+ HStack(spacing: 8) {
564+ SecureField("your z.ai key", text: $viewModel.zaiAPIKeyInput)
565+ .textFieldStyle(.roundedBorder)
566+
567+ Button("Save") {
568+ viewModel.saveZaiAPIKey()
569+ }
570+ }
571+ .controlSize(.small)
572+ }
573+
349574 HStack {
350575 Text("Refresh every")
351576
@@ -386,42 +611,7 @@ struct MenuView: View {
386611 .keyboardShortcut("q")
387612 }
388613 .controlSize(.small)
389−
390− Link("hyper.charm.land", destination: Self.websiteURL)
391− .font(Self.footnoteFont)
392− .foregroundColor(.secondary)
393− .opacity(0.7)
394− .frame(maxWidth: .infinity, alignment: .center)
395− }
396− }
397−}
398−
399−// MARK: - Loading
400−
401−/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
402−/// the menu bar title rather than a generic spinner, so the two read as the same app.
403−private struct PulsingBolt: View {
404− @State private var isPulsing = false
405−
406− var body: some View {
407− VStack(spacing: 6) {
408− Text("⚡")
409− .font(.system(size: 34))
410− .opacity(isPulsing ? 0.35 : 1)
411− .scaleEffect(isPulsing ? 0.94 : 1)
412− .animation(
413− .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
414− value: isPulsing
415− )
416− // Roughly the height of the balance it stands in for, so the hero
417− // doesn't jump when the first fetch lands.
418− .frame(height: 56)
419−
420− Text("Loading…")
421− .font(.system(size: 11, weight: .medium, design: .rounded))
422− .foregroundColor(.secondary)
423614 }
424− .onAppear { isPulsing = true }
425615 }
426616}
427617
@@ -455,8 +645,8 @@ private struct UsageBar: View {
455645
456646// MARK: - Sparkline
457647
458−/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
459−/// takes the balance's color so the trend reads as part of the number above it, with a
648+/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649+/// takes the metric's color so the trend reads as part of the number above it, with a
460650/// faint fade underneath for weight and a dot marking the latest point.
461651///
462652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -548,4 +738,4 @@ struct Sparkline: View {
548738 path.closeSubpath()
549739 }
550740 }
551−}
741+}
UsageMenubar/UsageMenubarApp.swift+25−7
@@ -35,7 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3535
3636 if let button = statusItem.button {
3737 button.image = nil
38− button.title = "⚡?"
38+ button.title = "…"
3939 button.target = self
4040 button.action = #selector(statusItemClicked(_:))
4141 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -46,8 +46,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
49− // Observe published changes (balance + isLoading + Claude usage) to update the
50− // menu bar title. CombineLatest fires when any of them changes.
49+ // Observe published changes (balance + isLoading + hyperConfigured +
50+ // Claude usage + z.ai usage) to update the menu bar title. CombineLatest
51+ // fires when any of them changes.
5152 //
5253 // The title is built from the values the publisher hands us, not by reading the
5354 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -56,23 +57,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5657 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5758 // whose Combine scheduler only runs in the default run loop mode — the title
5859 // would freeze while a menu or a scroll was tracking.
59− statusTextCancellable = Publishers.CombineLatest3(
60+ // Combine has CombineLatest4 but not 5, so nest: outer combines
61+ // balance + isLoading + hyperConfigured with an inner that merges
62+ // claudeUsage + zaiUsage. The inner Just(()) is a dummy third stream
63+ // to satisfy CombineLatest3 — it fires once and never again, so the
64+ // inner effectively republishes claudeUsage and zaiUsage changes.
65+ statusTextCancellable = Publishers.CombineLatest4(
6066 viewModel.$balance,
6167 viewModel.$isLoading,
62− viewModel.$claudeUsage
68+ viewModel.$hyperConfigured,
69+ Publishers.CombineLatest3(
70+ viewModel.$claudeUsage,
71+ viewModel.$zaiUsage,
72+ Just<Void>(())
73+ )
6374 )
64− .map { balance, isLoading, claudeUsage in
75+ .map { balance, isLoading, hyperConfigured, claudeAndZai in
76+ let (claudeUsage, zaiUsage, _) = claudeAndZai
6577 let fiveHour = claudeUsage?.fiveHour
6678 .map { Int($0.utilization.rounded()) }
6779 let sevenDay = claudeUsage?.sevenDay
6880 .map { Int($0.utilization.rounded()) }
6981 ?? claudeUsage?.sevenDayOpus
7082 .map { Int($0.utilization.rounded()) }
83+ let zaiFiveHour = zaiUsage?.fiveHourPercent
84+ let zaiWeekly = zaiUsage?.weeklyPercent
7185 return ViewModel.statusBarText(
7286 balance: balance,
7387 isLoading: isLoading,
88+ hyperConfigured: hyperConfigured,
7489 claudeFiveHourPercent: fiveHour,
75− claudeSevenDayPercent: sevenDay
90+ claudeSevenDayPercent: sevenDay,
91+ zaiFiveHourPercent: zaiFiveHour,
92+ zaiWeeklyPercent: zaiWeekly,
93+ zaiInPeakHours: ViewModel.zaiInPeakHours
7694 )
7795 }
7896 .removeDuplicates()
UsageMenubar/ViewModel.swift+388−48
@@ -7,11 +7,11 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction the balance has moved over the most recent history points.
14−enum BalanceTrend {
13+/// The direction a metric has moved over the most recent history points.
14+enum MetricTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
2929 }
3030}
3131
32−/// A single balance observation.
33−struct BalancePoint: Equatable {
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
34+struct MetricPoint: Equatable {
3435 let date: Date
35− let balance: Int
36+ let value: Int
3637}
3738
3839/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -80,6 +81,24 @@ final class ViewModel: ObservableObject {
8081 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
8182 @Published var claudePlan: String?
8283
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+
83102 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
84103 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
85104 @Published var apiKeyInput: String = ""
@@ -93,8 +112,20 @@ final class ViewModel: ObservableObject {
93112 /// Brief "✓ Copied" confirmation shown after copying the balance.
94113 @Published var copiedConfirmation: Bool = false
95114
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+
96121 /// Successful balance observations from the last 24 hours, oldest first.
97− @Published private(set) var history: [BalancePoint] = []
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] = []
98129
99130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
100131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -125,8 +156,13 @@ final class ViewModel: ObservableObject {
125156 /// last-updated time and history out from under the user.
126157 private var activeAPIKey: String
127158
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+
128163 private let checker: CreditsChecking
129164 private let claudeChecker: ClaudeUsageChecking
165+ private let zaiChecker: ZaiUsageChecking
130166 private let defaults: UserDefaults
131167
132168 /// The currently in-flight refresh task, if any. Used to cancel
@@ -140,6 +176,12 @@ final class ViewModel: ObservableObject {
140176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
141177 private var savedResetTask: Task<Void, Never>?
142178
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+
143185 // MARK: - History Configuration
144186
145187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -147,17 +189,26 @@ final class ViewModel: ObservableObject {
147189 private static let maxHistoryEntries = 300
148190 private static let historyWindow: TimeInterval = 24 * 60 * 60
149191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
150195 init(
151196 checker: CreditsChecking = CreditsChecker(),
152197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198+ zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
153199 defaults: UserDefaults = .standard
154200 ) {
155201 self.checker = checker
156202 self.claudeChecker = claudeChecker
203+ self.zaiChecker = zaiChecker
157204 self.defaults = defaults
158205 let storedKey = KeychainHelper.load() ?? ""
159206 apiKeyInput = storedKey
160207 activeAPIKey = storedKey
208+ hyperConfigured = !storedKey.isEmpty
209+ let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210+ zaiAPIKeyInput = storedZaiKey
211+ activeZaiAPIKey = storedZaiKey
161212 // Assigning in init doesn't fire didSet, so this load doesn't re-post
162213 // the change notification or write back to defaults.
163214 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -186,11 +237,13 @@ final class ViewModel: ObservableObject {
186237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
187238 }
188239
189− /// The text to show in the menu bar: `⚡{balance}` followed by Claude usage
190− /// windows, each prefixed with an emoji so they are distinguishable at a glance:
191− /// 🕐 for the 5-hour window, 📅 for the 7-day window. Windows that are absent or
192− /// at 0% are omitted; with neither, the bolt stands in alone: `⚡…` while the
193− /// first fetch is in flight, `⚡?` once it has come back empty-handed.
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%🔶`
194247 ///
195248 /// Takes its values as parameters rather than reading the properties: `@Published`
196249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -198,23 +251,54 @@ final class ViewModel: ObservableObject {
198251 static func statusBarText(
199252 balance: Int?,
200253 isLoading: Bool,
254+ hyperConfigured: Bool,
201255 claudeFiveHourPercent: Int?,
202− claudeSevenDayPercent: Int?
256+ claudeSevenDayPercent: Int?,
257+ zaiFiveHourPercent: Int?,
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
203260 ) -> String {
204− var parts: [String] = []
205− if let balance = balance {
206− parts.append("⚡\(formatBalance(balance))")
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)%")
207275 }
208− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
209− parts.append("🕐\(fiveHour)%")
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
210278 }
211− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
212− parts.append("📅\(sevenDay)%")
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)%")
213287 }
214− if parts.isEmpty {
215− return isLoading ? "⚡…" : "⚡?"
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 ? "…" : ""
216300 }
217− return parts.joined(separator: " · ")
301+ return segments.joined(separator: " · ")
218302 }
219303
220304 /// The text to show in the menu bar for the current state.
@@ -222,11 +306,21 @@ final class ViewModel: ObservableObject {
222306 Self.statusBarText(
223307 balance: balance,
224308 isLoading: isLoading,
309+ hyperConfigured: hyperConfigured,
225310 claudeFiveHourPercent: claudeFiveHourPercent,
226− claudeSevenDayPercent: claudeSevenDayPercent
311+ claudeSevenDayPercent: claudeSevenDayPercent,
312+ zaiFiveHourPercent: zaiFiveHourPercent,
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
227315 )
228316 }
229317
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+
230324 /// The formatted balance string for display in the popover.
231325 var formattedBalance: String {
232326 guard let balance = balance else { return "?" }
@@ -247,51 +341,88 @@ final class ViewModel: ObservableObject {
247341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
248342 }
249343
250− // MARK: - History
344+ // MARK: - History (Hyper)
251345
252− /// The last 24 hours of successful fetches, oldest first.
346+ /// The last 24 hours of successful balance fetches, oldest first.
253347 var balanceHistory: [(date: Date, balance: Int)] {
254− history.map { (date: $0.date, balance: $0.balance) }
348+ history.map { (date: $0.date, balance: $0.value) }
255349 }
256350
257351 /// The direction of travel across the most recent handful of observations.
258352 /// `.unknown` until there are at least two points to compare.
259− var balanceTrend: BalanceTrend {
260− guard history.count >= 2 else { return .unknown }
261− let recent = history.suffix(5)
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)
262392 guard let first = recent.first, let last = recent.last else { return .unknown }
263− if last.balance > first.balance { return .increasing }
264− if last.balance < first.balance { return .decreasing }
393+ if last.value > first.value { return .increasing }
394+ if last.value < first.value { return .decreasing }
265395 return .stable
266396 }
267397
268398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
269− private func recordHistory(balance: Int, at date: Date) {
270− history.append(BalancePoint(date: date, balance: balance))
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400+ array.append(MetricPoint(date: date, value: value))
271401
272402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
273− history.removeAll { $0.date < cutoff }
403+ array.removeAll { $0.date < cutoff }
274404
275− if history.count > Self.maxHistoryEntries {
276− history.removeFirst(history.count - Self.maxHistoryEntries)
405+ if array.count > Self.maxHistoryEntries {
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
277407 }
278408 }
279409
280410 // MARK: - Refresh
281411
282− /// Refreshes the Hyper balance and the Claude usage, both at once.
412+ /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
283413 ///
284− /// The two are independent: either can fail, or not be configured at all, without
285− /// touching the other. Cancels any previously in-flight refresh to prevent races.
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.
286416 func refresh() {
287417 refreshTask?.cancel()
288418
289419 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
290420 let key = activeAPIKey
421+ let zaiKey = activeZaiAPIKey
291422 if key.isEmpty {
292423 // Nothing to fetch, and nothing worth keeping: the balance, its history and
293− // any error all belonged to a key that is no longer there. Claude is left
294− // alone — it doesn't depend on the Hyper key.
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.
295426 balance = nil
296427 errorMessage = nil
297428 lastUpdated = nil
@@ -302,22 +433,25 @@ final class ViewModel: ObservableObject {
302433 // Check cancellation before setting isLoading — a previous
303434 // refresh() may have cancelled us and already set isLoading = false.
304435 guard !Task.isCancelled else { return }
305− // Loading state covers both fetches: the popover's loading view depends
306− // on it, and Claude usage is fetched even when there's no Hyper key.
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.
307438 isLoading = true
308439 // Don't clear the errors here — keep showing the last one
309440 // until we have a successful result.
310441
311− // Both requests go out at once; neither waits on the other.
442+ // All three requests go out at once; none waits on the others.
312443 async let balanceOutcome = fetchBalance(key: key)
313444 async let claudeOutcome = fetchClaudeUsage()
445+ async let zaiOutcome = fetchZaiUsage(key: zaiKey)
314446
315447 let balanceResult = await balanceOutcome
316448 let claudeResult = await claudeOutcome
449+ let zaiResult = await zaiOutcome
317450 guard !Task.isCancelled else { return }
318451
319452 applyBalance(balanceResult)
320453 applyClaude(claudeResult)
454+ applyZai(zaiResult)
321455 isLoading = false
322456 }
323457 }
@@ -344,7 +478,7 @@ final class ViewModel: ObservableObject {
344478 errorMessage = nil
345479 let now = Date()
346480 lastUpdated = now
347− recordHistory(balance: newBalance, at: now)
481+ recordMetric(newBalance, into: &history, at: now)
348482
349483 // Low-balance notification: on threshold crossing (≥10 → <10)
350484 // OR on first successful fetch if already below threshold.
@@ -389,12 +523,30 @@ final class ViewModel: ObservableObject {
389523 claudeUsage = nil
390524 claudePlan = nil
391525 claudeError = nil
526+ claudeHistory.removeAll()
392527
393528 case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
394531 claudeUsage = report.usage
395532 claudePlan = report.subscriptionType
396533 claudeError = nil
397534
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+
398550 case .failure(let message):
399551 // As with the balance: keep the last good numbers on screen and say what
400552 // went wrong, rather than blanking the section on a transient failure.
@@ -436,6 +588,88 @@ final class ViewModel: ObservableObject {
436588 }
437589 }
438590
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+
439673 // MARK: - Clipboard
440674
441675 /// Copies the current balance to the clipboard as plain digits (no grouping
@@ -456,7 +690,41 @@ final class ViewModel: ObservableObject {
456690 }
457691 }
458692
459− // MARK: - Low Balance Notification
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
460728
461729 private func sendLowBalanceNotification(balance: Int) {
462730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -485,6 +753,29 @@ final class ViewModel: ObservableObject {
485753 }
486754 }
487755
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+
488779 // MARK: - API Key
489780
490781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -499,6 +790,7 @@ final class ViewModel: ObservableObject {
499790 }
500791 apiKeyInput = ""
501792 activeAPIKey = ""
793+ hyperConfigured = false
502794 // refresh() with an empty key clears balance, error, history, and
503795 // cancels any in-flight task from the previous key.
504796 refresh()
@@ -522,6 +814,7 @@ final class ViewModel: ObservableObject {
522814 // Show the trimmed key that was actually stored.
523815 apiKeyInput = key
524816 activeAPIKey = key
817+ hyperConfigured = true
525818 refresh()
526819 }
527820
@@ -537,6 +830,53 @@ final class ViewModel: ObservableObject {
537830 }
538831 }
539832
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+
540880 // MARK: - Launch at Login
541881
542882 func updateLaunchAtLogin(_ enabled: Bool) {
@@ -556,4 +896,4 @@ final class ViewModel: ObservableObject {
556896 isInitializingLaunchAtLogin = false
557897 }
558898 }
559−}
899+}
UsageMenubar/ZaiUsageClient.swiftnew file+388
@@ -0,0 +1,388 @@
1+import Foundation
2+
3+// MARK: - Usage
4+
5+/// Usage data from z.ai's coding plan API — two endpoints that together give the
6+/// plan level, the 5-hour token quota, and the weekly token quota.
7+///
8+/// Every field in the report is optional. The quota endpoint may return fewer limits
9+/// than expected, and the subscription endpoint may return an empty list, so an
10+/// unrecognized shape degrades to "nothing to show" rather than failing the decode.
11+struct ZaiUsageReport: Equatable {
12+ /// Percent of the 5-hour token window consumed, 0–100.
13+ let fiveHourPercent: Int?
14+ /// Percent of the weekly token window consumed, 0–100.
15+ let weeklyPercent: Int?
16+ /// When the 5-hour window resets, or `nil` when not provided.
17+ let fiveHourResetsAt: Date?
18+ /// When the weekly window resets, or `nil` when not provided.
19+ let weeklyResetsAt: Date?
20+ /// The plan tier from the quota response: `"lite"`, `"pro"`, `"max"`.
21+ let planLevel: String?
22+
23+ /// Whether there is anything at all to render. A report with all-nil fields reads
24+ /// as a section header over nothing, which looks like a bug.
25+ var isEmpty: Bool {
26+ fiveHourPercent == nil && weeklyPercent == nil && planLevel == nil
27+ }
28+}
29+
30+// MARK: - API Response Models
31+
32+/// Response from `GET https://api.z.ai/api/monitor/usage/quota/limit`.
33+struct ZaiQuotaResponse: Decodable, Equatable {
34+ let code: Int
35+ let msg: String?
36+ let data: ZaiQuotaData?
37+ let success: Bool
38+
39+ private enum CodingKeys: String, CodingKey {
40+ case code, msg, data, success
41+ }
42+
43+ init(from decoder: Decoder) throws {
44+ let container = try decoder.container(keyedBy: CodingKeys.self)
45+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
46+ msg = try container.decodeIfPresent(String.self, forKey: .msg)
47+ data = try container.decodeIfPresent(ZaiQuotaData.self, forKey: .data)
48+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
49+ }
50+}
51+
52+struct ZaiQuotaData: Decodable, Equatable {
53+ let limits: [ZaiLimit]?
54+ let level: String?
55+
56+ private enum CodingKeys: String, CodingKey {
57+ case limits, level
58+ }
59+
60+ init(from decoder: Decoder) throws {
61+ let container = try decoder.container(keyedBy: CodingKeys.self)
62+ limits = try container.decodeIfPresent([ZaiLimit].self, forKey: .limits)
63+ level = try container.decodeIfPresent(String.self, forKey: .level)
64+ }
65+}
66+
67+/// One limit entry from the quota response.
68+///
69+/// `unit` identifies the window: 3 = 5-hour, 6 = weekly, 5 = monthly tools.
70+/// `type` is `"TOKENS_LIMIT"` for token quotas or `"TIME_LIMIT"` for tool-call quotas.
71+struct ZaiLimit: Decodable, Equatable {
72+ let type: String
73+ let unit: Int
74+ let percentage: Int
75+ /// Epoch milliseconds, or `nil` when nothing has been used.
76+ let nextResetTime: Int64?
77+
78+ private enum CodingKeys: String, CodingKey {
79+ case type, unit, percentage, nextResetTime
80+ }
81+
82+ init(from decoder: Decoder) throws {
83+ let container = try decoder.container(keyedBy: CodingKeys.self)
84+ type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
85+ unit = try container.decodeIfPresent(Int.self, forKey: .unit) ?? 0
86+ // Decoded as Double and rounded: the field is documented as an integer, but
87+ // a fractional value would throw and take the whole payload down with it.
88+ let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percentage) ?? 0
89+ percentage = Int(rawPercent.rounded())
90+ nextResetTime = try container.decodeIfPresent(Int64.self, forKey: .nextResetTime)
91+ }
92+
93+ init(type: String, unit: Int, percentage: Int, nextResetTime: Int64? = nil) {
94+ self.type = type
95+ self.unit = unit
96+ self.percentage = percentage
97+ self.nextResetTime = nextResetTime
98+ }
99+}
100+
101+/// Response from `GET https://api.z.ai/api/biz/subscription/list`.
102+struct ZaiSubscriptionResponse: Decodable, Equatable {
103+ let code: Int
104+ let data: [ZaiSubscription]?
105+ let success: Bool
106+
107+ private enum CodingKeys: String, CodingKey {
108+ case code, data, success
109+ }
110+
111+ init(from decoder: Decoder) throws {
112+ let container = try decoder.container(keyedBy: CodingKeys.self)
113+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
114+ data = try container.decodeIfPresent([ZaiSubscription].self, forKey: .data)
115+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
116+ }
117+}
118+
119+struct ZaiSubscription: Decodable, Equatable {
120+ let productName: String?
121+ let status: String?
122+
123+ private enum CodingKeys: String, CodingKey {
124+ case productName, status
125+ }
126+
127+ init(from decoder: Decoder) throws {
128+ let container = try decoder.container(keyedBy: CodingKeys.self)
129+ productName = try container.decodeIfPresent(String.self, forKey: .productName)
130+ status = try container.decodeIfPresent(String.self, forKey: .status)
131+ }
132+
133+ init(productName: String? = nil, status: String? = nil) {
134+ self.productName = productName
135+ self.status = status
136+ }
137+}
138+
139+// MARK: - Errors
140+
141+/// Errors that can occur while fetching z.ai usage.
142+enum ZaiUsageError: LocalizedError, Equatable {
143+ /// No API key has been entered. Not an error the user needs to see — the popover
144+ /// just leaves the section out, same as missing Claude credentials.
145+ case noAPIKey
146+
147+ /// The API key was rejected by z.ai.
148+ case invalidAPIKey
149+ case requestFailed(String)
150+ case decodingFailed(String)
151+
152+ var errorDescription: String? {
153+ switch self {
154+ case .noAPIKey:
155+ return nil
156+ case .invalidAPIKey:
157+ return "z.ai API key is invalid. Check your key at z.ai/manage-apikey/coding-plan/personal/my-plan"
158+ case .requestFailed(let message):
159+ return "z.ai request failed: \(message)"
160+ case .decodingFailed(let message):
161+ return "Failed to decode z.ai usage: \(message)"
162+ }
163+ }
164+}
165+
166+// MARK: - Protocol
167+
168+/// Protocol abstraction for fetching z.ai usage, enabling test injection.
169+protocol ZaiUsageChecking {
170+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport
171+}
172+
173+// MARK: - Client
174+
175+/// Client for z.ai's coding plan usage API, authenticating with an API key the user
176+/// enters in settings (stored in Keychain, separate from the Hyper key).
177+///
178+/// Fetches the quota/limit and subscription endpoints concurrently to get both usage
179+/// percentages and the plan tier in one round-trip. Retries transient failures with
180+/// exponential backoff, same pattern as `CreditsChecker` and `ClaudeUsageClient`.
181+actor ZaiUsageClient: ZaiUsageChecking {
182+ static let baseURL = URL(string: "https://api.z.ai/api")!
183+ static let quotaEndpoint = URL(string: "https://api.z.ai/api/monitor/usage/quota/limit")!
184+ static let subscriptionEndpoint = URL(string: "https://api.z.ai/api/biz/subscription/list")!
185+
186+ /// Retries *after* the initial attempt, so at most 3 requests per call.
187+ static let maxRetries = 2
188+
189+ /// URL error codes worth retrying: the request never reached a server that had an
190+ /// opinion about it, so the same request may well succeed a moment later.
191+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
192+ .timedOut,
193+ .cannotConnectToHost,
194+ .cannotFindHost,
195+ .dnsLookupFailed,
196+ .networkConnectionLost,
197+ .notConnectedToInternet,
198+ .resourceUnavailable,
199+ .badServerResponse,
200+ ]
201+
202+ /// Client-error status codes that are nonetheless worth retrying: both are the
203+ /// server telling us to come back later, not that the request itself is wrong.
204+ private static let retryableStatusCodes: Set<Int> = [
205+ 408, // Request Timeout
206+ 429, // Too Many Requests
207+ ]
208+
209+ private let session: URLSession
210+ private let retryBaseDelay: Duration
211+
212+ /// - Parameters:
213+ /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
214+ /// one (2s, then 4s). Injectable so tests need not wait in real time.
215+ init(
216+ session: URLSession? = nil,
217+ retryBaseDelay: Duration = .seconds(2)
218+ ) {
219+ self.retryBaseDelay = retryBaseDelay
220+ if let session {
221+ self.session = session
222+ } else {
223+ let config = URLSessionConfiguration.default
224+ config.timeoutIntervalForRequest = 10
225+ config.timeoutIntervalForResource = 15
226+ self.session = URLSession(configuration: config)
227+ }
228+ }
229+
230+ /// Fetches current usage with the given API key.
231+ ///
232+ /// The quota and subscription endpoints are fetched concurrently; neither waits on
233+ /// the other. The subscription endpoint is best-effort — if it fails, the plan
234+/// level from the quota response (`data.level`) is used instead.
235+ ///
236+ /// - Throws: `ZaiUsageError`. `.noAPIKey` means no key was provided, which callers
237+ /// should treat as "nothing to show" rather than as a failure.
238+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport {
239+ guard !apiKey.isEmpty else { throw ZaiUsageError.noAPIKey }
240+
241+ async let quotaOutcome = fetchQuota(apiKey: apiKey)
242+ async let subscriptionOutcome = fetchSubscription(apiKey: apiKey)
243+
244+ let quota = try await quotaOutcome
245+ let subscription = try? await subscriptionOutcome
246+
247+ // Extract percentages from the limits array.
248+ var fiveHourPercent: Int?
249+ var weeklyPercent: Int?
250+ var fiveHourResetsAt: Date?
251+ var weeklyResetsAt: Date?
252+
253+ for limit in quota?.limits ?? [] {
254+ switch limit.unit {
255+ case 3: // 5-hour window
256+ fiveHourPercent = limit.percentage
257+ fiveHourResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
258+ case 6: // weekly window
259+ weeklyPercent = limit.percentage
260+ weeklyResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
261+ default:
262+ break
263+ }
264+ }
265+
266+ // Plan level: prefer the product name from the subscription, fall back to the
267+ // quota response's `level` field.
268+ var planLevel: String? = quota?.level
269+ if let sub = subscription?.data?.first(where: { $0.status == "VALID" }),
270+ let name = sub.productName {
271+ planLevel = name
272+ }
273+
274+ return ZaiUsageReport(
275+ fiveHourPercent: fiveHourPercent,
276+ weeklyPercent: weeklyPercent,
277+ fiveHourResetsAt: fiveHourResetsAt,
278+ weeklyResetsAt: weeklyResetsAt,
279+ planLevel: planLevel
280+ )
281+ }
282+
283+ // MARK: - Quota Request
284+
285+ private func fetchQuota(apiKey: String) async throws -> ZaiQuotaData? {
286+ var request = URLRequest(url: Self.quotaEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
287+ request.httpMethod = "GET"
288+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
289+ request.setValue("application/json", forHTTPHeaderField: "Accept")
290+
291+ let data = try await send(request)
292+
293+ do {
294+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: data)
295+ guard response.success else {
296+ throw ZaiUsageError.requestFailed(response.msg ?? "Unknown error")
297+ }
298+ return response.data
299+ } catch let error as ZaiUsageError {
300+ throw error
301+ } catch {
302+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
303+ }
304+ }
305+
306+ // MARK: - Subscription Request
307+
308+ private func fetchSubscription(apiKey: String) async throws -> ZaiSubscriptionResponse {
309+ var request = URLRequest(url: Self.subscriptionEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
310+ request.httpMethod = "GET"
311+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
312+ request.setValue("application/json", forHTTPHeaderField: "Accept")
313+
314+ let data = try await send(request)
315+
316+ do {
317+ return try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: data)
318+ } catch {
319+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
320+ }
321+ }
322+
323+ // MARK: - Transport
324+
325+ /// Sends a request, retrying transient failures with exponential backoff.
326+ private func send(_ request: URLRequest) async throws -> Data {
327+ var attempt = 0
328+
329+ while true {
330+ do {
331+ return try await performAttempt(request)
332+ } catch let failure as AttemptFailure {
333+ guard failure.isRetryable, attempt < Self.maxRetries else {
334+ throw failure.underlying
335+ }
336+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
337+ attempt += 1
338+ }
339+ }
340+ }
341+
342+ /// Performs a single request, tagging the failure with whether a retry could
343+ /// plausibly help.
344+ private func performAttempt(_ request: URLRequest) async throws -> Data {
345+ let data: Data
346+ let response: URLResponse
347+ do {
348+ (data, response) = try await session.data(for: request)
349+ } catch let urlError as URLError {
350+ throw AttemptFailure(
351+ underlying: .requestFailed(urlError.localizedDescription),
352+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
353+ )
354+ } catch {
355+ throw AttemptFailure(
356+ underlying: .requestFailed(error.localizedDescription),
357+ isRetryable: false
358+ )
359+ }
360+
361+ guard let httpResponse = response as? HTTPURLResponse else {
362+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
363+ }
364+
365+ switch httpResponse.statusCode {
366+ case 200...299:
367+ return data
368+ case 401:
369+ throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
370+ case 500...599:
371+ throw AttemptFailure(
372+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
373+ isRetryable: true
374+ )
375+ default:
376+ throw AttemptFailure(
377+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
378+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
379+ )
380+ }
381+ }
382+
383+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
384+ private struct AttemptFailure: Error {
385+ let underlying: ZaiUsageError
386+ let isRetryable: Bool
387+ }
388+}
UsageMenubarTests/CreditsCheckerTests.swift+19
@@ -519,10 +519,22 @@ final class StubURLProtocol: URLProtocol {
519519 Self.outcomes.append(contentsOf: outcomes)
520520 }
521521
522+ /// Queues an outcome for a specific URL path. URL-matched outcomes are consumed
523+ /// before the FIFO queue, so concurrent requests to different endpoints each get
524+ /// the right response regardless of arrival order.
525+ static func enqueue(_ outcome: Outcome, forURLPath path: String) {
526+ lock.lock()
527+ defer { lock.unlock() }
528+ urlMatchedOutcomes[path] = outcome
529+ }
530+
531+ private static var urlMatchedOutcomes: [String: Outcome] = [:]
532+
522533 static func reset() {
523534 lock.lock()
524535 defer { lock.unlock() }
525536 outcomes = []
537+ urlMatchedOutcomes = [:]
526538 lastOutcome = nil
527539 requests = []
528540 bodies = []
@@ -548,12 +560,19 @@ final class StubURLProtocol: URLProtocol {
548560 }
549561
550562 /// Records the request and pops the outcome that should answer it.
563+ /// URL-matched outcomes take priority over the FIFO queue, so concurrent
564+ /// requests to different endpoints each get the right response.
551565 private static func nextOutcome(for request: URLRequest) -> Outcome {
552566 lock.lock()
553567 defer { lock.unlock() }
554568 requests.append(request)
555569 bodies.append(drainBody(from: request))
556570
571+ if let path = request.url?.path,
572+ let matched = urlMatchedOutcomes[path] {
573+ return matched
574+ }
575+
557576 if !outcomes.isEmpty {
558577 lastOutcome = outcomes.removeFirst()
559578 }
UsageMenubarTests/StatusBarTextTests.swift+244−24
@@ -13,15 +13,21 @@ final class StatusBarTextTests: XCTestCase {
1313 func testBothServices() {
1414 let title = ViewModel.statusBarText(
1515 balance: 42, isLoading: false,
16− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
16+ hyperConfigured: true,
17+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19+ zaiInPeakHours: false
1720 )
18− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
1922 }
2023
2124 func testHyperOnly() {
2225 let title = ViewModel.statusBarText(
2326 balance: 42, isLoading: false,
24− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
27+ hyperConfigured: true,
28+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30+ zaiInPeakHours: false
2531 )
2632 XCTAssertEqual(title, "⚡42")
2733 }
@@ -29,76 +35,290 @@ final class StatusBarTextTests: XCTestCase {
2935 func testClaudeOnly() {
3036 let title = ViewModel.statusBarText(
3137 balance: nil, isLoading: false,
32− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
38+ hyperConfigured: false,
39+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41+ zaiInPeakHours: false
3342 )
34− XCTAssertEqual(title, "🕐62% · 📅8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
3544 }
3645
3746 func testLoadingWithNothingToShow() {
3847 let title = ViewModel.statusBarText(
3948 balance: nil, isLoading: true,
40− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
49+ hyperConfigured: false,
50+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52+ zaiInPeakHours: false
4153 )
42− XCTAssertEqual(title, "⚡…")
54+ XCTAssertEqual(title, "…")
4355 }
4456
4557 func testNothingToShow() {
4658 let title = ViewModel.statusBarText(
4759 balance: nil, isLoading: false,
48− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
60+ hyperConfigured: false,
61+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63+ zaiInPeakHours: false
4964 )
50− XCTAssertEqual(title, "⚡?")
65+ XCTAssertEqual(title, "")
5166 }
5267
5368 /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
54− /// without a Hyper key refreshes every few minutes, and a title that fell back to `⚡…`
69+ /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
5570 /// each time would blink their percentage away and back.
5671 func testLoadingKeepsAClaudePercentOnScreen() {
5772 let title = ViewModel.statusBarText(
5873 balance: nil, isLoading: true,
59− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
74+ hyperConfigured: false,
75+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77+ zaiInPeakHours: false
6078 )
61− XCTAssertEqual(title, "🕐62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
6280 }
6381
6482 func testLoadingKeepsAStaleBalanceOnScreen() {
6583 let title = ViewModel.statusBarText(
6684 balance: 42, isLoading: true,
67− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
85+ hyperConfigured: true,
86+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88+ zaiInPeakHours: false
6889 )
69− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
7091 }
7192
72− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
7394 let title = ViewModel.statusBarText(
7495 balance: 42, isLoading: false,
75− claudeFiveHourPercent: 0, claudeSevenDayPercent: 8
96+ hyperConfigured: true,
97+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99+ zaiInPeakHours: false
76100 )
77− XCTAssertEqual(title, "⚡42 · 📅8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
78102 }
79103
80104 func testBothClaudeWindowsZero() {
81105 let title = ViewModel.statusBarText(
82106 balance: 42, isLoading: false,
83− claudeFiveHourPercent: 0, claudeSevenDayPercent: 0
107+ hyperConfigured: true,
108+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiInPeakHours: false
84111 )
85− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
86113 }
87114
88115 func testFiveHourOnly() {
89116 let title = ViewModel.statusBarText(
90117 balance: nil, isLoading: false,
91− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
118+ hyperConfigured: false,
119+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121+ zaiInPeakHours: false
92122 )
93− XCTAssertEqual(title, "🕐62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
94124 }
95125
96126 func testSevenDayOnly() {
97127 let title = ViewModel.statusBarText(
98128 balance: nil, isLoading: false,
99− claudeFiveHourPercent: nil, claudeSevenDayPercent: 8
129+ hyperConfigured: false,
130+ claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
131+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132+ zaiInPeakHours: false
133+ )
134+ XCTAssertEqual(title, "\u{2055}8%")
135+ }
136+
137+ // MARK: - Hyper not configured
138+
139+ func testHyperNotConfiguredHidesBoltEvenWithBalance() {
140+ // balance is non-nil but Hyper is not configured — should not appear.
141+ // This can happen briefly during the transition when a key is deleted.
142+ let title = ViewModel.statusBarText(
143+ balance: 42, isLoading: false,
144+ hyperConfigured: false,
145+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
146+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147+ zaiInPeakHours: false
148+ )
149+ XCTAssertEqual(title, "")
150+ }
151+
152+ func testHyperNotConfiguredLoadingShowsJustDots() {
153+ let title = ViewModel.statusBarText(
154+ balance: nil, isLoading: true,
155+ hyperConfigured: false,
156+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158+ zaiInPeakHours: false
159+ )
160+ XCTAssertEqual(title, "…")
161+ }
162+
163+ func testHyperNotConfiguredButClaudeStillShows() {
164+ let title = ViewModel.statusBarText(
165+ balance: nil, isLoading: false,
166+ hyperConfigured: false,
167+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
168+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169+ zaiInPeakHours: false
170+ )
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
172+ }
173+
174+ // MARK: - z.ai in Title
175+
176+ func testZaiOnly() {
177+ let title = ViewModel.statusBarText(
178+ balance: nil, isLoading: false,
179+ hyperConfigured: false,
180+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
181+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182+ zaiInPeakHours: false
183+ )
184+ XCTAssertEqual(title, "\u{005A}12%")
185+ }
186+
187+ func testZaiWithHyper() {
188+ let title = ViewModel.statusBarText(
189+ balance: 42, isLoading: false,
190+ hyperConfigured: true,
191+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
192+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193+ zaiInPeakHours: false
194+ )
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
196+ }
197+
198+ func testZaiWithClaude() {
199+ let title = ViewModel.statusBarText(
200+ balance: nil, isLoading: false,
201+ hyperConfigured: false,
202+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
203+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204+ zaiInPeakHours: false
205+ )
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
207+ }
208+
209+ func testAllThreeServices() {
210+ let title = ViewModel.statusBarText(
211+ balance: 42, isLoading: false,
212+ hyperConfigured: true,
213+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
214+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215+ zaiInPeakHours: false
216+ )
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
218+ }
219+
220+ func testZaiZeroIsShown() {
221+ let title = ViewModel.statusBarText(
222+ balance: 42, isLoading: false,
223+ hyperConfigured: true,
224+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
225+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226+ zaiInPeakHours: false
227+ )
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
229+ }
230+
231+ func testLoadingKeepsZaiPercentOnScreen() {
232+ let title = ViewModel.statusBarText(
233+ balance: nil, isLoading: true,
234+ hyperConfigured: false,
235+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
236+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237+ zaiInPeakHours: false
238+ )
239+ XCTAssertEqual(title, "\u{005A}12%")
240+ }
241+
242+ // MARK: - z.ai Weekly
243+
244+ func testZaiWeeklyOnly() {
245+ let title = ViewModel.statusBarText(
246+ balance: nil, isLoading: false,
247+ hyperConfigured: false,
248+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250+ zaiInPeakHours: false
251+ )
252+ XCTAssertEqual(title, "\u{005A}3%")
253+ }
254+
255+ func testZaiWeeklyWithFiveHour() {
256+ let title = ViewModel.statusBarText(
257+ balance: nil, isLoading: false,
258+ hyperConfigured: false,
259+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
260+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261+ zaiInPeakHours: false
262+ )
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
264+ }
265+
266+ func testZaiWeeklyZeroIsShown() {
267+ let title = ViewModel.statusBarText(
268+ balance: nil, isLoading: false,
269+ hyperConfigured: false,
270+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
271+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272+ zaiInPeakHours: false
273+ )
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
275+ }
276+
277+ func testLoadingKeepsZaiWeeklyOnScreen() {
278+ let title = ViewModel.statusBarText(
279+ balance: nil, isLoading: true,
280+ hyperConfigured: false,
281+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
282+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283+ zaiInPeakHours: false
284+ )
285+ XCTAssertEqual(title, "\u{005A}3%")
286+ }
287+
288+ // MARK: - z.ai Peak Hours
289+
290+ func testPeakHoursAddsDiamondToZaiSegment() {
291+ let title = ViewModel.statusBarText(
292+ balance: 42, isLoading: false,
293+ hyperConfigured: true,
294+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
295+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296+ zaiInPeakHours: true
297+ )
298+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%\u{1F536}")
299+ }
300+
301+ func testPeakHoursWithZaiOnly() {
302+ let title = ViewModel.statusBarText(
303+ balance: nil, isLoading: false,
304+ hyperConfigured: false,
305+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
306+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307+ zaiInPeakHours: true
308+ )
309+ XCTAssertEqual(title, "\u{005A}12%\u{1F536}")
310+ }
311+
312+ func testPeakHoursIgnoredWhenZaiNotConfigured() {
313+ let title = ViewModel.statusBarText(
314+ balance: 42, isLoading: false,
315+ hyperConfigured: true,
316+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
317+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
318+ zaiInPeakHours: true
100319 )
101− XCTAssertEqual(title, "📅8%")
320+ // No z.ai segment, so peak marker has nothing to attach to.
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
102322 }
103323
104324 // MARK: - Claude Window Percentages
@@ -155,4 +375,4 @@ final class StatusBarTextTests: XCTestCase {
155375 XCTAssertNil(vm.claudeFiveHourPercent)
156376 XCTAssertNil(vm.claudeSevenDayPercent)
157377 }
158−}
378+}
UsageMenubarTests/ZaiUsageClientTests.swiftnew file+237
@@ -0,0 +1,237 @@
1+import XCTest
2+@testable import UsageMenubar
3+
4+@MainActor
5+final class ZaiUsageClientTests: XCTestCase {
6+
7+ override func setUp() {
8+ super.setUp()
9+ StubURLProtocol.reset()
10+ }
11+
12+ override func tearDown() {
13+ StubURLProtocol.reset()
14+ super.tearDown()
15+ }
16+
17+ /// A client whose requests are served by `StubURLProtocol`, with a backoff short
18+ /// enough that the retry tests run in milliseconds rather than seconds.
19+ private func makeStubbedClient(retryBaseDelay: Duration = .milliseconds(1)) -> ZaiUsageClient {
20+ let config = URLSessionConfiguration.ephemeral
21+ config.protocolClasses = [StubURLProtocol.self]
22+ return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
23+ }
24+
25+ /// Enqueues quota and subscription responses matched to their URL paths, so the
26+ /// order of arrival doesn't matter — each endpoint gets its own response.
27+ private func enqueueQuotaAndSubscription(
28+ quotaStatus: Int = 200,
29+ quotaJSON: Data,
30+ subscriptionStatus: Int = 200,
31+ subscriptionJSON: Data
32+ ) {
33+ StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
34+ StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
35+ }
36+
37+ // MARK: - Decoding
38+
39+ func testDecodeQuotaResponse() throws {
40+ let json = """
41+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
42+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
43+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
44+ ],"level":"max"}}
45+ """.data(using: .utf8)!
46+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
47+ XCTAssertTrue(response.success)
48+ XCTAssertEqual(response.code, 200)
49+ XCTAssertEqual(response.data?.level, "max")
50+ XCTAssertEqual(response.data?.limits?.count, 2)
51+ XCTAssertEqual(response.data?.limits?.first?.percentage, 12)
52+ XCTAssertEqual(response.data?.limits?.first?.unit, 3)
53+ }
54+
55+ func testDecodeQuotaResponseWithFractionalPercentage() throws {
56+ let json = """
57+ {"code":200,"success":true,"data":{"limits":[
58+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12.6,"nextResetTime":1784278018952}
59+ ],"level":"pro"}}
60+ """.data(using: .utf8)!
61+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
62+ XCTAssertEqual(response.data?.limits?.first?.percentage, 13)
63+ }
64+
65+ func testDecodeQuotaResponseWithMissingFields() throws {
66+ let json = """
67+ {"code":200,"success":true,"data":{"limits":[],"level":null}}
68+ """.data(using: .utf8)!
69+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
70+ XCTAssertEqual(response.data?.limits?.count, 0)
71+ XCTAssertNil(response.data?.level)
72+ }
73+
74+ func testDecodeSubscriptionResponse() throws {
75+ let json = """
76+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
77+ """.data(using: .utf8)!
78+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
79+ XCTAssertEqual(response.data?.first?.productName, "GLM Coding Max")
80+ XCTAssertEqual(response.data?.first?.status, "VALID")
81+ }
82+
83+ func testDecodeEmptySubscriptionResponse() throws {
84+ let json = """
85+ {"code":200,"data":[],"success":true}
86+ """.data(using: .utf8)!
87+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
88+ XCTAssertEqual(response.data?.count, 0)
89+ }
90+
91+ // MARK: - Fetch
92+
93+ func testFetchUsageSuccess() async throws {
94+ let quotaJSON = """
95+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
96+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
97+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
98+ ],"level":"max"}}
99+ """.data(using: .utf8)!
100+ let subscriptionJSON = """
101+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
102+ """.data(using: .utf8)!
103+
104+ enqueueQuotaAndSubscription(quotaJSON: quotaJSON, subscriptionJSON: subscriptionJSON)
105+
106+ let client = makeStubbedClient()
107+ let report = try await client.fetchUsage(apiKey: "test-key")
108+
109+ XCTAssertEqual(report.fiveHourPercent, 12)
110+ XCTAssertEqual(report.weeklyPercent, 3)
111+ XCTAssertNotNil(report.fiveHourResetsAt)
112+ XCTAssertNotNil(report.weeklyResetsAt)
113+ }
114+
115+ func testFetchUsageNoKey() async {
116+ let client = makeStubbedClient()
117+ do {
118+ _ = try await client.fetchUsage(apiKey: "")
119+ XCTFail("Expected noAPIKey error")
120+ } catch ZaiUsageError.noAPIKey {
121+ // Expected
122+ } catch {
123+ XCTFail("Unexpected error: \(error)")
124+ }
125+
126+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
127+ }
128+
129+ func testFetchUsageInvalidKey() async {
130+ let errorJSON = """
131+ {"code":401,"msg":"token expired or incorrect","success":false}
132+ """.data(using: .utf8)!
133+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
134+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
135+
136+ let client = makeStubbedClient()
137+ do {
138+ _ = try await client.fetchUsage(apiKey: "bad-key")
139+ XCTFail("Expected invalidAPIKey error")
140+ } catch ZaiUsageError.invalidAPIKey {
141+ // Expected
142+ } catch {
143+ XCTFail("Unexpected error: \(error)")
144+ }
145+ }
146+
147+ // MARK: - Retry
148+
149+ func testRetriesOnServerErrorThenSucceeds() async throws {
150+ let quotaJSON = """
151+ {"code":200,"success":true,"data":{"limits":[
152+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
153+ ],"level":"max"}}
154+ """.data(using: .utf8)!
155+ let subscriptionJSON = """
156+ {"code":200,"data":[],"success":true}
157+ """.data(using: .utf8)!
158+
159+ // Subscription succeeds on first try. Quota fails with 500, then succeeds.
160+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
161+ StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
162+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
163+
164+ let client = makeStubbedClient()
165+ let report = try await client.fetchUsage(apiKey: "test-key")
166+ XCTAssertEqual(report.fiveHourPercent, 12)
167+ }
168+
169+ func testRetriesOnRateLimitThenSucceeds() async throws {
170+ let quotaJSON = """
171+ {"code":200,"success":true,"data":{"limits":[
172+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
173+ ],"level":"max"}}
174+ """.data(using: .utf8)!
175+ let subscriptionJSON = """
176+ {"code":200,"data":[],"success":true}
177+ """.data(using: .utf8)!
178+
179+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
180+ StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
181+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
182+
183+ let client = makeStubbedClient()
184+ let report = try await client.fetchUsage(apiKey: "test-key")
185+ XCTAssertEqual(report.fiveHourPercent, 12)
186+ }
187+
188+ func testDoesNotRetryOn401() async {
189+ let errorJSON = """
190+ {"code":401,"msg":"token expired or incorrect","success":false}
191+ """.data(using: .utf8)!
192+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
193+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
194+
195+ let client = makeStubbedClient()
196+ do {
197+ _ = try await client.fetchUsage(apiKey: "bad-key")
198+ XCTFail("Expected invalidAPIKey error")
199+ } catch ZaiUsageError.invalidAPIKey {
200+ // Expected — should not retry
201+ } catch {
202+ XCTFail("Unexpected error: \(error)")
203+ }
204+ }
205+
206+ // MARK: - ZaiUsageReport
207+
208+ func testEmptyReportIsEmpty() {
209+ let report = ZaiUsageReport(
210+ fiveHourPercent: nil,
211+ weeklyPercent: nil,
212+ fiveHourResetsAt: nil,
213+ weeklyResetsAt: nil,
214+ planLevel: nil
215+ )
216+ XCTAssertTrue(report.isEmpty)
217+ }
218+
219+ func testNonEmptyReportIsNotEmpty() {
220+ let report = ZaiUsageReport(
221+ fiveHourPercent: 12,
222+ weeklyPercent: nil,
223+ fiveHourResetsAt: nil,
224+ weeklyResetsAt: nil,
225+ planLevel: nil
226+ )
227+ XCTAssertFalse(report.isEmpty)
228+ }
229+
230+ // MARK: - Protocol conformance
231+
232+ func testZaiUsageClientConformsToProtocol() {
233+ let checker: ZaiUsageChecking = ZaiUsageClient()
234+ // If this compiles, ZaiUsageClient conforms to ZaiUsageChecking.
235+ XCTAssertNotNil(checker)
236+ }
237+}
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"