on 4cb1f63 help sign in
31 files changed+1328−2666
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+24−24 CLAUDE.md+22−62 UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.json HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json+20 UsageMenubar/Assets.xcassets/Contents.json → HyperCreditsMenubar/Assets.xcassets/Contents.json UsageMenubar/ClaudeUsageClient.swift → HyperCreditsMenubar/ClaudeUsageClient.swift+307−56 UsageMenubar/CreditsChecker.swift → HyperCreditsMenubar/CreditsChecker.swift UsageMenubar/UsageMenubarApp.swift → HyperCreditsMenubar/HyperCreditsApp.swift+14−47 UsageMenubar/UsageMenubar.entitlements → HyperCreditsMenubar/HyperCreditsMenubar.entitlements UsageMenubar/Info.plist → HyperCreditsMenubar/Info.plist+3−3 UsageMenubar/KeychainHelper.swift → HyperCreditsMenubar/KeychainHelper.swift+1−29 HyperCreditsMenubar/MenuView.swift+526 UsageMenubar/VersionFormatter.swift → HyperCreditsMenubar/VersionFormatter.swift UsageMenubar/ViewModel.swift → HyperCreditsMenubar/ViewModel.swift+46−425 UsageMenubarTests/ClaudeUsageClientTests.swift → HyperCreditsMenubarTests/ClaudeUsageClientTests.swift+273−78 UsageMenubarTests/CreditsCheckerTests.swift → HyperCreditsMenubarTests/CreditsCheckerTests.swift+1−20 UsageMenubarTests/VersionFormatterTests.swift → HyperCreditsMenubarTests/VersionFormatterTests.swift+1−1 README.md+65−85 UsageMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json−68 UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-128.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-16.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-256.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-32.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-512.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-64.png UsageMenubar/MenuView.swift−741 UsageMenubar/ZaiUsageClient.swift−388 UsageMenubarTests/StatusBarTextTests.swift−378 UsageMenubarTests/ZaiUsageClientTests.swift−237 project.yml+25−24
.github/workflows/release.yml+24−24
@@ -74,7 +74,7 @@ jobs:
7474
7575 props = (
7676 doc.setdefault("targets", {})
77− .setdefault("UsageMenubar", {})
77+ .setdefault("HyperCreditsMenubar", {})
7878 .setdefault("info", {})
7979 .setdefault("properties", {})
8080 )
@@ -116,7 +116,7 @@ jobs:
116116 - name: Run tests
117117 run: |
118118 xcodebuild test \
119− -scheme UsageMenubar \
119+ -scheme HyperCreditsMenubar \
120120 -configuration Debug \
121121 -destination "platform=macOS" \
122122 CODE_SIGNING_ALLOWED=NO \
@@ -128,9 +128,9 @@ jobs:
128128 - name: Archive
129129 run: |
130130 xcodebuild archive \
131− -scheme UsageMenubar \
131+ -scheme HyperCreditsMenubar \
132132 -configuration Release \
133− -archivePath build/UsageMenubar.xcarchive \
133+ -archivePath build/HyperCreditsMenubar.xcarchive \
134134 -destination "generic/platform=macOS" \
135135 CODE_SIGNING_ALLOWED=NO \
136136 CODE_SIGNING_REQUIRED=NO \
@@ -143,8 +143,8 @@ jobs:
143143 run: |
144144 mkdir -p build/export
145145 # Simply copy the .app from the archive (no signing during the build)
146− cp -R "build/UsageMenubar.xcarchive/Products/Applications/UsageMenubar.app" \
147− "build/export/UsageMenubar.app"
146+ cp -R "build/HyperCreditsMenubar.xcarchive/Products/Applications/HyperCreditsMenubar.app" \
147+ "build/export/HyperCreditsMenubar.app"
148148
149149 # ── Ad-hoc code signing ───────────────────────────────────────────
150150 # `-s -` is an ad-hoc signature: no certificate, no Apple identity.
@@ -156,7 +156,7 @@ jobs:
156156 - name: Ad-hoc code sign the app
157157 run: |
158158 set -eo pipefail
159− APP="build/export/UsageMenubar.app"
159+ APP="build/export/HyperCreditsMenubar.app"
160160 codesign --force --deep -s - "$APP"
161161 echo "── Verifying signature ──"
162162 codesign --verify --deep --strict --verbose=2 "$APP"
@@ -170,7 +170,7 @@ jobs:
170170 run: |
171171 set -eo pipefail
172172 cd build/export
173− ditto -c -k --keepParent UsageMenubar.app "UsageMenubar-${VERSION}.zip"
173+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${VERSION}.zip"
174174
175175 # ── Detect notarization credentials ───────────────────────────────
176176 # Secrets are checked in a shell step (not a step-level `if:`) so the
@@ -223,7 +223,7 @@ jobs:
223223 chmod 600 "$KEY_FILE"
224224
225225 echo "── Submitting to Apple notary service ──"
226− xcrun notarytool submit "build/export/UsageMenubar-${VERSION}.zip" \
226+ xcrun notarytool submit "build/export/HyperCreditsMenubar-${VERSION}.zip" \
227227 --key "$KEY_FILE" \
228228 --key-id "$NOTARY_API_KEY_ID" \
229229 --issuer "$NOTARY_API_KEY_ISSUER" \
@@ -231,8 +231,8 @@ jobs:
231231 --timeout 30m
232232
233233 echo "── Stapling ticket to the app ──"
234− xcrun stapler staple "build/export/UsageMenubar.app"
235− xcrun stapler validate "build/export/UsageMenubar.app"
234+ xcrun stapler staple "build/export/HyperCreditsMenubar.app"
235+ xcrun stapler validate "build/export/HyperCreditsMenubar.app"
236236
237237 # ── Package release artifacts ─────────────────────────────────────
238238 # Re-zip from the .app so the published archive picks up a stapled
@@ -241,19 +241,19 @@ jobs:
241241 run: |
242242 set -eo pipefail
243243 cd build/export
244− rm -f "UsageMenubar-${VERSION}.zip"
245− ditto -c -k --keepParent UsageMenubar.app "UsageMenubar-${VERSION}.zip"
244+ rm -f "HyperCreditsMenubar-${VERSION}.zip"
245+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${VERSION}.zip"
246246 # Also create a stable-named zip for the Homebrew Cask
247− cp "UsageMenubar-${VERSION}.zip" "UsageMenubar-latest.zip"
248− cp "UsageMenubar-${VERSION}.zip" ../../
249− cp "UsageMenubar-latest.zip" ../../
247+ cp "HyperCreditsMenubar-${VERSION}.zip" "HyperCreditsMenubar-latest.zip"
248+ cp "HyperCreditsMenubar-${VERSION}.zip" ../../
249+ cp "HyperCreditsMenubar-latest.zip" ../../
250250
251251 # ── Upload build artifact ─────────────────────────────────────────
252252 - name: Upload build artifact
253253 uses: actions/upload-artifact@v4
254254 with:
255− name: UsageMenubar-${{ env.VERSION }}
256− path: UsageMenubar-${{ env.VERSION }}.zip
255+ name: HyperCreditsMenubar-${{ env.VERSION }}
256+ path: HyperCreditsMenubar-${{ env.VERSION }}.zip
257257 retention-days: 30
258258
259259 # ── Create GitHub Release ─────────────────────────────────────────
@@ -263,13 +263,13 @@ jobs:
263263 tag_name: ${{ env.VERSION }}
264264 name: ${{ env.VERSION }}
265265 body: |
266− ## UsageMenubar ${{ env.VERSION }}
266+ ## HyperCreditsMenubar ${{ env.VERSION }}
267267
268− **Download:** `UsageMenubar-${{ env.VERSION }}.zip`
268+ **Download:** `HyperCreditsMenubar-${{ env.VERSION }}.zip`
269269
270270 ### Installation
271− 1. Download and unzip `UsageMenubar-${{ env.VERSION }}.zip`
272− 2. Move `UsageMenubar.app` to your `/Applications` folder
271+ 1. Download and unzip `HyperCreditsMenubar-${{ env.VERSION }}.zip`
272+ 2. Move `HyperCreditsMenubar.app` to your `/Applications` folder
273273 3. Launch the app — it will appear in your menu bar as ⚡
274274 4. Click the ⚡ icon, enter your Hyper API key, and click Save
275275
@@ -280,7 +280,7 @@ jobs:
280280 ### Changes
281281 Built from commit ${{ github.sha }}
282282 files: |
283− UsageMenubar-${{ env.VERSION }}.zip
284− UsageMenubar-latest.zip
283+ HyperCreditsMenubar-${{ env.VERSION }}.zip
284+ HyperCreditsMenubar-latest.zip
285285 draft: false
286286 prerelease: false
CLAUDE.md+22−62
@@ -1,68 +1,28 @@
1−# UsageMenubar
1+# HyperCreditsMenubar
22
33## Architecture
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−
14−- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
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)
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.
6+- `HyperCreditsApp.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)
179- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
18−- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
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)
10+- `ClaudeUsageClient.swift` — Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
11+- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper)
2112- `VersionFormatter.swift` — Version string formatting
2213
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`).
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.
4817
49−## Claude credentials (strictly read-only)
18+## Claude credentials (read-only, with one exception)
5019`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
5120Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
52−- **Never write them. Never refresh the token.** `ClaudeCredentialStoring` has no `save`,
53− and that is load-bearing, not an oversight. The app reads the current access token and
54− spends it as-is.
55−- Why: the token endpoint **rotates the refresh token**, and the CLI refreshes on its own
56− schedule. An app that also refreshed would race it — whoever went second would present a
57− refresh token the server had already retired, signing the user out of their own terminal.
58− This app *did* do that, and it was the top bug. Do not reintroduce it, however tempting a
59− "just refresh it for the user" convenience looks.
60−- A spent or revoked token (401/403) is therefore terminal, not retried: it surfaces as
61− `.invalidCredentials`, whose message tells the user to run `claude`. The CLI mints a new
62− token; the next fetch (every 1–5 min, credentials re-read each time) picks it up for free.
63−- Consequently `ClaudeOAuthCredentials` models neither `refreshToken` nor `expiresAt` —
64− nothing here has any business knowing them.
65−- Retries still apply to *transport* failures (timeouts, 5xx, 429). That's not auth.
21+- Never modify them, except the token refresh, which writes back a rotated token to
22+ whichever source it was read from, preserving fields we don't model (patched raw JSON).
23+- The refresh endpoint **rotates the refresh token** — refreshing twice with the same one
24+ signs the user out of their own CLI. Hence the client is an `actor` and dedups
25+ concurrent refreshes onto one in-flight, cancellation-immune `Task`.
6626- Missing credentials are **not an error** — the popover just omits the section.
6727- **The app cannot be sandboxed**: spawning `security` and reading `~/.claude` require it
6828 to be off. Distribution is ad-hoc signed via Homebrew, not the App Store.
@@ -70,14 +30,14 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
7030## Build
7131- Uses [xcodegen](https://github.com/yonaskolb/XcodeGen) — `project.yml` is source of truth
7232- `xcodegen generate` creates the .xcodeproj (not committed)
73−- Build: `xcodebuild -scheme UsageMenubar -configuration Debug build`
74−- Test: `xcodebuild test -scheme UsageMenubar -configuration Debug -destination "platform=macOS"`
33+- Build: `xcodebuild -scheme HyperCreditsMenubar -configuration Debug build`
34+- Test: `xcodebuild test -scheme HyperCreditsMenubar -configuration Debug -destination "platform=macOS"`
7535- Swift 5.9, macOS 14.0 deployment target
7636
7737## Conventions
78−- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
38+- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
7939- No card-heavy/multi-material/gradient-rich designs
80−- Rounded font family at one scale (subhero/section/control/caption/footnote)
40+- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
8141- Monospaced digits for numbers that change
8242- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
8343 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -85,4 +45,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
8545
8646## VCS
8747- jj colocated with git
88−- Push directly to master — no PRs for this repo
48+- Push directly to master — no PRs for this repo
UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.jsonrenamed

The content is the same.

HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsonnew file+20
@@ -0,0 +1,20 @@
1+{
2+ "images" : [
3+ {
4+ "filename" : "AppIcon-512.png",
5+ "idiom" : "universal",
6+ "platform" : "macos",
7+ "size" : "512x512"
8+ },
9+ {
10+ "filename" : "AppIcon-1024.png",
11+ "idiom" : "universal",
12+ "platform" : "macos",
13+ "size" : "1024x1024"
14+ }
15+ ],
16+ "info" : {
17+ "author" : "xcode",
18+ "version" : 1
19+ }
20+}
UsageMenubar/Assets.xcassets/Contents.json → HyperCreditsMenubar/Assets.xcassets/Contents.jsonrenamed

The content is the same.

UsageMenubar/ClaudeUsageClient.swift → HyperCreditsMenubar/ClaudeUsageClient.swiftrenamed+307−56
@@ -154,32 +154,45 @@ private enum ISO8601 {
154154
155155// MARK: - Credentials
156156
157−/// The subset of Claude Code's OAuth blob this app reads. The key names are Claude
158−/// Code's, not ours — and reading is *all* we do with them.
159−///
160−/// Deliberately models neither `refreshToken` nor `expiresAt`. Both existed only to
161−/// serve a token refresh, and refreshing is precisely what this app must never do: the
162−/// token endpoint rotates the refresh token, so an app refreshing behind the CLI's back
163−/// leaves the CLI holding a token the server has already retired — signing the user out
164−/// of their own terminal. Not modelling the fields is the cheapest way to keep it that
165−/// way. Every field Claude Code stores that isn't named here is simply ignored.
166−struct ClaudeOAuthCredentials: Decodable, Equatable {
167− let claudeAiOauth: OAuthToken
168−
169− struct OAuthToken: Decodable, Equatable {
170− let accessToken: String
171− let scopes: [String]?
157+/// The OAuth credentials the Claude Code CLI owns. The key names are Claude Code's,
158+/// not ours — this is its blob, and we only ever read it and rotate its tokens.
159+struct ClaudeOAuthCredentials: Codable, Equatable {
160+ var claudeAiOauth: OAuthToken
161+
162+ struct OAuthToken: Codable, Equatable {
163+ var accessToken: String
164+ var refreshToken: String
165+ /// Epoch milliseconds.
166+ var expiresAt: Int64
167+ var scopes: [String]?
172168 /// `"pro"`, `"max"`, `"team"`, …
173− let subscriptionType: String?
174− let rateLimitTier: String?
169+ var subscriptionType: String?
170+ var rateLimitTier: String?
175171 }
176172}
177173
178−/// Reads the credentials Claude Code owns. Read-only by design — there is no `save`,
179−/// because the CLI is the only thing allowed to rotate these tokens.
174+/// Where credentials were read from, so a rotated token is written back to the same
175+/// place. Writing to the other one would leave Claude Code holding a refresh token
176+/// the server has already retired — i.e. would sign the user out of their own CLI.
177+enum ClaudeCredentialSource: Equatable {
178+ case keychain(account: String)
179+ case file(URL)
180+}
181+
182+/// Credentials together with where they came from.
183+struct StoredClaudeCredentials: Equatable {
184+ var credentials: ClaudeOAuthCredentials
185+ var source: ClaudeCredentialSource
186+}
187+
188+/// Reads — and, after a token refresh, writes back — the credentials Claude Code owns.
180189/// Injectable so tests never go near the real Keychain or the real credentials file.
181190protocol ClaudeCredentialStoring {
182− func load() -> ClaudeOAuthCredentials?
191+ func load() -> StoredClaudeCredentials?
192+
193+ /// Best effort: failing to persist a refreshed token is not worth failing the
194+ /// fetch over, and the next launch will simply refresh again.
195+ func save(_ stored: StoredClaudeCredentials)
183196}
184197
185198/// The real store: Claude Code's login-Keychain item, falling back to
@@ -210,14 +223,101 @@ struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
210223 self.credentialsFile = credentialsFile
211224 }
212225
213− func load() -> ClaudeOAuthCredentials? {
226+ func load() -> StoredClaudeCredentials? {
214227 if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
215228 let credentials = Self.decode(Data(json.utf8)) {
216− return credentials
229+ return StoredClaudeCredentials(
230+ credentials: credentials,
231+ source: .keychain(account: keychainAccount())
232+ )
233+ }
234+
235+ guard let data = try? Data(contentsOf: credentialsFile),
236+ let credentials = Self.decode(data) else { return nil }
237+
238+ return StoredClaudeCredentials(credentials: credentials, source: .file(credentialsFile))
239+ }
240+
241+ func save(_ stored: StoredClaudeCredentials) {
242+ guard let json = rotatedJSON(for: stored) else { return }
243+
244+ switch stored.source {
245+ case .keychain(let account):
246+ _ = Self.runSecurity([
247+ "add-generic-password",
248+ "-U", // update the existing item rather than adding a second one
249+ "-s", keychainService,
250+ "-a", account,
251+ "-w", json,
252+ ])
253+
254+ case .file(let url):
255+ guard let data = json.data(using: .utf8) else { return }
256+ try? data.write(to: url, options: .atomic)
257+ // An atomic write replaces the file, so the 0600 mode has to be reapplied.
258+ try? FileManager.default.setAttributes(
259+ [.posixPermissions: 0o600],
260+ ofItemAtPath: url.path
261+ )
262+ }
263+ }
264+
265+ /// The stored blob with only the three token fields rotated.
266+ ///
267+ /// Patches the raw JSON rather than re-encoding `ClaudeOAuthCredentials`, because
268+ /// a round-trip through our own type would silently drop any field Anthropic adds
269+ /// to the blob that we don't model — and this is the file the user's CLI logs in
270+ /// with. Re-encoding is the fallback for when the raw blob can't be read back.
271+ private func rotatedJSON(for stored: StoredClaudeCredentials) -> String? {
272+ let token = stored.credentials.claudeAiOauth
273+
274+ if let raw = rawJSON(from: stored.source),
275+ let parsed = try? JSONSerialization.jsonObject(with: raw),
276+ var object = parsed as? [String: Any],
277+ var oauth = object["claudeAiOauth"] as? [String: Any] {
278+ oauth["accessToken"] = token.accessToken
279+ oauth["refreshToken"] = token.refreshToken
280+ oauth["expiresAt"] = token.expiresAt
281+ object["claudeAiOauth"] = oauth
282+
283+ if let patched = try? JSONSerialization.data(withJSONObject: object),
284+ let json = String(data: patched, encoding: .utf8) {
285+ return json
286+ }
287+ }
288+
289+ guard let encoded = try? JSONEncoder().encode(stored.credentials) else { return nil }
290+ return String(data: encoded, encoding: .utf8)
291+ }
292+
293+ private func rawJSON(from source: ClaudeCredentialSource) -> Data? {
294+ switch source {
295+ case .keychain:
296+ return Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"])
297+ .map { Data($0.utf8) }
298+ case .file(let url):
299+ return try? Data(contentsOf: url)
300+ }
301+ }
302+
303+ /// The account on Claude Code's Keychain item, so a write-back updates that item
304+ /// instead of adding a second one under a different account.
305+ private func keychainAccount() -> String {
306+ guard let output = Self.runSecurity(["find-generic-password", "-s", keychainService]) else {
307+ return NSUserName()
308+ }
309+
310+ // The attribute dump spells the account as: "acct"<blob>="someone"
311+ for line in output.split(separator: "\n") {
312+ let trimmed = line.trimmingCharacters(in: .whitespaces)
313+ guard trimmed.hasPrefix("\"acct\""),
314+ let range = trimmed.range(of: #"="[^"]+"#, options: .regularExpression)
315+ else { continue }
316+ // The match starts at `="` and stops before the closing quote.
317+ return String(trimmed[range].dropFirst(2))
217318 }
218319
219− guard let data = try? Data(contentsOf: credentialsFile) else { return nil }
220− return Self.decode(data)
320+ return NSUserName()
221321 }
222322
223323 private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
@@ -263,11 +363,8 @@ enum ClaudeUsageError: LocalizedError, Equatable {
263363 /// Claude Code has never signed in here. Not an error the user needs to see — the
264364 /// popover just leaves the section out.
265365 case noCredentials
266−
267− /// The token the CLI last stored has expired or been revoked. The app cannot fix
268− /// this itself — only the CLI may mint a new one — so the message sends the user
269− /// there.
270366 case invalidCredentials
367+ case tokenRefreshFailed
271368 case requestFailed(String)
272369 case decodingFailed(String)
273370
@@ -277,6 +374,8 @@ enum ClaudeUsageError: LocalizedError, Equatable {
277374 return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
278375 case .invalidCredentials:
279376 return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
377+ case .tokenRefreshFailed:
378+ return "Could not refresh Claude credentials. Run `claude` in a terminal to sign in again."
280379 case .requestFailed(let message):
281380 return "Claude request failed: \(message)"
282381 case .decodingFailed(let message):
@@ -300,30 +399,33 @@ protocol ClaudeUsageChecking {
300399
301400// MARK: - Client
302401
303−/// Client for Claude Code's subscription usage API, authenticating with whatever OAuth
304−/// access token the CLI has most recently stored on this machine.
305−///
306−/// **A passenger, never a driver.** It reads the CLI's token and spends it as-is. It
307−/// never refreshes: the token endpoint *rotates* the refresh token, and the CLI refreshes
308−/// on its own schedule, so an app that also refreshed would race it — whichever of the two
309−/// went second would present a refresh token the server had already retired, and the user
310−/// would find themselves signed out of their own terminal. That bug is not worth a usage
311−/// readout. When the token is spent, we say so and point at `claude`.
312−///
313−/// Credentials are re-read on every fetch (the app polls every 1–5 minutes), so a token
314−/// the CLI refreshes is picked up on the next tick without the app lifting a finger.
402+/// Client for Claude Code's subscription usage API, authenticating with the OAuth
403+/// credentials the CLI already stores on this machine.
315404///
316−/// Still an actor: it holds no mutable state now that the refresh is gone, but the
317−/// overlapping fetches (timer, manual click, wake) share one instance, and an actor keeps
318−/// that sharing trivially safe.
405+/// An actor rather than a plain client, because the refresh path mutates state the
406+/// user's CLI depends on: the token endpoint rotates the refresh token, so two
407+/// concurrent refreshes would leave one of them holding a token the server has
408+/// already retired. Overlapping fetches (timer, manual click, wake) instead join a
409+/// single in-flight refresh.
319410actor ClaudeUsageClient: ClaudeUsageChecking {
320411 static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
412+ static let tokenEndpoint = URL(string: "https://platform.claude.com/v1/oauth/token")!
321413
322− /// Cloudflare fronts the API and answers 403 `browser_signature_banned` to anything
323− /// without a browser User-Agent.
414+ /// Claude Code's public OAuth client id, and the scopes it signs in with.
415+ static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
416+ static let scope = "user:inference user:profile user:sessions:claude_code"
417+
418+ /// Cloudflare fronts the token endpoint and answers 403 `browser_signature_banned`
419+ /// to anything without a browser User-Agent.
324420 static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
325421 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
326422
423+ /// Refresh this far ahead of the stated expiry rather than racing it.
424+ static let expiryBuffer: TimeInterval = 60
425+
426+ /// Used when the token endpoint omits `expires_in`.
427+ static let defaultTokenLifetime: TimeInterval = 8 * 60 * 60
428+
327429 /// Retries *after* the initial attempt, so at most 3 requests per call.
328430 static let maxRetries = 2
329431
@@ -350,17 +452,24 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
350452 private let session: URLSession
351453 private let store: ClaudeCredentialStoring
352454 private let retryBaseDelay: Duration
455+ private let now: @Sendable () -> Date
456+
457+ /// The refresh currently in flight, if any. See `refreshCredentials(from:)`.
458+ private var inFlightRefresh: Task<StoredClaudeCredentials, Error>?
353459
354460 /// - Parameters:
355461 /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
356462 /// one (2s, then 4s). Injectable so tests need not wait in real time.
463+ /// - now: The current instant. Injectable so tests can drive token expiry.
357464 init(
358465 session: URLSession? = nil,
359466 store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
360− retryBaseDelay: Duration = .seconds(2)
467+ retryBaseDelay: Duration = .seconds(2),
468+ now: @escaping @Sendable () -> Date = { Date() }
361469 ) {
362470 self.store = store
363471 self.retryBaseDelay = retryBaseDelay
472+ self.now = now
364473 if let session {
365474 self.session = session
366475 } else {
@@ -371,26 +480,43 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
371480 }
372481 }
373482
374− /// Fetches current usage with the CLI's current access token, exactly as stored.
375− ///
376− /// The token is not checked for expiry and not refreshed on rejection — see the type
377− /// comment. A spent token surfaces as `.invalidCredentials`, which asks the user to
378− /// run `claude`; doing so has the CLI mint a fresh token, which the next fetch picks
379− /// up on its own.
483+ /// Fetches current usage, refreshing the OAuth token first if it has expired.
380484 ///
381485 /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
382486 /// signed in here, which callers should treat as "nothing to show" rather than
383487 /// as a failure.
384488 func fetchUsage() async throws -> ClaudeUsageReport {
385− guard let credentials = store.load() else { throw ClaudeUsageError.noCredentials }
489+ guard let stored = store.load() else { throw ClaudeUsageError.noCredentials }
386490
387− let token = credentials.claudeAiOauth
388− let usage = try await requestUsage(token: token.accessToken)
389− return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
491+ var current = stored
492+ var didRefresh = false
493+ if isExpired(stored.credentials) {
494+ current = try await refreshCredentials(from: stored)
495+ didRefresh = true
496+ }
497+
498+ do {
499+ return try await report(with: current)
500+ } catch ClaudeUsageError.invalidCredentials where !didRefresh {
501+ // A token can be revoked server-side well before its stated expiry, so a
502+ // rejection is worth one refresh and one retry.
503+ guard let refreshed = try? await refreshCredentials(from: current) else {
504+ // A failed refresh (Cloudflare, a retired refresh token) says less
505+ // than the rejection that sent us here, so surface that instead.
506+ throw ClaudeUsageError.invalidCredentials
507+ }
508+ return try await report(with: refreshed)
509+ }
390510 }
391511
392512 // MARK: - Usage Request
393513
514+ private func report(with stored: StoredClaudeCredentials) async throws -> ClaudeUsageReport {
515+ let token = stored.credentials.claudeAiOauth
516+ let usage = try await requestUsage(token: token.accessToken)
517+ return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
518+ }
519+
394520 private func requestUsage(token: String) async throws -> ClaudeUsage {
395521 var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
396522 request.httpMethod = "GET"
@@ -408,6 +534,101 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
408534 }
409535 }
410536
537+ // MARK: - Token Refresh
538+
539+ /// Exchanges the refresh token for a fresh access token and persists the result.
540+ ///
541+ /// Callers join whatever refresh is already running rather than starting a second
542+ /// one, and the exchange itself runs in an unstructured task so that a cancelled
543+ /// caller — the view model cancels the previous refresh whenever a new one starts
544+ /// — can never abandon a rotation half-done, with the new refresh token neither
545+ /// used nor written back.
546+ private func refreshCredentials(from stored: StoredClaudeCredentials) async throws -> StoredClaudeCredentials {
547+ if let existing = inFlightRefresh {
548+ return try await existing.value
549+ }
550+
551+ let task = Task { () throws -> StoredClaudeCredentials in
552+ let response = try await self.requestTokenRefresh(
553+ refreshToken: stored.credentials.claudeAiOauth.refreshToken
554+ )
555+
556+ var refreshed = stored
557+ refreshed.credentials.claudeAiOauth.accessToken = response.accessToken
558+ if let rotated = response.refreshToken {
559+ refreshed.credentials.claudeAiOauth.refreshToken = rotated
560+ }
561+ let lifetime = response.expiresIn.map(TimeInterval.init) ?? Self.defaultTokenLifetime
562+ refreshed.credentials.claudeAiOauth.expiresAt =
563+ Int64((self.now().timeIntervalSince1970 + lifetime) * 1000)
564+
565+ self.store.save(refreshed)
566+ return refreshed
567+ }
568+ // Only the task's creator ever clears the slot, so a joiner resuming late can
569+ // never wipe out a refresh that started after it.
570+ inFlightRefresh = task
571+
572+ do {
573+ let refreshed = try await task.value
574+ inFlightRefresh = nil
575+ return refreshed
576+ } catch {
577+ inFlightRefresh = nil
578+ throw error
579+ }
580+ }
581+
582+ /// Posts the refresh grant as JSON, then — for token endpoints that reject a JSON
583+ /// body — retries the same exchange form-encoded before giving up.
584+ private func requestTokenRefresh(refreshToken: String) async throws -> TokenRefreshResponse {
585+ let parameters = [
586+ "grant_type": "refresh_token",
587+ "refresh_token": refreshToken,
588+ "client_id": Self.clientID,
589+ "scope": Self.scope,
590+ ]
591+
592+ if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: false) {
593+ return response
594+ }
595+ if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: true) {
596+ return response
597+ }
598+ throw ClaudeUsageError.tokenRefreshFailed
599+ }
600+
601+ /// One refresh exchange in one encoding. Returns `nil` rather than throwing, so the
602+ /// caller can fall through to the other encoding.
603+ private func tokenRefreshAttempt(
604+ parameters: [String: String],
605+ formEncoded: Bool
606+ ) async -> TokenRefreshResponse? {
607+ var request = URLRequest(url: Self.tokenEndpoint)
608+ request.httpMethod = "POST"
609+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
610+ request.setValue("application/json", forHTTPHeaderField: "Accept")
611+
612+ if formEncoded {
613+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
614+ request.httpBody = Self.formURLEncoded(parameters).data(using: .utf8)
615+ } else {
616+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
617+ request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
618+ }
619+
620+ guard let data = try? await send(request) else { return nil }
621+ return try? JSONDecoder().decode(TokenRefreshResponse.self, from: data)
622+ }
623+
624+ /// Whether the access token is spent, or close enough that using it would be a race.
625+ private func isExpired(_ credentials: ClaudeOAuthCredentials) -> Bool {
626+ let expiry = Date(
627+ timeIntervalSince1970: TimeInterval(credentials.claudeAiOauth.expiresAt) / 1000
628+ )
629+ return expiry.timeIntervalSince(now()) <= Self.expiryBuffer
630+ }
631+
411632 // MARK: - Transport
412633
413634 /// Sends a request, retrying transient failures with exponential backoff.
@@ -468,9 +689,39 @@ actor ClaudeUsageClient: ClaudeUsageChecking {
468689 }
469690 }
470691
692+ /// Encodes parameters as an `application/x-www-form-urlencoded` body. Percent-encodes
693+ /// against RFC 3986's unreserved set, so the spaces and colons in the scope don't
694+ /// travel raw.
695+ private static func formURLEncoded(_ parameters: [String: String]) -> String {
696+ var allowed = CharacterSet.alphanumerics
697+ allowed.insert(charactersIn: "-._~")
698+
699+ return parameters.map { key, value in
700+ let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
701+ let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
702+ return "\(encodedKey)=\(encodedValue)"
703+ }
704+ .joined(separator: "&")
705+ }
706+
471707 /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
472708 private struct AttemptFailure: Error {
473709 let underlying: ClaudeUsageError
474710 let isRetryable: Bool
475711 }
476712}
713+
714+/// The token endpoint's answer to a refresh grant.
715+struct TokenRefreshResponse: Decodable, Equatable {
716+ let accessToken: String
717+ let refreshToken: String?
718+
719+ /// Seconds. Omitted by some deployments, in which case the caller assumes a default.
720+ let expiresIn: Int?
721+
722+ private enum CodingKeys: String, CodingKey {
723+ case accessToken = "access_token"
724+ case refreshToken = "refresh_token"
725+ case expiresIn = "expires_in"
726+ }
727+}
UsageMenubar/CreditsChecker.swift → HyperCreditsMenubar/CreditsChecker.swiftrenamed

The content is the same.

UsageMenubar/UsageMenubarApp.swift → HyperCreditsMenubar/HyperCreditsApp.swiftrenamed+14−47
@@ -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,9 +46,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
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.
49+ // Observe published changes (balance + isLoading) to update the menu bar title.
50+ // CombineLatest fires when either published property changes.
5251 //
5352 // The title is built from the values the publisher hands us, not by reading the
5453 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -57,47 +56,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5756 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5857 // whose Combine scheduler only runs in the default run loop mode — the title
5958 // would freeze while a menu or a scroll was tracking.
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(
66− viewModel.$balance,
67− viewModel.$isLoading,
68− viewModel.$hyperConfigured,
69− Publishers.CombineLatest3(
70− viewModel.$claudeUsage,
71− viewModel.$zaiUsage,
72− Just<Void>(())
73− )
74− )
75− .map { balance, isLoading, hyperConfigured, claudeAndZai in
76− let (claudeUsage, zaiUsage, _) = claudeAndZai
77− let fiveHour = claudeUsage?.fiveHour
78− .map { Int($0.utilization.rounded()) }
79− let sevenDay = claudeUsage?.sevenDay
80− .map { Int($0.utilization.rounded()) }
81− ?? claudeUsage?.sevenDayOpus
82− .map { Int($0.utilization.rounded()) }
83− let zaiFiveHour = zaiUsage?.fiveHourPercent
84− let zaiWeekly = zaiUsage?.weeklyPercent
85− return ViewModel.statusBarText(
86− balance: balance,
87− isLoading: isLoading,
88− hyperConfigured: hyperConfigured,
89− claudeFiveHourPercent: fiveHour,
90− claudeSevenDayPercent: sevenDay,
91− zaiFiveHourPercent: zaiFiveHour,
92− zaiWeeklyPercent: zaiWeekly,
93− zaiInPeakHours: ViewModel.zaiInPeakHours
94− )
95− }
96− .removeDuplicates()
97− .receive(on: DispatchQueue.main)
98− .sink { [weak self] text in
99− self?.statusItem.button?.title = text
100− }
59+ statusTextCancellable = Publishers.CombineLatest(viewModel.$balance, viewModel.$isLoading)
60+ .map { balance, isLoading in
61+ ViewModel.statusBarText(balance: balance, isLoading: isLoading)
62+ }
63+ .removeDuplicates()
64+ .receive(on: DispatchQueue.main)
65+ .sink { [weak self] text in
66+ self?.statusItem.button?.title = text
67+ }
10168
10269 // Register for sleep/wake notifications
10370 registerSleepWakeNotifications()
@@ -221,7 +188,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
221188
222189 menu.addItem(.separator())
223190
224− let quitItem = NSMenuItem(title: "Quit UsageMenubar", action: #selector(quit(_:)), keyEquivalent: "q")
191+ let quitItem = NSMenuItem(title: "Quit HyperCredits", action: #selector(quit(_:)), keyEquivalent: "q")
225192 quitItem.target = self
226193 menu.addItem(quitItem)
227194
@@ -266,7 +233,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
266233// MARK: - App Entry Point
267234
268235@main
269−struct UsageMenubarApp: App {
236+struct HyperCreditsApp: App {
270237 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
271238
272239 var body: some Scene {
UsageMenubar/UsageMenubar.entitlements → HyperCreditsMenubar/HyperCreditsMenubar.entitlementsrenamed

The content is the same.

UsageMenubar/Info.plist → HyperCreditsMenubar/Info.plistrenamed+3−3
@@ -3,11 +3,11 @@
33<plist version="1.0">
44<dict>
55 <key>CFBundleName</key>
6− <string>UsageMenubar</string>
6+ <string>HyperCreditsMenubar</string>
77 <key>CFBundleDisplayName</key>
8− <string>Usage Menubar</string>
8+ <string>Hyper Credits</string>
99 <key>CFBundleIdentifier</key>
10− <string>soy.iko.usage-menubar</string>
10+ <string>com.ilyakooo0.hyper-credits-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift → HyperCreditsMenubar/KeychainHelper.swiftrenamed+1−29
@@ -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 = "soy.iko.usage-menubar"
85+ private static let service = "com.ilyakooo0.hyper-credits-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -106,31 +106,3 @@ 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−}
HyperCreditsMenubar/MenuView.swiftnew file+526
@@ -0,0 +1,526 @@
1+import SwiftUI
2+
3+/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
4+struct MenuView: View {
5+ @ObservedObject var viewModel: ViewModel
6+
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
9+
10+ private static let websiteURL = URL(string: "https://hyper.charm.land")!
11+
12+ // MARK: - Type Scale
13+
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()
21+ private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
22+ .monospacedDigit()
23+ private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
24+ private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
25+ private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
26+ private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
27+
28+ var body: some View {
29+ VStack(alignment: .leading, spacing: 18) {
30+ hero
31+ if showsClaude {
32+ hairline
33+ claude
34+ }
35+ hairline
36+ settings
37+ hairline
38+ footer
39+ }
40+ .padding(.horizontal, 18)
41+ .padding(.top, 18)
42+ .padding(.bottom, 14)
43+ .frame(width: 280)
44+ // Set as the environment font so the controls inherit the rounded design too.
45+ .font(Self.controlFont)
46+ .background(Color(nsColor: .windowBackgroundColor))
47+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
48+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
49+ .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
50+ .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
51+ .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
52+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
53+ .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
54+ .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
55+ }
56+
57+ /// A section rule light enough to read as a pause rather than a border.
58+ private var hairline: some View {
59+ Divider().opacity(0.3)
60+ }
61+
62+ // MARK: - Hero
63+
64+ private var hero: some View {
65+ VStack(spacing: 8) {
66+ if viewModel.isLoading && viewModel.balance == nil {
67+ PulsingBolt()
68+ .transition(.opacity)
69+ } else {
70+ VStack(spacing: 6) {
71+ balanceNumber
72+ caption
73+ sparkline
74+ }
75+ .transition(.opacity)
76+ }
77+
78+ if let lastUpdatedText = viewModel.lastUpdatedText {
79+ Text(lastUpdatedText)
80+ .font(Self.footnoteFont)
81+ .foregroundColor(.secondary)
82+ .transition(.opacity)
83+ }
84+
85+ if let error = viewModel.errorMessage {
86+ errorRow(error)
87+ .transition(.opacity)
88+ }
89+ }
90+ .frame(maxWidth: .infinity)
91+ }
92+
93+ /// The balance itself. Clicking it copies the raw number to the clipboard.
94+ private var balanceNumber: some View {
95+ Button(action: { viewModel.copyBalanceToClipboard() }) {
96+ HStack(alignment: .firstTextBaseline, spacing: 6) {
97+ Text(viewModel.formattedBalance)
98+ .font(Self.heroFont)
99+ .foregroundColor(viewModel.balanceColor)
100+ .contentTransition(.opacity)
101+
102+ if let symbol = viewModel.balanceTrend.symbolName {
103+ Image(systemName: symbol)
104+ .font(.system(size: 12, weight: .semibold))
105+ .foregroundColor(.secondary)
106+ .transition(.opacity)
107+ }
108+ }
109+ // Without this the hover area is only the glyphs themselves.
110+ .contentShape(Rectangle())
111+ }
112+ .buttonStyle(.plain)
113+ .disabled(viewModel.balance == nil)
114+ .help("Click to copy")
115+ .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
116+ .onHover { isHoveringBalance = $0 }
117+ .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
118+ }
119+
120+ /// Doubles as the copy confirmation so the layout doesn't shift.
121+ private var caption: some View {
122+ Group {
123+ if viewModel.copiedConfirmation {
124+ Text("✓ Copied")
125+ .foregroundColor(.green)
126+ } else {
127+ Text("credits")
128+ .foregroundColor(.secondary)
129+ }
130+ }
131+ .font(Self.captionFont)
132+ }
133+
134+ @ViewBuilder
135+ private var sparkline: some View {
136+ if historyValues.count >= 2 {
137+ Sparkline(values: historyValues, color: viewModel.balanceColor)
138+ .frame(height: 26)
139+ .padding(.top, 4)
140+ .transition(.opacity)
141+ }
142+ }
143+
144+ /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
145+ private var historyValues: [Int] {
146+ viewModel.balanceHistory.map { $0.balance }
147+ }
148+
149+ /// Errors read as a quiet inline notice rather than loose red text: an icon, the
150+ /// message, and a tinted rounded background with no border.
151+ private func errorRow(_ message: String) -> some View {
152+ HStack(alignment: .top, spacing: 6) {
153+ Image(systemName: "exclamationmark.triangle.fill")
154+ .font(.system(size: 10, weight: .semibold))
155+
156+ Text(message)
157+ .font(Self.captionFont)
158+ // Long messages wrap instead of being truncated to one line.
159+ .fixedSize(horizontal: false, vertical: true)
160+
161+ Spacer(minLength: 0)
162+ }
163+ .foregroundColor(.red)
164+ .padding(.horizontal, 10)
165+ .padding(.vertical, 7)
166+ .background(
167+ RoundedRectangle(cornerRadius: 8, style: .continuous)
168+ .fill(Color.red.opacity(0.1))
169+ )
170+ .padding(.top, 2)
171+ }
172+
173+ // MARK: - Claude
174+
175+ /// Whether there is anything to say about Claude. Nothing at all when Claude Code
176+ /// isn't signed in on this machine — the section doesn't appear, and no error is
177+ /// shown for a service the user may simply not use.
178+ private var showsClaude: Bool {
179+ if viewModel.claudeError != nil { return true }
180+ if let usage = viewModel.claudeUsage { return !usage.isEmpty }
181+ return false
182+ }
183+
184+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
185+ /// with the individual windows underneath as quiet supporting detail.
186+ private var claude: some View {
187+ VStack(alignment: .leading, spacing: 12) {
188+ HStack(spacing: 8) {
189+ Text("Claude Code")
190+ .font(Self.sectionFont)
191+
192+ Spacer(minLength: 0)
193+
194+ if let plan = viewModel.claudePlanLabel {
195+ Text(plan)
196+ .font(Self.captionFont)
197+ .foregroundColor(.secondary)
198+ }
199+ }
200+
201+ if let usage = viewModel.claudeUsage, !usage.isEmpty {
202+ usageDetail(usage)
203+ }
204+
205+ if let error = viewModel.claudeError {
206+ errorRow(error)
207+ }
208+ }
209+ }
210+
211+ private func usageDetail(_ usage: ClaudeUsage) -> some View {
212+ VStack(alignment: .leading, spacing: 12) {
213+ if let headline = Self.headline(for: usage) {
214+ HStack(alignment: .firstTextBaseline, spacing: 8) {
215+ Text("\(headline.percent)%")
216+ .font(Self.subheroFont)
217+ .foregroundColor(Self.usageColor(headline.percent))
218+ .contentTransition(.opacity)
219+
220+ Spacer(minLength: 0)
221+
222+ if let resets = headline.resetsIn {
223+ Text("resets in \(resets)")
224+ .font(Self.footnoteFont)
225+ .foregroundColor(.secondary)
226+ }
227+ }
228+ }
229+
230+ VStack(alignment: .leading, spacing: 9) {
231+ if let fiveHour = usage.fiveHour {
232+ window("5-hour", fiveHour)
233+ }
234+ if let sevenDay = usage.sevenDay {
235+ window("7-day", sevenDay)
236+ }
237+ // Opus has its own weekly cap on Max plans, but it is only worth a row
238+ // once it has actually been used.
239+ if let opus = usage.sevenDayOpus, opus.utilization > 0 {
240+ window("7-day Opus", opus)
241+ }
242+ }
243+ }
244+ }
245+
246+ /// One window: its name, its share of the allowance, and a hairline bar. The bar is
247+ /// the only chrome here, and it carries the same color as the number beside it.
248+ private func window(_ label: String, _ window: UsageWindow) -> some View {
249+ let percent = Int(window.utilization.rounded())
250+
251+ return VStack(alignment: .leading, spacing: 5) {
252+ HStack(spacing: 6) {
253+ Text(label)
254+ .font(Self.captionFont)
255+ .foregroundColor(.secondary)
256+
257+ Spacer(minLength: 0)
258+
259+ Text("\(percent)%")
260+ .font(Self.captionFont.monospacedDigit())
261+ .foregroundColor(Self.usageColor(percent))
262+ }
263+
264+ UsageBar(percent: percent, color: Self.usageColor(percent))
265+ }
266+ }
267+
268+ /// The number the section leads with: whichever limit the server flags as binding,
269+ /// falling back to the 5-hour window when it flags none.
270+ private static func headline(for usage: ClaudeUsage) -> (percent: Int, resetsIn: String?)? {
271+ if let active = usage.activeLimit {
272+ return (active.percent, active.resetsInFormatted)
273+ }
274+ if let fiveHour = usage.fiveHour {
275+ return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
276+ }
277+ return nil
278+ }
279+
280+ /// The balance colors read the other way round here, because for a limit more is
281+ /// worse: green with room to spare, yellow closing in, red nearly spent.
282+ private static func usageColor(_ percent: Int) -> Color {
283+ if percent >= 90 { return .red }
284+ if percent >= 70 { return .yellow }
285+ return .green
286+ }
287+
288+ // MARK: - Settings
289+
290+ private var settings: some View {
291+ VStack(alignment: .leading, spacing: 14) {
292+ VStack(alignment: .leading, spacing: 8) {
293+ HStack(spacing: 8) {
294+ Text("API Key")
295+ .font(Self.sectionFont)
296+
297+ Spacer(minLength: 0)
298+
299+ // The confirmation takes the link's place rather than adding a row
300+ // of its own, so saving never resizes the popover.
301+ if viewModel.savedConfirmation {
302+ Text("✓ Saved")
303+ .font(Self.captionFont)
304+ .foregroundColor(.green)
305+ .transition(.opacity)
306+ } else {
307+ Link("Get key →", destination: Self.websiteURL)
308+ .font(Self.captionFont)
309+ .transition(.opacity)
310+ }
311+ }
312+
313+ HStack(spacing: 8) {
314+ SecureField("sk-…", text: $viewModel.apiKeyInput)
315+ .textFieldStyle(.roundedBorder)
316+
317+ Button("Save") {
318+ viewModel.saveAPIKey()
319+ }
320+ }
321+ .controlSize(.small)
322+ }
323+
324+ HStack {
325+ Text("Refresh every")
326+
327+ Spacer(minLength: 0)
328+
329+ Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
330+ ForEach(RefreshInterval.options, id: \.self) { minutes in
331+ Text("\(minutes)m").tag(minutes)
332+ }
333+ }
334+ .pickerStyle(.menu)
335+ .labelsHidden()
336+ .fixedSize()
337+ .controlSize(.small)
338+ }
339+
340+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
341+ .toggleStyle(.switch)
342+ .controlSize(.small)
343+ }
344+ }
345+
346+ // MARK: - Footer
347+
348+ private var footer: some View {
349+ VStack(spacing: 14) {
350+ HStack {
351+ Button(action: { viewModel.refresh() }) {
352+ Label("Refresh", systemImage: "arrow.clockwise")
353+ }
354+ .disabled(viewModel.isLoading)
355+
356+ Spacer(minLength: 12)
357+
358+ Button("Quit") {
359+ NSApplication.shared.terminate(nil)
360+ }
361+ .keyboardShortcut("q")
362+ }
363+ .controlSize(.small)
364+
365+ Link("hyper.charm.land", destination: Self.websiteURL)
366+ .font(Self.footnoteFont)
367+ .foregroundColor(.secondary)
368+ .opacity(0.7)
369+ .frame(maxWidth: .infinity, alignment: .center)
370+ }
371+ }
372+}
373+
374+// MARK: - Loading
375+
376+/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
377+/// the menu bar title rather than a generic spinner, so the two read as the same app.
378+private struct PulsingBolt: View {
379+ @State private var isPulsing = false
380+
381+ var body: some View {
382+ VStack(spacing: 6) {
383+ Text("⚡")
384+ .font(.system(size: 34))
385+ .opacity(isPulsing ? 0.35 : 1)
386+ .scaleEffect(isPulsing ? 0.94 : 1)
387+ .animation(
388+ .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
389+ value: isPulsing
390+ )
391+ // Roughly the height of the balance it stands in for, so the hero
392+ // doesn't jump when the first fetch lands.
393+ .frame(height: 56)
394+
395+ Text("Loading…")
396+ .font(.system(size: 11, weight: .medium, design: .rounded))
397+ .foregroundColor(.secondary)
398+ }
399+ .onAppear { isPulsing = true }
400+ }
401+}
402+
403+// MARK: - Usage Bar
404+
405+/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
406+/// rule under the label rather than as a control.
407+private struct UsageBar: View {
408+ /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
409+ let percent: Int
410+ let color: Color
411+
412+ private let height: CGFloat = 3
413+
414+ var body: some View {
415+ GeometryReader { geometry in
416+ let fraction = min(max(Double(percent) / 100, 0), 1)
417+
418+ ZStack(alignment: .leading) {
419+ Capsule()
420+ .fill(Color.secondary.opacity(0.15))
421+
422+ Capsule()
423+ .fill(color)
424+ .frame(width: geometry.size.width * fraction)
425+ }
426+ }
427+ .frame(height: height)
428+ }
429+}
430+
431+// MARK: - Sparkline
432+
433+/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
434+/// takes the balance's color so the trend reads as part of the number above it, with a
435+/// faint fade underneath for weight and a dot marking the latest point.
436+///
437+/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
438+/// readable when fetches are irregular (wake-ups, manual refreshes).
439+struct Sparkline: View {
440+ let values: [Int]
441+ var color: Color = .secondary
442+
443+ /// Keeps the stroke and the dot from being clipped at the edges.
444+ private let verticalInset: CGFloat = 3
445+ private let dotRadius: CGFloat = 2.5
446+
447+ var body: some View {
448+ GeometryReader { geometry in
449+ let points = points(in: geometry.size)
450+
451+ if points.count >= 2 {
452+ ZStack {
453+ fill(under: points, height: geometry.size.height)
454+ .fill(
455+ LinearGradient(
456+ colors: [color.opacity(0.18), color.opacity(0)],
457+ startPoint: .top,
458+ endPoint: .bottom
459+ )
460+ )
461+
462+ line(through: points)
463+ .stroke(
464+ color.opacity(0.9),
465+ style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
466+ )
467+
468+ if let last = points.last {
469+ Circle()
470+ .fill(color)
471+ .frame(width: dotRadius * 2, height: dotRadius * 2)
472+ .position(last)
473+ }
474+ }
475+ }
476+ }
477+ }
478+
479+ /// Normalizes the values into the available space, oldest at the left.
480+ private func points(in size: CGSize) -> [CGPoint] {
481+ guard values.count >= 2 else { return [] }
482+
483+ let minValue = values.min() ?? 0
484+ let maxValue = values.max() ?? 0
485+ let range = Double(maxValue - minValue)
486+
487+ // The dot is centered on the last point, so the line stops a radius short of
488+ // the trailing edge rather than letting the dot spill outside the frame.
489+ let usableWidth = max(size.width - dotRadius, 1)
490+ let usableHeight = max(size.height - verticalInset * 2, 1)
491+ let stepX = usableWidth / CGFloat(values.count - 1)
492+
493+ return values.enumerated().map { index, value in
494+ // A flat series has no range to normalize against; center it.
495+ let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
496+ return CGPoint(
497+ x: CGFloat(index) * stepX,
498+ y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
499+ )
500+ }
501+ }
502+
503+ private func line(through points: [CGPoint]) -> Path {
504+ Path { path in
505+ guard let first = points.first else { return }
506+ path.move(to: first)
507+ for point in points.dropFirst() {
508+ path.addLine(to: point)
509+ }
510+ }
511+ }
512+
513+ /// The line closed down to the baseline, so it can be filled.
514+ private func fill(under points: [CGPoint], height: CGFloat) -> Path {
515+ Path { path in
516+ guard let first = points.first, let last = points.last else { return }
517+ path.move(to: CGPoint(x: first.x, y: height))
518+ path.addLine(to: first)
519+ for point in points.dropFirst() {
520+ path.addLine(to: point)
521+ }
522+ path.addLine(to: CGPoint(x: last.x, y: height))
523+ path.closeSubpath()
524+ }
525+ }
526+}
UsageMenubar/VersionFormatter.swift → HyperCreditsMenubar/VersionFormatter.swiftrenamed

The content is the same.

UsageMenubar/ViewModel.swift → HyperCreditsMenubar/ViewModel.swiftrenamed+46−425
@@ -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("soy.iko.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.hyper-credits-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction a metric has moved over the most recent history points.
14−enum MetricTrend {
13+/// The direction the balance has moved over the most recent history points.
14+enum BalanceTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,11 +29,10 @@ enum MetricTrend {
2929 }
3030}
3131
32−/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33−/// and z.ai 5-hour % alike.
34−struct MetricPoint: Equatable {
32+/// A single balance observation.
33+struct BalancePoint: Equatable {
3534 let date: Date
36− let value: Int
35+ let balance: Int
3736}
3837
3938/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -81,24 +80,6 @@ final class ViewModel: ObservableObject {
8180 /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
8281 @Published var claudePlan: String?
8382
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−
10283 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
10384 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
10485 @Published var apiKeyInput: String = ""
@@ -112,20 +93,8 @@ final class ViewModel: ObservableObject {
11293 /// Brief "✓ Copied" confirmation shown after copying the balance.
11394 @Published var copiedConfirmation: Bool = false
11495
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−
12196 /// Successful balance observations from the last 24 hours, oldest first.
122− @Published private(set) var history: [MetricPoint] = []
123−
124− /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125− @Published private(set) var claudeHistory: [MetricPoint] = []
126−
127− /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128− @Published private(set) var zaiHistory: [MetricPoint] = []
97+ @Published private(set) var history: [BalancePoint] = []
12998
13099 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131100 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -156,13 +125,8 @@ final class ViewModel: ObservableObject {
156125 /// last-updated time and history out from under the user.
157126 private var activeAPIKey: String
158127
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−
163128 private let checker: CreditsChecking
164129 private let claudeChecker: ClaudeUsageChecking
165− private let zaiChecker: ZaiUsageChecking
166130 private let defaults: UserDefaults
167131
168132 /// The currently in-flight refresh task, if any. Used to cancel
@@ -176,12 +140,6 @@ final class ViewModel: ObservableObject {
176140 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177141 private var savedResetTask: Task<Void, Never>?
178142
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−
185143 // MARK: - History Configuration
186144
187145 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -189,26 +147,17 @@ final class ViewModel: ObservableObject {
189147 private static let maxHistoryEntries = 300
190148 private static let historyWindow: TimeInterval = 24 * 60 * 60
191149
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195150 init(
196151 checker: CreditsChecking = CreditsChecker(),
197152 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198− zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199153 defaults: UserDefaults = .standard
200154 ) {
201155 self.checker = checker
202156 self.claudeChecker = claudeChecker
203− self.zaiChecker = zaiChecker
204157 self.defaults = defaults
205158 let storedKey = KeychainHelper.load() ?? ""
206159 apiKeyInput = storedKey
207160 activeAPIKey = storedKey
208− hyperConfigured = !storedKey.isEmpty
209− let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210− zaiAPIKeyInput = storedZaiKey
211− activeZaiAPIKey = storedZaiKey
212161 // Assigning in init doesn't fire didSet, so this load doesn't re-post
213162 // the change notification or write back to defaults.
214163 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -237,88 +186,25 @@ final class ViewModel: ObservableObject {
237186 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238187 }
239188
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.
189+ /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
245190 ///
246− /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247− ///
248− /// Takes its values as parameters rather than reading the properties: `@Published`
249− /// publishes in `willSet`, so a Combine subscriber that called back into the view
250− /// model would see the *pre-change* value and render a title one update behind.
251− static func statusBarText(
252− balance: Int?,
253− isLoading: Bool,
254− hyperConfigured: Bool,
255− claudeFiveHourPercent: Int?,
256− claudeSevenDayPercent: Int?,
257− zaiFiveHourPercent: Int?,
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
260− ) -> String {
261− var segments: [String] = []
262−
263− // Hyper
264− if hyperConfigured, let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
266− }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
271− claudeWindows.append("\(fiveHour)%")
272− }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
275− }
276− if !claudeWindows.isEmpty {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
278− }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
283− zaiWindows.append("\(zaiFiveHour)%")
284− }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
191+ /// Takes the balance and loading flag as parameters rather than reading the
192+ /// properties: `@Published` publishes in `willSet`, so a Combine subscriber that
193+ /// called back into the view model would see the *pre-change* value and render a
194+ /// title one update behind.
195+ static func statusBarText(balance: Int?, isLoading: Bool) -> String {
196+ if isLoading && balance == nil {
197+ return "⚡…"
287198 }
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 ? "…" : ""
199+ if let balance = balance {
200+ return "⚡\(formatBalance(balance))"
300201 }
301− return segments.joined(separator: " · ")
202+ return "⚡?"
302203 }
303204
304205 /// The text to show in the menu bar for the current state.
305206 var statusBarItemText: String {
306− Self.statusBarText(
307− balance: balance,
308− isLoading: isLoading,
309− hyperConfigured: hyperConfigured,
310− claudeFiveHourPercent: claudeFiveHourPercent,
311− claudeSevenDayPercent: claudeSevenDayPercent,
312− zaiFiveHourPercent: zaiFiveHourPercent,
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
315− )
316− }
317−
318− /// Whether z.ai is currently in peak hours (06:00–10:00 UTC).
319− static var zaiInPeakHours: Bool {
320− let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321− return hour >= 6 && hour < 10
207+ Self.statusBarText(balance: balance, isLoading: isLoading)
322208 }
323209
324210 /// The formatted balance string for display in the popover.
@@ -341,118 +227,76 @@ final class ViewModel: ObservableObject {
341227 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342228 }
343229
344− // MARK: - History (Hyper)
230+ // MARK: - History
345231
346− /// The last 24 hours of successful balance fetches, oldest first.
232+ /// The last 24 hours of successful fetches, oldest first.
347233 var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
234+ history.map { (date: $0.date, balance: $0.balance) }
349235 }
350236
351237 /// The direction of travel across the most recent handful of observations.
352238 /// `.unknown` until there are at least two points to compare.
353− var balanceTrend: MetricTrend {
354− metricTrend(history)
355− }
356−
357− /// The sparkline's data for the Hyper balance.
358− var balanceSparklineValues: [Int] {
359− history.map { $0.value }
360− }
361−
362− // MARK: - History (Claude)
363−
364− /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365− var claudeSparklineValues: [Int] {
366− claudeHistory.map { $0.value }
367− }
368−
369− /// The direction of travel for the Claude 5-hour %.
370− var claudeTrend: MetricTrend {
371− metricTrend(claudeHistory)
372− }
373−
374− // MARK: - History (z.ai)
375−
376− /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377− var zaiSparklineValues: [Int] {
378− zaiHistory.map { $0.value }
379− }
380−
381− /// The direction of travel for the z.ai 5-hour %.
382− var zaiTrend: MetricTrend {
383− metricTrend(zaiHistory)
384− }
385−
386− // MARK: - History Helpers
387−
388− /// Computes the trend from a generic history array.
389− private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390− guard points.count >= 2 else { return .unknown }
391− let recent = points.suffix(5)
239+ var balanceTrend: BalanceTrend {
240+ guard history.count >= 2 else { return .unknown }
241+ let recent = history.suffix(5)
392242 guard let first = recent.first, let last = recent.last else { return .unknown }
393− if last.value > first.value { return .increasing }
394− if last.value < first.value { return .decreasing }
243+ if last.balance > first.balance { return .increasing }
244+ if last.balance < first.balance { return .decreasing }
395245 return .stable
396246 }
397247
398248 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399− private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400− array.append(MetricPoint(date: date, value: value))
249+ private func recordHistory(balance: Int, at date: Date) {
250+ history.append(BalancePoint(date: date, balance: balance))
401251
402252 let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
253+ history.removeAll { $0.date < cutoff }
404254
405− if array.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
255+ if history.count > Self.maxHistoryEntries {
256+ history.removeFirst(history.count - Self.maxHistoryEntries)
407257 }
408258 }
409259
410260 // MARK: - Refresh
411261
412− /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
262+ /// Refreshes the Hyper balance and the Claude usage, both at once.
413263 ///
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.
264+ /// The two are independent: either can fail, or not be configured at all, without
265+ /// touching the other. Cancels any previously in-flight refresh to prevent races.
416266 func refresh() {
417267 refreshTask?.cancel()
418268
419269 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
420270 let key = activeAPIKey
421− let zaiKey = activeZaiAPIKey
422271 if key.isEmpty {
423272 // Nothing to fetch, and nothing worth keeping: the balance, its history and
424− // any error all belonged to a key that is no longer there. Claude and z.ai
425− // are left alone — they don't depend on the Hyper key.
273+ // any error all belonged to a key that is no longer there. Claude is left
274+ // alone — it doesn't depend on the Hyper key.
426275 balance = nil
427276 errorMessage = nil
428277 lastUpdated = nil
429278 history.removeAll()
279+ isLoading = false
430280 }
431281
432282 refreshTask = Task { @MainActor in
433283 // Check cancellation before setting isLoading — a previous
434284 // refresh() may have cancelled us and already set isLoading = false.
435285 guard !Task.isCancelled else { return }
436− // Loading state covers all fetches: the popover's loading view depends
437− // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
438− isLoading = true
286+ if !key.isEmpty { isLoading = true }
439287 // Don't clear the errors here — keep showing the last one
440288 // until we have a successful result.
441289
442− // All three requests go out at once; none waits on the others.
290+ // Both requests go out at once; neither waits on the other.
443291 async let balanceOutcome = fetchBalance(key: key)
444292 async let claudeOutcome = fetchClaudeUsage()
445− async let zaiOutcome = fetchZaiUsage(key: zaiKey)
446293
447294 let balanceResult = await balanceOutcome
448295 let claudeResult = await claudeOutcome
449− let zaiResult = await zaiOutcome
450296 guard !Task.isCancelled else { return }
451297
452298 applyBalance(balanceResult)
453299 applyClaude(claudeResult)
454− applyZai(zaiResult)
455− isLoading = false
456300 }
457301 }
458302
@@ -478,7 +322,7 @@ final class ViewModel: ObservableObject {
478322 errorMessage = nil
479323 let now = Date()
480324 lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
325+ recordHistory(balance: newBalance, at: now)
482326
483327 // Low-balance notification: on threshold crossing (≥10 → <10)
484328 // OR on first successful fetch if already below threshold.
@@ -494,6 +338,8 @@ final class ViewModel: ObservableObject {
494338 // (it's already nil in that case, so nothing to do).
495339 errorMessage = error.localizedDescription
496340 }
341+
342+ isLoading = false
497343 }
498344
499345 // MARK: - Claude Usage
@@ -523,30 +369,12 @@ final class ViewModel: ObservableObject {
523369 claudeUsage = nil
524370 claudePlan = nil
525371 claudeError = nil
526− claudeHistory.removeAll()
527372
528373 case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531374 claudeUsage = report.usage
532375 claudePlan = report.subscriptionType
533376 claudeError = nil
534377
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−
550378 case .failure(let message):
551379 // As with the balance: keep the last good numbers on screen and say what
552380 // went wrong, rather than blanking the section on a transient failure.
@@ -554,25 +382,6 @@ final class ViewModel: ObservableObject {
554382 }
555383 }
556384
557− /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
558− var claudeFiveHourPercent: Int? {
559− guard let fiveHour = claudeUsage?.fiveHour else { return nil }
560− return Int(fiveHour.utilization.rounded())
561− }
562−
563− /// The 7-day window percentage for the menu bar title, or `nil` when absent.
564− /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
565− /// has been used — on Max plans the Opus cap is the one that binds first.
566− var claudeSevenDayPercent: Int? {
567− if let sevenDay = claudeUsage?.sevenDay {
568− return Int(sevenDay.utilization.rounded())
569− }
570− if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571− return Int(opus.utilization.rounded())
572− }
573− return nil
574− }
575−
576385 /// Display label for the Claude plan, e.g. "Pro" or "Max".
577386 var claudePlanLabel: String? {
578387 guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -588,88 +397,6 @@ final class ViewModel: ObservableObject {
588397 }
589398 }
590399
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−
673400 // MARK: - Clipboard
674401
675402 /// Copies the current balance to the clipboard as plain digits (no grouping
@@ -690,41 +417,7 @@ final class ViewModel: ObservableObject {
690417 }
691418 }
692419
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
420+ // MARK: - Low Balance Notification
728421
729422 private func sendLowBalanceNotification(balance: Int) {
730423 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -738,7 +431,7 @@ final class ViewModel: ObservableObject {
738431 return
739432 }
740433 let content = UNMutableNotificationContent()
741− content.title = "Low Usage Menubar"
434+ content.title = "Low Hyper Credits"
742435 content.body = body
743436 content.sound = .default
744437
@@ -753,29 +446,6 @@ final class ViewModel: ObservableObject {
753446 }
754447 }
755448
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−
779449 // MARK: - API Key
780450
781451 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -790,7 +460,6 @@ final class ViewModel: ObservableObject {
790460 }
791461 apiKeyInput = ""
792462 activeAPIKey = ""
793− hyperConfigured = false
794463 // refresh() with an empty key clears balance, error, history, and
795464 // cancels any in-flight task from the previous key.
796465 refresh()
@@ -814,7 +483,6 @@ final class ViewModel: ObservableObject {
814483 // Show the trimmed key that was actually stored.
815484 apiKeyInput = key
816485 activeAPIKey = key
817− hyperConfigured = true
818486 refresh()
819487 }
820488
@@ -830,53 +498,6 @@ final class ViewModel: ObservableObject {
830498 }
831499 }
832500
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−
880501 // MARK: - Launch at Login
881502
882503 func updateLaunchAtLogin(_ enabled: Bool) {
@@ -896,4 +517,4 @@ final class ViewModel: ObservableObject {
896517 isInitializingLaunchAtLogin = false
897518 }
898519 }
899−}
520+}
UsageMenubarTests/ClaudeUsageClientTests.swift → HyperCreditsMenubarTests/ClaudeUsageClientTests.swiftrenamed+273−78
@@ -1,9 +1,9 @@
11import XCTest
22import Foundation
3−@testable import UsageMenubar
3+@testable import HyperCreditsMenubar
44
5−/// The instant the countdown tests reckon from, so "3h 20m" is a fact rather than a race
6−/// against the wall clock.
5+/// The instant every test pretends it is. Injected into the client, so token expiry is
6+/// a decision the test makes rather than a race against the wall clock.
77private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
88
99final class ClaudeUsageClientTests: XCTestCase {
@@ -33,17 +33,22 @@ final class ClaudeUsageClientTests: XCTestCase {
3333 return ClaudeUsageClient(
3434 session: URLSession(configuration: config),
3535 store: store,
36− retryBaseDelay: retryBaseDelay
36+ retryBaseDelay: retryBaseDelay,
37+ now: { fixedNow }
3738 )
3839 }
3940
4041 private func credentials(
4142 accessToken: String = "access-1",
43+ refreshToken: String = "refresh-1",
44+ expiresAt: Date = fixedNow.addingTimeInterval(3600),
4245 subscriptionType: String? = "max"
4346 ) -> ClaudeOAuthCredentials {
4447 ClaudeOAuthCredentials(
4548 claudeAiOauth: .init(
4649 accessToken: accessToken,
50+ refreshToken: refreshToken,
51+ expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
4752 scopes: ["user:inference"],
4853 subscriptionType: subscriptionType,
4954 rateLimitTier: nil
@@ -51,6 +56,11 @@ final class ClaudeUsageClientTests: XCTestCase {
5156 )
5257 }
5358
59+ /// Credentials whose access token is already spent, so a fetch has to refresh first.
60+ private func expiredCredentials() -> ClaudeOAuthCredentials {
61+ credentials(expiresAt: fixedNow.addingTimeInterval(-1))
62+ }
63+
5464 private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
5565 Data("""
5666 {
@@ -60,6 +70,21 @@ final class ClaudeUsageClientTests: XCTestCase {
6070 """.utf8)
6171 }
6272
73+ private func tokenJSON(
74+ accessToken: String = "access-2",
75+ refreshToken: String? = "refresh-2",
76+ expiresIn: Int? = 3600
77+ ) -> Data {
78+ var fields = [#""access_token": "\#(accessToken)""#]
79+ if let refreshToken { fields.append(#""refresh_token": "\#(refreshToken)""#) }
80+ if let expiresIn { fields.append(#""expires_in": \#(expiresIn)"#) }
81+ return Data("{\(fields.joined(separator: ", "))}".utf8)
82+ }
83+
84+ private func requests(to url: URL) -> [URLRequest] {
85+ StubURLProtocol.recordedRequests.filter { $0.url == url }
86+ }
87+
6388 // MARK: - Usage decoding
6489
6590 func testDecodesUsagePayload() throws {
@@ -231,44 +256,193 @@ final class ClaudeUsageClientTests: XCTestCase {
231256 XCTAssertEqual(StubURLProtocol.requestCount, 0)
232257 }
233258
234− func testTheStoredTokenIsSpentExactlyAsItIs() async throws {
259+ func testValidTokenIsUsedWithoutRefreshing() async throws {
235260 let store = FakeClaudeCredentialStore(credentials: credentials())
236261 StubURLProtocol.enqueue(.status(200, usageJSON()))
237262
238263 _ = try await makeClient(store: store).fetchUsage()
239264
240− // One request, to the usage endpoint. No token endpoint, no grant, no rotation:
241− // refreshing behind the CLI's back is what signed the user out to begin with.
242265 XCTAssertEqual(StubURLProtocol.requestCount, 1)
243− XCTAssertEqual(StubURLProtocol.recordedRequests[0].url, ClaudeUsageClient.usageEndpoint)
244− XCTAssertEqual(StubURLProtocol.recordedRequests[0].httpMethod, "GET")
266+ XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267+ XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
245268 }
246269
247− func testCredentialsAreReReadOnEveryFetch() async throws {
248− // The CLI refreshes on its own schedule and we never do, so the only way to see
249− // a new token is to go back to the store each time. Caching one would strand the
250− // app on a token the CLI has long since replaced.
251− let store = FakeClaudeCredentialStore(credentials: credentials(accessToken: "access-1"))
252− StubURLProtocol.enqueue(.status(200, usageJSON()), .status(200, usageJSON()))
270+ // MARK: - Token refresh
253271
254− let client = makeClient(store: store)
255− _ = try await client.fetchUsage()
272+ func testExpiredTokenIsRefreshedBeforeTheUsageRequest() async throws {
273+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
274+ StubURLProtocol.enqueue(
275+ .status(200, tokenJSON(accessToken: "access-2")),
276+ .status(200, usageJSON())
277+ )
278+
279+ let report = try await makeClient(store: store).fetchUsage()
280+
281+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
283+
284+ let recorded = StubURLProtocol.recordedRequests
285+ XCTAssertEqual(recorded[0].url, ClaudeUsageClient.tokenEndpoint)
286+ XCTAssertEqual(recorded[0].httpMethod, "POST")
287+ XCTAssertEqual(recorded[1].url, ClaudeUsageClient.usageEndpoint)
288+ XCTAssertEqual(
289+ recorded[1].value(forHTTPHeaderField: "Authorization"),
290+ "Bearer access-2",
291+ "The usage request has to carry the refreshed token, not the spent one"
292+ )
293+ }
294+
295+ func testRefreshRotatesAndPersistsTheCredentials() async throws {
296+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
297+ StubURLProtocol.enqueue(
298+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: "refresh-2", expiresIn: 3600)),
299+ .status(200, usageJSON())
300+ )
301+
302+ _ = try await makeClient(store: store).fetchUsage()
303+
304+ let saved = try XCTUnwrap(store.savedCredentials.last)
305+ let token = saved.credentials.claudeAiOauth
306+ XCTAssertEqual(token.accessToken, "access-2")
307+ XCTAssertEqual(
308+ token.refreshToken,
309+ "refresh-2",
310+ "The server rotated the refresh token; failing to store it would sign Claude Code out"
311+ )
312+ XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313+ XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
314+ }
315+
316+ func testRefreshKeepsTheOldRefreshTokenWhenTheServerDoesNotRotateIt() async throws {
317+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
318+ StubURLProtocol.enqueue(
319+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: nil)),
320+ .status(200, usageJSON())
321+ )
322+
323+ _ = try await makeClient(store: store).fetchUsage()
324+
325+ let saved = try XCTUnwrap(store.savedCredentials.last)
326+ XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
327+ }
328+
329+ func testRefreshWithoutExpiresInAssumesEightHours() async throws {
330+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
331+ StubURLProtocol.enqueue(
332+ .status(200, tokenJSON(accessToken: "access-2", expiresIn: nil)),
333+ .status(200, usageJSON())
334+ )
335+
336+ _ = try await makeClient(store: store).fetchUsage()
337+
338+ let saved = try XCTUnwrap(store.savedCredentials.last)
339+ XCTAssertEqual(
340+ saved.credentials.claudeAiOauth.expiresAt,
341+ Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
342+ )
343+ }
344+
345+ func testRefreshSendsTheGrant() async throws {
346+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
348+
349+ _ = try await makeClient(store: store).fetchUsage()
256350
257− store.replace(with: credentials(accessToken: "access-2"))
258− _ = try await client.fetchUsage()
351+ let body = try XCTUnwrap(StubURLProtocol.recordedBodies.first ?? nil)
352+ let grant = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: String])
353+ XCTAssertEqual(grant["grant_type"], "refresh_token")
354+ XCTAssertEqual(grant["refresh_token"], "refresh-1")
355+ XCTAssertEqual(grant["client_id"], ClaudeUsageClient.clientID)
356+ XCTAssertEqual(grant["scope"], ClaudeUsageClient.scope)
357+ }
259358
260− XCTAssertEqual(store.loadCount, 2)
261− let sent = StubURLProtocol.recordedRequests.map { $0.value(forHTTPHeaderField: "Authorization") }
262− XCTAssertEqual(sent, ["Bearer access-1", "Bearer access-2"])
359+ func testRefreshFallsBackToFormEncoding() async throws {
360+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
361+ StubURLProtocol.enqueue(
362+ .status(400, Data()), // the JSON body is rejected
363+ .status(200, tokenJSON(accessToken: "access-2")),
364+ .status(200, usageJSON())
365+ )
366+
367+ _ = try await makeClient(store: store).fetchUsage()
368+
369+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
370+ let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371+ $0.value(forHTTPHeaderField: "Content-Type")
372+ }
373+ XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
374+
375+ let form = try XCTUnwrap(StubURLProtocol.recordedBodies[1].flatMap { String(data: $0, encoding: .utf8) })
376+ XCTAssertTrue(form.contains("grant_type=refresh_token"), "Got: \(form)")
377+ XCTAssertTrue(form.contains("refresh_token=refresh-1"), "Got: \(form)")
378+ XCTAssertTrue(
379+ form.contains("user%3Ainference"),
380+ "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
381+ )
382+ }
383+
384+ func testRefreshFailureThrowsTokenRefreshFailed() async {
385+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386+ StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
387+
388+ do {
389+ _ = try await makeClient(store: store).fetchUsage()
390+ XCTFail("Expected tokenRefreshFailed error")
391+ } catch ClaudeUsageError.tokenRefreshFailed {
392+ // expected
393+ } catch {
394+ XCTFail("Unexpected error: \(error)")
395+ }
396+
397+ // Both encodings tried, and no usage request made with a token we never got.
398+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
399+ XCTAssertTrue(requests(to: ClaudeUsageClient.usageEndpoint).isEmpty)
400+ }
401+
402+ func testConcurrentFetchesShareASingleRefresh() async throws {
403+ // Two refreshes at once would each spend the refresh token, and whichever
404+ // finished second would be left holding one the server had already retired.
405+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
406+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
407+
408+ let client = makeClient(store: store)
409+ async let first = client.fetchUsage()
410+ async let second = client.fetchUsage()
411+ _ = try await (first, second)
412+
413+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414+ XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415+ XCTAssertEqual(store.savedCredentials.count, 1)
263416 }
264417
265418 // MARK: - Rejected tokens
266419
267− func testRejectedTokenSurfacesInvalidCredentialsWithoutRefreshing() async {
268− // A spent or revoked token is the user's to fix by running `claude`. The app
269− // attempting the refresh itself is the bug this whole client is shaped around.
420+ func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421+ // A token can be revoked server-side well before its stated expiry.
270422 let store = FakeClaudeCredentialStore(credentials: credentials())
271− StubURLProtocol.enqueue(.status(401, Data()))
423+ StubURLProtocol.enqueue(
424+ .status(401, Data()),
425+ .status(200, tokenJSON(accessToken: "access-2")),
426+ .status(200, usageJSON())
427+ )
428+
429+ let report = try await makeClient(store: store).fetchUsage()
430+
431+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
433+ XCTAssertEqual(
434+ StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
435+ "Bearer access-2"
436+ )
437+ }
438+
439+ func testRepeatedRejectionSurfacesInvalidCredentials() async {
440+ let store = FakeClaudeCredentialStore(credentials: credentials())
441+ StubURLProtocol.enqueue(
442+ .status(401, Data()),
443+ .status(200, tokenJSON(accessToken: "access-2")),
444+ .status(401, Data())
445+ )
272446
273447 do {
274448 _ = try await makeClient(store: store).fetchUsage()
@@ -279,16 +453,16 @@ final class ClaudeUsageClientTests: XCTestCase {
279453 XCTFail("Unexpected error: \(error)")
280454 }
281455
282− XCTAssertEqual(
283− StubURLProtocol.requestCount,
284− 1,
285− "A 401 must end the fetch — no refresh, and no retry of the request either"
286− )
456+ // One refresh, and no attempt at a second: it plainly isn't the token.
457+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
458+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
287459 }
288460
289− func testForbiddenIsTreatedAsARejectedToken() async {
461+ func testFailedRefreshAfterRejectionSurfacesTheRejection() async {
462+ // "Couldn't refresh" says less than "the server rejected your token", and both
463+ // point at the same fix, so the rejection is the more useful of the two.
290464 let store = FakeClaudeCredentialStore(credentials: credentials())
291− StubURLProtocol.enqueue(.status(403, Data()))
465+ StubURLProtocol.enqueue(.status(401, Data()), .status(403, Data()))
292466
293467 do {
294468 _ = try await makeClient(store: store).fetchUsage()
@@ -298,15 +472,20 @@ final class ClaudeUsageClientTests: XCTestCase {
298472 } catch {
299473 XCTFail("Unexpected error: \(error)")
300474 }
301−
302− XCTAssertEqual(StubURLProtocol.requestCount, 1)
303475 }
304476
305− func testTheInvalidCredentialsMessagePointsAtTheCLI() {
306− // The app can't mint a token, so the only useful thing it can say is where one
307− // comes from.
308− let message = ClaudeUsageError.invalidCredentials.errorDescription
309− XCTAssertEqual(message, "Claude sign-in expired. Run `claude` in a terminal to sign in again.")
477+ func testForbiddenIsTreatedAsARejectedToken() async {
478+ let store = FakeClaudeCredentialStore(credentials: credentials())
479+ StubURLProtocol.enqueue(.status(403, Data()))
480+
481+ do {
482+ _ = try await makeClient(store: store).fetchUsage()
483+ XCTFail("Expected invalidCredentials error")
484+ } catch ClaudeUsageError.invalidCredentials {
485+ // expected
486+ } catch {
487+ XCTFail("Unexpected error: \(error)")
488+ }
310489 }
311490
312491 // MARK: - Retries
@@ -427,8 +606,7 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
427606
428607 private var directory: URL!
429608
430− /// A credentials blob as Claude Code actually writes it: the two token fields we no
431− /// longer model, and two more we never did.
609+ /// A credentials blob carrying two fields we deliberately don't model.
432610 private let credentialsJSON = """
433611 {
434612 "claudeAiOauth": {
@@ -473,11 +651,12 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
473651 func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
474652 let file = try writeCredentialsFile()
475653
476− let credentials = try XCTUnwrap(makeStore(file: file).load())
654+ let stored = try XCTUnwrap(makeStore(file: file).load())
477655
478− XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
479− XCTAssertEqual(credentials.claudeAiOauth.subscriptionType, "max")
480− XCTAssertEqual(credentials.claudeAiOauth.scopes, ["user:inference", "user:profile"])
656+ XCTAssertEqual(stored.credentials.claudeAiOauth.accessToken, "access-1")
657+ XCTAssertEqual(stored.credentials.claudeAiOauth.refreshToken, "refresh-1")
658+ XCTAssertEqual(stored.credentials.claudeAiOauth.subscriptionType, "max")
659+ XCTAssertEqual(stored.source, .file(file))
481660 }
482661
483662 func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
@@ -493,65 +672,81 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
493672 XCTAssertNil(makeStore(file: file).load())
494673 }
495674
496− func testABlobWithoutTheFieldsWeDroppedStillLoads() throws {
497− // `refreshToken` and `expiresAt` are no longer modelled, so their absence must
498− // not fail the decode — nor must their presence, above.
499− let file = directory.appendingPathComponent(".credentials.json")
500− try Data(#"{"claudeAiOauth": {"accessToken": "access-1"}}"#.utf8).write(to: file)
675+ func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676+ let file = try writeCredentialsFile()
677+ let store = makeStore(file: file)
501678
502− let credentials = try XCTUnwrap(makeStore(file: file).load())
679+ var stored = try XCTUnwrap(store.load())
680+ stored.credentials.claudeAiOauth.accessToken = "access-2"
681+ stored.credentials.claudeAiOauth.refreshToken = "refresh-2"
682+ stored.credentials.claudeAiOauth.expiresAt = 4_100_000_000_000
683+ store.save(stored)
503684
504− XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
505− XCTAssertNil(credentials.claudeAiOauth.subscriptionType)
685+ let written = try XCTUnwrap(
686+ try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
687+ )
688+ let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
689+
690+ XCTAssertEqual(oauth["accessToken"] as? String, "access-2")
691+ XCTAssertEqual(oauth["refreshToken"] as? String, "refresh-2")
692+ XCTAssertEqual((oauth["expiresAt"] as? NSNumber)?.int64Value, 4_100_000_000_000)
693+
694+ // This is the file the user's CLI signs in with. Round-tripping it through our
695+ // own type would drop the fields we don't model, so the write patches the raw
696+ // JSON instead — and these two have to survive it.
697+ XCTAssertEqual(oauth["somethingWeDoNotModel"] as? String, "keep me")
698+ XCTAssertEqual(written["anotherTopLevelKey"] as? String, "keep me too")
699+ XCTAssertEqual(oauth["subscriptionType"] as? String, "max")
506700 }
507701
508− func testLoadingLeavesTheCredentialsFileByteForByteUntouched() throws {
509− // The regression guard for the bug this store was built around: the app used to
510− // write a rotated token back here, racing the CLI's own refresh and signing the
511− // user out of their terminal. It must now be a reader and nothing else.
702+ func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
512703 let file = try writeCredentialsFile()
513− let before = try Data(contentsOf: file)
704+ let store = makeStore(file: file)
514705
515− _ = makeStore(file: file).load()
706+ let stored = try XCTUnwrap(store.load())
707+ store.save(stored)
516708
517− XCTAssertEqual(try Data(contentsOf: file), before)
709+ let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
710+ XCTAssertEqual(
711+ (attributes[.posixPermissions] as? NSNumber)?.int16Value,
712+ 0o600,
713+ "An atomic write replaces the file, so the mode has to be reapplied"
714+ )
518715 }
519716}
520717
521718// MARK: - Fake credential store
522719
523720/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
524−///
525−/// It cannot write, because the protocol no longer can: the CLI is the only thing that
526−/// may rotate these tokens. `replace(with:)` is the test standing in for the CLI having
527−/// done exactly that behind the app's back.
528721final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
529722 private let lock = NSLock()
530− private var current: ClaudeOAuthCredentials?
531− private var loads = 0
723+ private var current: StoredClaudeCredentials?
724+ private var writes: [StoredClaudeCredentials] = []
532725
533− init(credentials: ClaudeOAuthCredentials?) {
534− current = credentials
726+ init(
727+ credentials: ClaudeOAuthCredentials?,
728+ source: ClaudeCredentialSource = .keychain(account: "tester")
729+ ) {
730+ current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
535731 }
536732
537− /// How many times the client has gone back to the store — it should be once per fetch.
538− var loadCount: Int {
733+ /// Every write-back the client has made, oldest first.
734+ var savedCredentials: [StoredClaudeCredentials] {
539735 lock.lock()
540736 defer { lock.unlock() }
541− return loads
737+ return writes
542738 }
543739
544− /// Stands in for the CLI refreshing its own token between two fetches.
545− func replace(with credentials: ClaudeOAuthCredentials?) {
740+ func load() -> StoredClaudeCredentials? {
546741 lock.lock()
547742 defer { lock.unlock() }
548− current = credentials
743+ return current
549744 }
550745
551− func load() -> ClaudeOAuthCredentials? {
746+ func save(_ stored: StoredClaudeCredentials) {
552747 lock.lock()
553748 defer { lock.unlock() }
554− loads += 1
555− return current
749+ current = stored
750+ writes.append(stored)
556751 }
557752}
UsageMenubarTests/CreditsCheckerTests.swift → HyperCreditsMenubarTests/CreditsCheckerTests.swiftrenamed+1−20
@@ -1,7 +1,7 @@
11import XCTest
22import Foundation
33import Security
4−@testable import UsageMenubar
4+@testable import HyperCreditsMenubar
55
66final class CreditsCheckerTests: XCTestCase {
77
@@ -519,22 +519,10 @@ 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−
533522 static func reset() {
534523 lock.lock()
535524 defer { lock.unlock() }
536525 outcomes = []
537− urlMatchedOutcomes = [:]
538526 lastOutcome = nil
539527 requests = []
540528 bodies = []
@@ -560,19 +548,12 @@ final class StubURLProtocol: URLProtocol {
560548 }
561549
562550 /// 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.
565551 private static func nextOutcome(for request: URLRequest) -> Outcome {
566552 lock.lock()
567553 defer { lock.unlock() }
568554 requests.append(request)
569555 bodies.append(drainBody(from: request))
570556
571− if let path = request.url?.path,
572− let matched = urlMatchedOutcomes[path] {
573− return matched
574− }
575−
576557 if !outcomes.isEmpty {
577558 lastOutcome = outcomes.removeFirst()
578559 }
UsageMenubarTests/VersionFormatterTests.swift → HyperCreditsMenubarTests/VersionFormatterTests.swiftrenamed+1−1
@@ -1,5 +1,5 @@
11import XCTest
2−@testable import UsageMenubar
2+@testable import HyperCreditsMenubar
33
44final class VersionFormatterTests: XCTestCase {
55
README.md+65−85
@@ -1,97 +1,74 @@
1−<p align="center">
2− <img src="UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.png" width="128" height="128" alt="Usage Menubar app icon">
3−</p>
1+# HyperCreditsMenubar
42
5−# UsageMenubar
6−
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.
3+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.
84
95## Features
106
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
7+- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
8+- 🤖 **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
9+- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
10+- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
11+- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
12+- 🖱️ **Right-click menu** — right-click the menu bar icon for quick access to Refresh, Open hyper.charm.land, and Quit
13+- 🔔 **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
14+- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
15+- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
16+- 🔐 **Secure input** — API key field is masked
1717- 🚀 **Launch at login** — optional, using `SMAppService`
18+- 🔑 **Get key link** — quick link to [hyper.charm.land](https://hyper.charm.land) right next to the API key field
1819- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
1920
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−
3421## Installation
3522
3623### Homebrew (recommended)
3724
3825```bash
3926brew tap ilyakooo0/tap
40−brew install --cask usage-menubar
27+brew install --cask hyper-credits-menubar
4128```
4229
4330Or in one command:
4431
4532```bash
46−brew install --cask ilyakooo0/tap/usage-menubar
33+brew install --cask ilyakooo0/tap/hyper-credits-menubar
4734```
4835
4936### Download the pre-built release
5037
51−1. Go to the [Releases page](https://github.com/ilyakooo0/usage-menubar/releases)
52−2. Download the latest `UsageMenubar-*.zip`
53−3. Unzip and move `UsageMenubar.app` to `/Applications`
54−4. Launch the app — it will appear in your menu bar
38+1. Go to the [Releases page](https://github.com/ilyakooo0/hyper-credits-menubar/releases)
39+2. Download the latest `HyperCreditsMenubar-*.zip`
40+3. Unzip and move `HyperCreditsMenubar.app` to `/Applications`
41+4. Launch the app — it will appear in your menu bar as ⚡
5542
5643> **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.
5744
58−## Setup
59−
60−Click the menu bar icon to open the popover. Each service is configured independently:
45+### Set your API key
6146
62−### Hyper credits
47+1. Click the ⚡ icon in your menu bar
48+2. Enter your Hyper API key (starts with `sk-...`) in the **API Key** field
49+3. Click **Save**
50+4. Your balance will appear immediately
6351
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
52+You can get your API key from [hyper.charm.land](https://hyper.charm.land).
6753
68−Get your key from [hyper.charm.land](https://hyper.charm.land).
54+### Claude Code limits (optional, no setup)
6955
70−### Claude Code limits (no setup)
56+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.
7157
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.
58+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.
7359
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.
60+Two things worth knowing:
7561
7662- **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.
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).
63+- **The credentials are read-only, with one exception.** When the access token has expired, the app refreshes it against Anthropic's token endpoint and writes the result back to wherever it read it from — exactly as Claude Code itself would. Nothing else about them is ever modified, and the fields the app doesn't model are preserved verbatim, so your CLI session keeps working.
8664
8765## Settings
8866
89−Everything is configured from the popover — click the menu bar icon.
67+Everything is configured from the popover — click the ⚡ icon in the menu bar.
9068
9169| Setting | Where | Notes |
9270| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
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. |
71+| **API key** | **API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
9572| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
9673| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
9774| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
@@ -111,20 +88,20 @@ The app also refreshes automatically when your Mac wakes from sleep.
11188
11289```bash
11390# Clone the repository
114−git clone https://github.com/ilyakooo0/usage-menubar.git
115−cd usage-menubar
91+git clone https://github.com/ilyakooo0/hyper-credits-menubar.git
92+cd hyper-credits-menubar
11693
11794# Generate the Xcode project
11895xcodegen generate
11996
12097# Open in Xcode
121−open UsageMenubar.xcodeproj
98+open HyperCreditsMenubar.xcodeproj
12299
123100# Or build from the command line
124101xcodebuild \
125− -scheme UsageMenubar \
102+ -scheme HyperCreditsMenubar \
126103 -configuration Release \
127− -archivePath build/UsageMenubar.xcarchive \
104+ -archivePath build/HyperCreditsMenubar.xcarchive \
128105 archive \
129106 CODE_SIGNING_ALLOWED=NO
130107```
@@ -133,7 +110,7 @@ xcodebuild \
133110
134111```bash
135112xcodebuild test \
136− -scheme UsageMenubar \
113+ -scheme HyperCreditsMenubar \
137114 -configuration Debug \
138115 -destination "platform=macOS"
139116```
@@ -153,25 +130,22 @@ The version is derived automatically from the commit timestamp in the GitHub Act
153130## Project structure
154131
155132```
156−usage-menubar/
133+hyper-credits-menubar/
157134├── project.yml # xcodegen project definition
158−├── UsageMenubar/
159−│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
160−│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
135+├── HyperCreditsMenubar/
136+│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer, sleep/wake
137+│ ├── ViewModel.swift # Observable state: balance, Claude usage, refresh, notifications
161138│ ├── MenuView.swift # SwiftUI popover view
162−│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
139+│ ├── CreditsChecker.swift # API client (GET /v1/credits)
163140│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
164−│ ├── ZaiUsageClient.swift # z.ai API client (GET /monitor/usage/quota/limit)
165−│ ├── KeychainHelper.swift # Keychain wrapper (Hyper + z.ai keys)
141+│ ├── KeychainHelper.swift # Keychain wrapper for API key
166142│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
167143│ ├── Info.plist # LSUIElement=true (agent app)
168−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
144+│ ├── HyperCreditsMenubar.entitlements # Network client; deliberately not sandboxed
169145│ └── Assets.xcassets/ # App icon, accent color
170−├── UsageMenubarTests/
171−│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
172−│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173−│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174−│ ├── StatusBarTextTests.swift # Menu bar title formatting
146+├── HyperCreditsMenubarTests/
147+│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
148+│ ├── ClaudeUsageClientTests.swift # Usage decoding, token refresh, retry, credential store
175149│ └── VersionFormatterTests.swift # Version formatting tests
176150├── .github/workflows/
177151│ └── release.yml # Build + release on push to master
@@ -182,18 +156,24 @@ usage-menubar/
182156
183157## How it works
184158
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.
159+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).
160+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and settings.
161+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).
162+4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
163+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.
164+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.
165+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.
166+8. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
167+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`), refreshes the access token when it has expired, and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` and `anthropic-beta: oauth-2025-04-20`. It is an actor so that overlapping refreshes (timer, manual click, wake) join a single in-flight token exchange — the endpoint rotates the refresh token, and rotating it twice would sign Claude Code out. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
168+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.
19516911. **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.
196170
171+## Credits
172+
173+- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
174+- **API**: [Hyper by Charm](https://hyper.charm.land)
175+- **Built with**: Swift 5.9, SwiftUI, macOS 14+
176+
197177## License
198178
199−MIT — see [LICENSE](LICENSE).
179+MIT — see [LICENSE](LICENSE).
UsageMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsondeleted−68
@@ -1,68 +0,0 @@
1−{
2− "images": [
3− {
4− "filename": "icon-16.png",
5− "idiom": "mac",
6− "scale": "1x",
7− "size": "16x16"
8− },
9− {
10− "filename": "icon-32.png",
11− "idiom": "mac",
12− "scale": "2x",
13− "size": "16x16"
14− },
15− {
16− "filename": "icon-32.png",
17− "idiom": "mac",
18− "scale": "1x",
19− "size": "32x32"
20− },
21− {
22− "filename": "icon-64.png",
23− "idiom": "mac",
24− "scale": "2x",
25− "size": "32x32"
26− },
27− {
28− "filename": "icon-128.png",
29− "idiom": "mac",
30− "scale": "1x",
31− "size": "128x128"
32− },
33− {
34− "filename": "icon-256.png",
35− "idiom": "mac",
36− "scale": "2x",
37− "size": "128x128"
38− },
39− {
40− "filename": "icon-256.png",
41− "idiom": "mac",
42− "scale": "1x",
43− "size": "256x256"
44− },
45− {
46− "filename": "icon-512.png",
47− "idiom": "mac",
48− "scale": "2x",
49− "size": "256x256"
50− },
51− {
52− "filename": "icon-512.png",
53− "idiom": "mac",
54− "scale": "1x",
55− "size": "512x512"
56− },
57− {
58− "filename": "icon-1024.png",
59− "idiom": "mac",
60− "scale": "2x",
61− "size": "512x512"
62− }
63− ],
64− "info": {
65− "author": "xcode",
66− "version": 1
67− }
68−}
UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-128.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-16.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-256.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-32.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-512.pngdeleted

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-64.pngdeleted

Binary file not shown.

UsageMenubar/MenuView.swiftdeleted−741
@@ -1,741 +0,0 @@
1−import SwiftUI
2−
3−/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
4−struct MenuView: View {
5− @ObservedObject var viewModel: ViewModel
6−
7− 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")!
9−
10− // MARK: - Type Scale
11−
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.
16− private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
17− .monospacedDigit()
18− private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
19− private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
20− private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
21− private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
22−
23− var body: some View {
24− VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
27− }
28− if showsClaude {
29− serviceBreak
30− claude
31− }
32− if showsZai {
33− serviceBreak
34− zai
35− }
36− hairline
37− settings
38− hairline
39− footer
40− }
41− .padding(.horizontal, 18)
42− .padding(.top, 18)
43− .padding(.bottom, 14)
44− .frame(width: 280)
45− // Set as the environment font so the controls inherit the rounded design too.
46− .font(Self.controlFont)
47− .background(Color(nsColor: .windowBackgroundColor))
48− .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
49− .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)
52− .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53− .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54− .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
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)
58− .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59− .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)
62− }
63−
64− /// A section rule light enough to read as a pause rather than a border.
65− private var hairline: some View {
66− Divider().opacity(0.3)
67− }
68−
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.
71− private var serviceBreak: some View {
72− hairline.padding(.vertical, 5)
73− }
74−
75− /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
77− private func sectionHeader(_ title: String, systemImage: String) -> some View {
78− HStack(spacing: 5) {
79− Image(systemName: systemImage)
80− .font(.system(size: 11, weight: .semibold))
81−
82− Text(title)
83− .font(Self.sectionFont)
84− }
85− }
86−
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)
105−
106− if let symbol = trend.symbolName {
107− Image(systemName: symbol)
108− .font(.system(size: 12, weight: .semibold))
109− .foregroundColor(.secondary)
110− .transition(.opacity)
111− }
112− }
113− .contentShape(Rectangle())
114− }
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)
125− .foregroundColor(.secondary)
126− }
127− }
128− .font(Self.captionFont)
129− }
130− }
131−
132− /// A sparkline shown when there are at least 2 history points.
133− @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
135− if values.count >= 2 {
136− Sparkline(values: values, color: color)
137− .frame(height: 26)
138− .padding(.top, 4)
139− .transition(.opacity)
140− }
141− }
142−
143− /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144− /// message, and a tinted rounded background with no border.
145− private func errorRow(_ message: String) -> some View {
146− HStack(alignment: .top, spacing: 6) {
147− Image(systemName: "exclamationmark.triangle.fill")
148− .font(.system(size: 10, weight: .semibold))
149−
150− Text(message)
151− .font(Self.captionFont)
152− // Long messages wrap instead of being truncated to one line.
153− .fixedSize(horizontal: false, vertical: true)
154−
155− Spacer(minLength: 0)
156− }
157− .foregroundColor(.red)
158− .padding(.horizontal, 10)
159− .padding(.vertical, 7)
160− .background(
161− RoundedRectangle(cornerRadius: 8, style: .continuous)
162− .fill(Color.red.opacity(0.1))
163− )
164− .padding(.top, 2)
165− }
166−
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−
217− // MARK: - Claude
218−
219− /// Whether there is anything to say about Claude. Nothing at all when Claude Code
220− /// isn't signed in on this machine — the section doesn't appear, and no error is
221− /// shown for a service the user may simply not use.
222− private var showsClaude: Bool {
223− if viewModel.claudeError != nil { return true }
224− if let usage = viewModel.claudeUsage { return !usage.isEmpty }
225− return false
226− }
227−
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.
230− private var claude: some View {
231− VStack(alignment: .leading, spacing: 12) {
232− HStack(spacing: 8) {
233− sectionHeader("Claude Code", systemImage: "sparkles")
234−
235− Spacer(minLength: 0)
236−
237− if let plan = viewModel.claudePlanLabel {
238− Text(plan)
239− .font(Self.captionFont)
240− .foregroundColor(.secondary)
241− }
242− }
243−
244− if let usage = viewModel.claudeUsage, !usage.isEmpty {
245− usageDetail(usage)
246− }
247−
248− if let error = viewModel.claudeError {
249− errorRow(error)
250− }
251− }
252− }
253−
254− private func usageDetail(_ usage: ClaudeUsage) -> some View {
255− VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
257− if let fiveHour = usage.fiveHour {
258− let percent = Int(fiveHour.utilization.rounded())
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− )
269−
270− Spacer(minLength: 0)
271−
272− if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
274− .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
276− }
277− }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281− }
282− }
283−
284− VStack(alignment: .leading, spacing: 9) {
285− if let sevenDay = usage.sevenDay {
286− window("7-day", sevenDay)
287− }
288− // Opus has its own weekly cap on Max plans, but it is only worth a row
289− // once it has actually been used.
290− if let opus = usage.sevenDayOpus, opus.utilization > 0 {
291− window("7-day Opus", opus)
292− }
293− }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300− }
301− }
302−
303− /// One window: its name, its share of the allowance, and a hairline bar. The bar is
304− /// the only chrome here, and it carries the same color as the number beside it.
305− private func window(_ label: String, _ window: UsageWindow) -> some View {
306− let percent = Int(window.utilization.rounded())
307−
308− return VStack(alignment: .leading, spacing: 5) {
309− HStack(spacing: 6) {
310− Text(label)
311− .font(Self.captionFont)
312− .foregroundColor(.secondary)
313−
314− Spacer(minLength: 0)
315−
316− Text("\(percent)%")
317− .font(Self.captionFont.monospacedDigit())
318− .foregroundColor(Self.usageColor(percent))
319− }
320−
321− UsageBar(percent: percent, color: Self.usageColor(percent))
322− }
323− }
324−
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−
330− /// The balance colors read the other way round here, because for a limit more is
331− /// worse: green with room to spare, yellow closing in, red nearly spent.
332− private static func usageColor(_ percent: Int) -> Color {
333− if percent >= 90 { return .red }
334− if percent >= 70 { return .yellow }
335− return .green
336− }
337−
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−
508− // MARK: - Settings
509−
510− private var settings: some View {
511− VStack(alignment: .leading, spacing: 14) {
512− VStack(alignment: .leading, spacing: 8) {
513− HStack(spacing: 8) {
514− sectionHeader("Hyper API Key", systemImage: "bolt.fill")
515−
516− Spacer(minLength: 0)
517−
518− // The confirmation takes the link's place rather than adding a row
519− // of its own, so saving never resizes the popover.
520− if viewModel.savedConfirmation {
521− Text("✓ Saved")
522− .font(Self.captionFont)
523− .foregroundColor(.green)
524− .transition(.opacity)
525− } else {
526− Link("Get key →", destination: Self.websiteURL)
527− .font(Self.captionFont)
528− .transition(.opacity)
529− }
530− }
531−
532− HStack(spacing: 8) {
533− SecureField("sk-…", text: $viewModel.apiKeyInput)
534− .textFieldStyle(.roundedBorder)
535−
536− Button("Save") {
537− viewModel.saveAPIKey()
538− }
539− }
540− .controlSize(.small)
541− }
542−
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−
574− HStack {
575− Text("Refresh every")
576−
577− Spacer(minLength: 0)
578−
579− Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
580− ForEach(RefreshInterval.options, id: \.self) { minutes in
581− Text("\(minutes)m").tag(minutes)
582− }
583− }
584− .pickerStyle(.menu)
585− .labelsHidden()
586− .fixedSize()
587− .controlSize(.small)
588− }
589−
590− Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
591− .toggleStyle(.switch)
592− .controlSize(.small)
593− }
594− }
595−
596− // MARK: - Footer
597−
598− private var footer: some View {
599− VStack(spacing: 14) {
600− HStack {
601− Button(action: { viewModel.refresh() }) {
602− Label("Refresh", systemImage: "arrow.clockwise")
603− }
604− .disabled(viewModel.isLoading)
605−
606− Spacer(minLength: 12)
607−
608− Button("Quit") {
609− NSApplication.shared.terminate(nil)
610− }
611− .keyboardShortcut("q")
612− }
613− .controlSize(.small)
614− }
615− }
616−}
617−
618−// MARK: - Usage Bar
619−
620−/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
621−/// rule under the label rather than as a control.
622−private struct UsageBar: View {
623− /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
624− let percent: Int
625− let color: Color
626−
627− private let height: CGFloat = 3
628−
629− var body: some View {
630− GeometryReader { geometry in
631− let fraction = min(max(Double(percent) / 100, 0), 1)
632−
633− ZStack(alignment: .leading) {
634− Capsule()
635− .fill(Color.secondary.opacity(0.15))
636−
637− Capsule()
638− .fill(color)
639− .frame(width: geometry.size.width * fraction)
640− }
641− }
642− .frame(height: height)
643− }
644−}
645−
646−// MARK: - Sparkline
647−
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
650−/// faint fade underneath for weight and a dot marking the latest point.
651−///
652−/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
653−/// readable when fetches are irregular (wake-ups, manual refreshes).
654−struct Sparkline: View {
655− let values: [Int]
656− var color: Color = .secondary
657−
658− /// Keeps the stroke and the dot from being clipped at the edges.
659− private let verticalInset: CGFloat = 3
660− private let dotRadius: CGFloat = 2.5
661−
662− var body: some View {
663− GeometryReader { geometry in
664− let points = points(in: geometry.size)
665−
666− if points.count >= 2 {
667− ZStack {
668− fill(under: points, height: geometry.size.height)
669− .fill(
670− LinearGradient(
671− colors: [color.opacity(0.18), color.opacity(0)],
672− startPoint: .top,
673− endPoint: .bottom
674− )
675− )
676−
677− line(through: points)
678− .stroke(
679− color.opacity(0.9),
680− style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
681− )
682−
683− if let last = points.last {
684− Circle()
685− .fill(color)
686− .frame(width: dotRadius * 2, height: dotRadius * 2)
687− .position(last)
688− }
689− }
690− }
691− }
692− }
693−
694− /// Normalizes the values into the available space, oldest at the left.
695− private func points(in size: CGSize) -> [CGPoint] {
696− guard values.count >= 2 else { return [] }
697−
698− let minValue = values.min() ?? 0
699− let maxValue = values.max() ?? 0
700− let range = Double(maxValue - minValue)
701−
702− // The dot is centered on the last point, so the line stops a radius short of
703− // the trailing edge rather than letting the dot spill outside the frame.
704− let usableWidth = max(size.width - dotRadius, 1)
705− let usableHeight = max(size.height - verticalInset * 2, 1)
706− let stepX = usableWidth / CGFloat(values.count - 1)
707−
708− return values.enumerated().map { index, value in
709− // A flat series has no range to normalize against; center it.
710− let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
711− return CGPoint(
712− x: CGFloat(index) * stepX,
713− y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
714− )
715− }
716− }
717−
718− private func line(through points: [CGPoint]) -> Path {
719− Path { path in
720− guard let first = points.first else { return }
721− path.move(to: first)
722− for point in points.dropFirst() {
723− path.addLine(to: point)
724− }
725− }
726− }
727−
728− /// The line closed down to the baseline, so it can be filled.
729− private func fill(under points: [CGPoint], height: CGFloat) -> Path {
730− Path { path in
731− guard let first = points.first, let last = points.last else { return }
732− path.move(to: CGPoint(x: first.x, y: height))
733− path.addLine(to: first)
734− for point in points.dropFirst() {
735− path.addLine(to: point)
736− }
737− path.addLine(to: CGPoint(x: last.x, y: height))
738− path.closeSubpath()
739− }
740− }
741−}
UsageMenubar/ZaiUsageClient.swiftdeleted−388
@@ -1,388 +0,0 @@
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/StatusBarTextTests.swiftdeleted−378
@@ -1,378 +0,0 @@
1−import XCTest
2−@testable import UsageMenubar
3−
4−/// The menu bar title and the Claude numbers it is built from.
5−///
6−/// Balances are kept under 1,000 so the assertions don't depend on the grouping
7−/// separator of whatever locale the test happens to run in.
8−@MainActor
9−final class StatusBarTextTests: XCTestCase {
10−
11− // MARK: - Title
12−
13− func testBothServices() {
14− let title = ViewModel.statusBarText(
15− balance: 42, isLoading: false,
16− hyperConfigured: true,
17− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19− zaiInPeakHours: false
20− )
21− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
22− }
23−
24− func testHyperOnly() {
25− let title = ViewModel.statusBarText(
26− balance: 42, isLoading: false,
27− hyperConfigured: true,
28− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30− zaiInPeakHours: false
31− )
32− XCTAssertEqual(title, "⚡42")
33− }
34−
35− func testClaudeOnly() {
36− let title = ViewModel.statusBarText(
37− balance: nil, isLoading: false,
38− hyperConfigured: false,
39− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41− zaiInPeakHours: false
42− )
43− XCTAssertEqual(title, "\u{2055}62% 8%")
44− }
45−
46− func testLoadingWithNothingToShow() {
47− let title = ViewModel.statusBarText(
48− balance: nil, isLoading: true,
49− hyperConfigured: false,
50− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52− zaiInPeakHours: false
53− )
54− XCTAssertEqual(title, "…")
55− }
56−
57− func testNothingToShow() {
58− let title = ViewModel.statusBarText(
59− balance: nil, isLoading: false,
60− hyperConfigured: false,
61− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63− zaiInPeakHours: false
64− )
65− XCTAssertEqual(title, "")
66− }
67−
68− /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
69− /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
70− /// each time would blink their percentage away and back.
71− func testLoadingKeepsAClaudePercentOnScreen() {
72− let title = ViewModel.statusBarText(
73− balance: nil, isLoading: true,
74− hyperConfigured: false,
75− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77− zaiInPeakHours: false
78− )
79− XCTAssertEqual(title, "\u{2055}62%")
80− }
81−
82− func testLoadingKeepsAStaleBalanceOnScreen() {
83− let title = ViewModel.statusBarText(
84− balance: 42, isLoading: true,
85− hyperConfigured: true,
86− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88− zaiInPeakHours: false
89− )
90− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
91− }
92−
93− func testZeroPercentIsShown() {
94− let title = ViewModel.statusBarText(
95− balance: 42, isLoading: false,
96− hyperConfigured: true,
97− claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99− zaiInPeakHours: false
100− )
101− XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
102− }
103−
104− func testBothClaudeWindowsZero() {
105− let title = ViewModel.statusBarText(
106− balance: 42, isLoading: false,
107− hyperConfigured: true,
108− claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110− zaiInPeakHours: false
111− )
112− XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
113− }
114−
115− func testFiveHourOnly() {
116− let title = ViewModel.statusBarText(
117− balance: nil, isLoading: false,
118− hyperConfigured: false,
119− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121− zaiInPeakHours: false
122− )
123− XCTAssertEqual(title, "\u{2055}62%")
124− }
125−
126− func testSevenDayOnly() {
127− let title = ViewModel.statusBarText(
128− balance: nil, isLoading: false,
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
319− )
320− // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
322− }
323−
324− // MARK: - Claude Window Percentages
325−
326− func testFiveHourPercentRounds() {
327− let usage = ClaudeUsage(
328− fiveHour: UsageWindow(utilization: 61.6),
329− sevenDay: nil, sevenDayOpus: nil, limits: nil
330− )
331− let vm = ViewModel()
332− vm.claudeUsage = usage
333− XCTAssertEqual(vm.claudeFiveHourPercent, 62)
334− XCTAssertNil(vm.claudeSevenDayPercent)
335− }
336−
337− func testSevenDayPercentRounds() {
338− let usage = ClaudeUsage(
339− fiveHour: nil,
340− sevenDay: UsageWindow(utilization: 4.4),
341− sevenDayOpus: nil, limits: nil
342− )
343− let vm = ViewModel()
344− vm.claudeUsage = usage
345− XCTAssertNil(vm.claudeFiveHourPercent)
346− XCTAssertEqual(vm.claudeSevenDayPercent, 4)
347− }
348−
349− func testSevenDayFallsBackToOpus() {
350− let usage = ClaudeUsage(
351− fiveHour: nil,
352− sevenDay: nil,
353− sevenDayOpus: UsageWindow(utilization: 33.5),
354− limits: nil
355− )
356− let vm = ViewModel()
357− vm.claudeUsage = usage
358− XCTAssertEqual(vm.claudeSevenDayPercent, 34)
359− }
360−
361− func testSevenDayDoesNotFallBackToZeroOpus() {
362− let usage = ClaudeUsage(
363− fiveHour: nil,
364− sevenDay: nil,
365− sevenDayOpus: UsageWindow(utilization: 0),
366− limits: nil
367− )
368− let vm = ViewModel()
369− vm.claudeUsage = usage
370− XCTAssertNil(vm.claudeSevenDayPercent)
371− }
372−
373− func testNilUsageYieldsNilPercents() {
374− let vm = ViewModel()
375− XCTAssertNil(vm.claudeFiveHourPercent)
376− XCTAssertNil(vm.claudeSevenDayPercent)
377− }
378−}
UsageMenubarTests/ZaiUsageClientTests.swiftdeleted−237
@@ -1,237 +0,0 @@
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+25−24
@@ -1,12 +1,12 @@
11# xcodegen project definition — run `xcodegen generate` to (re)create
2−# UsageMenubar.xcodeproj. The .xcodeproj is not committed; this file is
2+# HyperCreditsMenubar.xcodeproj. The .xcodeproj is not committed; this file is
33# the source of truth for project structure and build settings.
4−name: UsageMenubar
4+name: HyperCreditsMenubar
55
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: soy.iko
9+ bundleIdPrefix: com.ilyakooo0
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:
@@ -32,22 +32,24 @@ settings:
3232
3333targets:
3434 # ── The menu bar app ───────────────────────────────────────────────────
35− UsageMenubar:
35+ HyperCreditsMenubar:
3636 type: application
3737 platform: macOS
3838 sources:
39− - path: UsageMenubar
39+ - path: HyperCreditsMenubar
4040 excludes:
4141 # Referenced explicitly via INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS,
4242 # so they must not also be compiled in as resources.
4343 - "Info.plist"
4444 - "*.entitlements"
45+ resources:
46+ - HyperCreditsMenubar/Assets.xcassets
4547 info:
46− path: UsageMenubar/Info.plist
48+ path: HyperCreditsMenubar/Info.plist
4749 properties:
48− CFBundleName: UsageMenubar
49− CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: soy.iko.usage-menubar
50+ CFBundleName: HyperCreditsMenubar
51+ CFBundleDisplayName: Hyper Credits
52+ CFBundleIdentifier: com.ilyakooo0.hyper-credits-menubar
5153 # Overwritten by CI — see the note on MARKETING_VERSION above.
5254 CFBundleVersion: "1"
5355 CFBundleShortVersionString: "1.0.0"
@@ -57,16 +59,15 @@ targets:
5759 NSHighResolutionCapable: true
5860 settings:
5961 base:
60− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
61− PRODUCT_NAME: UsageMenubar
62− INFOPLIST_FILE: UsageMenubar/Info.plist
62+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar
63+ PRODUCT_NAME: HyperCreditsMenubar
64+ INFOPLIST_FILE: HyperCreditsMenubar/Info.plist
6365 # Grants the outgoing-network-connection entitlement needed to reach
6466 # the Hyper and Claude APIs, and deliberately does not sandbox the app —
6567 # see the comment in the entitlements file. Stripped in CI's archive step
6668 # (unsigned builds can't carry entitlements).
67− CODE_SIGN_ENTITLEMENTS: UsageMenubar/UsageMenubar.entitlements
69+ CODE_SIGN_ENTITLEMENTS: HyperCreditsMenubar/HyperCreditsMenubar.entitlements
6870 ENABLE_HARDENED_RUNTIME: YES
69− ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
7071 MACOSX_DEPLOYMENT_TARGET: "14.0"
7172 LD_RUNPATH_SEARCH_PATHS:
7273 - "$(inherited)"
@@ -75,32 +76,32 @@ targets:
7576 # ── Unit tests ─────────────────────────────────────────────────────────
7677 # Hosted by the app target (BUNDLE_LOADER/TEST_HOST) so tests can reach the
7778 # app's internal types without re-declaring them.
78− UsageMenubarTests:
79+ HyperCreditsMenubarTests:
7980 type: bundle.unit-test
8081 platform: macOS
8182 sources:
82− - path: UsageMenubarTests
83+ - path: HyperCreditsMenubarTests
8384 dependencies:
84− - target: UsageMenubar
85+ - target: HyperCreditsMenubar
8586 settings:
8687 base:
87− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
88− PRODUCT_NAME: UsageMenubarTests
88+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar.tests
89+ PRODUCT_NAME: HyperCreditsMenubarTests
8990 BUNDLE_LOADER: "$(TEST_HOST)"
90− TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"
91+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/HyperCreditsMenubar.app/Contents/MacOS/HyperCreditsMenubar"
9192 MACOSX_DEPLOYMENT_TARGET: "14.0"
9293
9394schemes:
94− UsageMenubar:
95+ HyperCreditsMenubar:
9596 build:
9697 targets:
97− UsageMenubar: all
98− UsageMenubarTests: [test]
98+ HyperCreditsMenubar: all
99+ HyperCreditsMenubarTests: [test]
99100 run:
100101 config: Debug
101102 test:
102103 config: Debug
103104 targets:
104− - UsageMenubarTests
105+ - HyperCreditsMenubarTests
105106 archive:
106107 config: Release