on master help sign in
36 files changed+5464−580
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+190−26 CLAUDE.md+88 HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json−20 HyperCreditsMenubar/CreditsChecker.swift−71 HyperCreditsMenubar/HyperCreditsApp.swift−91 HyperCreditsMenubar/HyperCreditsMenubar.entitlements−10 HyperCreditsMenubar/KeychainHelper.swift−47 HyperCreditsMenubar/MenuView.swift−188 HyperCreditsMenubarTests/CreditsCheckerTests.swift−51 README.md+117−48 HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.json 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 HyperCreditsMenubar/Assets.xcassets/Contents.json → UsageMenubar/Assets.xcassets/Contents.json UsageMenubar/ClaudeUsageClient.swift+476 UsageMenubar/CreditsChecker.swift+197 HyperCreditsMenubar/Info.plist → UsageMenubar/Info.plist+3−3 UsageMenubar/KeychainHelper.swift+136 UsageMenubar/MenuView.swift+741 UsageMenubar/UsageMenubar.entitlements+16 UsageMenubar/UsageMenubarApp.swift+277 HyperCreditsMenubar/VersionFormatter.swift → UsageMenubar/VersionFormatter.swift UsageMenubar/ViewModel.swift+899 UsageMenubar/ZaiUsageClient.swift+388 UsageMenubarTests/ClaudeUsageClientTests.swift+557 UsageMenubarTests/CreditsCheckerTests.swift+646 UsageMenubarTests/StatusBarTextTests.swift+378 HyperCreditsMenubarTests/VersionFormatterTests.swift → UsageMenubarTests/VersionFormatterTests.swift+1−1 UsageMenubarTests/ZaiUsageClientTests.swift+237 project.yml+49−24
.github/workflows/release.yml+190−26
@@ -34,63 +34,226 @@ jobs:
3434 - name: Install xcodegen
3535 run: brew install xcodegen
3636
37+ # ── Inject version into project.yml ───────────────────────────────
38+ # Parses project.yml as YAML and sets the four version fields, rather
39+ # than pattern-matching with sed. A real parser can't corrupt unrelated
40+ # keys that happen to match the regex, and it fails loudly if the file
41+ # structure changes. sed remains as a fallback if pyyaml is unavailable.
42+ #
43+ # Note: the YAML round-trip drops the comments from project.yml. That
44+ # only affects this ephemeral CI checkout, never the committed file.
45+ - name: Inject version into project.yml
46+ env:
47+ VERSION: ${{ env.VERSION }}
48+ INJECT_PY: |
49+ import os
50+ import yaml
51+
52+ version = os.environ["VERSION"]
53+ path = "project.yml"
54+
55+ with open(path) as f:
56+ doc = yaml.safe_load(f)
57+
58+ # Force these scalars to be emitted double-quoted. Xcode version
59+ # fields must be strings; an unquoted value risks being read back
60+ # as a number by any downstream YAML consumer.
61+ class Quoted(str):
62+ pass
63+
64+ yaml.add_representer(
65+ Quoted,
66+ lambda dumper, data: dumper.represent_scalar(
67+ "tag:yaml.org,2002:str", str(data), style='"'
68+ ),
69+ )
70+
71+ base = doc.setdefault("settings", {}).setdefault("base", {})
72+ base["MARKETING_VERSION"] = Quoted(version)
73+ base["CURRENT_PROJECT_VERSION"] = Quoted(version)
74+
75+ props = (
76+ doc.setdefault("targets", {})
77+ .setdefault("UsageMenubar", {})
78+ .setdefault("info", {})
79+ .setdefault("properties", {})
80+ )
81+ props["CFBundleShortVersionString"] = Quoted(version)
82+ props["CFBundleVersion"] = Quoted(version)
83+
84+ with open(path, "w") as f:
85+ yaml.dump(doc, f, sort_keys=False, default_flow_style=False)
86+
87+ print(f"Rewrote project.yml with version {version}")
88+ run: |
89+ set -eo pipefail
90+
91+ if ! python3 -c 'import yaml' 2>/dev/null; then
92+ echo "pyyaml not importable — trying to install it"
93+ python3 -m pip install --quiet --disable-pip-version-check pyyaml \
94+ || python3 -m pip install --quiet --disable-pip-version-check --break-system-packages pyyaml \
95+ || true
96+ fi
97+
98+ if python3 -c 'import yaml' 2>/dev/null; then
99+ python3 -c "$INJECT_PY"
100+ else
101+ echo "::warning title=pyyaml unavailable::Falling back to sed for version injection."
102+ sed -i '' "s/MARKETING_VERSION: \".*\"/MARKETING_VERSION: \"$VERSION\"/" project.yml
103+ sed -i '' "s/CFBundleShortVersionString: \".*\"/CFBundleShortVersionString: \"$VERSION\"/" project.yml
104+ sed -i '' "s/CURRENT_PROJECT_VERSION: \".*\"/CURRENT_PROJECT_VERSION: \"$VERSION\"/" project.yml
105+ sed -i '' "s/CFBundleVersion: \".*\"/CFBundleVersion: \"$VERSION\"/" project.yml
106+ fi
107+
108+ echo "Version fields now in project.yml:"
109+ grep -E 'MARKETING_VERSION|CFBundleShortVersionString|CURRENT_PROJECT_VERSION|CFBundleVersion' project.yml
110+
37111 # ── Generate Xcode project ────────────────────────────────────────
38112 - name: Generate Xcode project
39113 run: xcodegen generate
40114
41115 # ── Run tests ─────────────────────────────────────────────────────
42116 - name: Run tests
43− continue-on-error: true
44117 run: |
45118 xcodebuild test \
46− -scheme HyperCreditsMenubar \
119+ -scheme UsageMenubar \
47120 -configuration Debug \
48121 -destination "platform=macOS" \
49122 CODE_SIGNING_ALLOWED=NO \
50123 CODE_SIGNING_REQUIRED=NO \
51124 CODE_SIGN_IDENTITY="" \
52− DEVELOPMENT_TEAM="" \
53− || true
125+ DEVELOPMENT_TEAM=""
54126
55127 # ── Archive ───────────────────────────────────────────────────────
56128 - name: Archive
57129 run: |
58130 xcodebuild archive \
59− -scheme HyperCreditsMenubar \
131+ -scheme UsageMenubar \
60132 -configuration Release \
61− -archivePath build/HyperCreditsMenubar.xcarchive \
133+ -archivePath build/UsageMenubar.xcarchive \
62134 -destination "generic/platform=macOS" \
63135 CODE_SIGNING_ALLOWED=NO \
64136 CODE_SIGNING_REQUIRED=NO \
65137 CODE_SIGN_IDENTITY="" \
66138 DEVELOPMENT_TEAM="" \
67− CODE_SIGN_ENTITLEMENTS="" \
68− ENABLE_HARDENED_RUNTIME=NO
139+ CODE_SIGN_ENTITLEMENTS=""
69140
70141 # ── Export .app from archive ──────────────────────────────────────
71142 - name: Export app from archive
72143 run: |
73144 mkdir -p build/export
74− # Simply copy the .app from the archive (no signing in CI)
75− cp -R "build/HyperCreditsMenubar.xcarchive/Products/Applications/HyperCreditsMenubar.app" \
76− "build/export/HyperCreditsMenubar.app"
145+ # 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"
148+
149+ # ── Ad-hoc code signing ───────────────────────────────────────────
150+ # `-s -` is an ad-hoc signature: no certificate, no Apple identity.
151+ # It does NOT make the app trusted — Gatekeeper still shows a prompt on
152+ # first launch for a download from the internet. What it does buy us is a
153+ # stable code signature, so the app has a consistent code identity for
154+ # the Keychain ACL (the API key stays readable across launches instead of
155+ # re-prompting) and macOS stops treating each launch as a new binary.
156+ - name: Ad-hoc code sign the app
157+ run: |
158+ set -eo pipefail
159+ APP="build/export/UsageMenubar.app"
160+ codesign --force --deep -s - "$APP"
161+ echo "── Verifying signature ──"
162+ codesign --verify --deep --strict --verbose=2 "$APP"
163+ codesign -dvv "$APP" 2>&1 | grep -E 'Identifier|Signature|Format' || true
77164
78− # ── Zip the app ───────────────────────────────────────────────────
165+ # ── Zip the app (submission candidate) ────────────────────────────
166+ # notarytool takes a zip, so build it before notarizing. If notarization
167+ # runs it staples a ticket into the .app, and the final packaging step
168+ # below re-zips so the released archive contains the stapled bundle.
79169 - name: Zip the app
80170 run: |
171+ set -eo pipefail
172+ cd build/export
173+ ditto -c -k --keepParent UsageMenubar.app "UsageMenubar-${VERSION}.zip"
174+
175+ # ── Detect notarization credentials ───────────────────────────────
176+ # Secrets are checked in a shell step (not a step-level `if:`) so the
177+ # workflow stays valid and simply skips notarization when unconfigured.
178+ #
179+ # Notarization also needs the App Store Connect private key itself, so
180+ # NOTARY_API_KEY_P8 (the full .p8 file contents) is required alongside
181+ # the key id and issuer.
182+ - name: Check notarization credentials
183+ id: notary
184+ env:
185+ NOTARY_API_KEY_ID: ${{ secrets.NOTARY_API_KEY_ID }}
186+ NOTARY_API_KEY_ISSUER: ${{ secrets.NOTARY_API_KEY_ISSUER }}
187+ NOTARY_API_KEY_P8: ${{ secrets.NOTARY_API_KEY_P8 }}
188+ run: |
189+ if [ -n "${NOTARY_API_KEY_ID:-}" ] \
190+ && [ -n "${NOTARY_API_KEY_ISSUER:-}" ] \
191+ && [ -n "${NOTARY_API_KEY_P8:-}" ]; then
192+ echo "available=true" >> "$GITHUB_OUTPUT"
193+ echo "Notarization credentials present."
194+ else
195+ echo "available=false" >> "$GITHUB_OUTPUT"
196+ echo "::notice title=Notarization skipped::NOTARY_API_KEY_ID / NOTARY_API_KEY_ISSUER / NOTARY_API_KEY_P8 are not all set. Releasing the ad-hoc signed build."
197+ fi
198+
199+ # ── Notarize and staple ───────────────────────────────────────────
200+ # IMPORTANT: Apple's notary service only accepts builds signed with a
201+ # Developer ID Application certificate under the hardened runtime. An
202+ # ad-hoc signature is rejected ("The binary is not signed with a valid
203+ # Developer ID certificate"). This step is therefore wired up and ready,
204+ # but it will not pass until the archive is signed with a real Developer
205+ # ID identity — which needs the certificate imported into the runner
206+ # keychain and CODE_SIGNING_ALLOWED turned back on in the archive step.
207+ #
208+ # continue-on-error keeps a failed/rejected notarization from blocking
209+ # the release; the ad-hoc signed artifact still ships.
210+ - name: Notarize and staple
211+ if: steps.notary.outputs.available == 'true'
212+ continue-on-error: true
213+ env:
214+ NOTARY_API_KEY_ID: ${{ secrets.NOTARY_API_KEY_ID }}
215+ NOTARY_API_KEY_ISSUER: ${{ secrets.NOTARY_API_KEY_ISSUER }}
216+ NOTARY_API_KEY_P8: ${{ secrets.NOTARY_API_KEY_P8 }}
217+ run: |
218+ set -eo pipefail
219+
220+ KEY_FILE="$RUNNER_TEMP/notary_key.p8"
221+ trap 'rm -f "$KEY_FILE"' EXIT
222+ printf '%s' "$NOTARY_API_KEY_P8" > "$KEY_FILE"
223+ chmod 600 "$KEY_FILE"
224+
225+ echo "── Submitting to Apple notary service ──"
226+ xcrun notarytool submit "build/export/UsageMenubar-${VERSION}.zip" \
227+ --key "$KEY_FILE" \
228+ --key-id "$NOTARY_API_KEY_ID" \
229+ --issuer "$NOTARY_API_KEY_ISSUER" \
230+ --wait \
231+ --timeout 30m
232+
233+ echo "── Stapling ticket to the app ──"
234+ xcrun stapler staple "build/export/UsageMenubar.app"
235+ xcrun stapler validate "build/export/UsageMenubar.app"
236+
237+ # ── Package release artifacts ─────────────────────────────────────
238+ # Re-zip from the .app so the published archive picks up a stapled
239+ # notarization ticket when the step above succeeded.
240+ - name: Package release artifacts
241+ run: |
242+ set -eo pipefail
81243 cd build/export
82− ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${{ env.VERSION }}.zip"
244+ rm -f "UsageMenubar-${VERSION}.zip"
245+ ditto -c -k --keepParent UsageMenubar.app "UsageMenubar-${VERSION}.zip"
83246 # Also create a stable-named zip for the Homebrew Cask
84− cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" "HyperCreditsMenubar-latest.zip"
85− cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" ../../
86− cp "HyperCreditsMenubar-latest.zip" ../../
247+ cp "UsageMenubar-${VERSION}.zip" "UsageMenubar-latest.zip"
248+ cp "UsageMenubar-${VERSION}.zip" ../../
249+ cp "UsageMenubar-latest.zip" ../../
87250
88251 # ── Upload build artifact ─────────────────────────────────────────
89252 - name: Upload build artifact
90253 uses: actions/upload-artifact@v4
91254 with:
92− name: HyperCreditsMenubar-${{ env.VERSION }}
93− path: HyperCreditsMenubar-${{ env.VERSION }}.zip
255+ name: UsageMenubar-${{ env.VERSION }}
256+ path: UsageMenubar-${{ env.VERSION }}.zip
94257 retention-days: 30
95258
96259 # ── Create GitHub Release ─────────────────────────────────────────
@@ -100,23 +263,24 @@ jobs:
100263 tag_name: ${{ env.VERSION }}
101264 name: ${{ env.VERSION }}
102265 body: |
103− ## HyperCreditsMenubar ${{ env.VERSION }}
266+ ## UsageMenubar ${{ env.VERSION }}
104267
105− **Download:** `HyperCreditsMenubar-${{ env.VERSION }}.zip`
268+ **Download:** `UsageMenubar-${{ env.VERSION }}.zip`
106269
107270 ### Installation
108− 1. Download and unzip `HyperCreditsMenubar-${{ env.VERSION }}.zip`
109− 2. Move `HyperCreditsMenubar.app` to your `/Applications` folder
271+ 1. Download and unzip `UsageMenubar-${{ env.VERSION }}.zip`
272+ 2. Move `UsageMenubar.app` to your `/Applications` folder
110273 3. Launch the app — it will appear in your menu bar as ⚡
111274 4. Click the ⚡ icon, enter your Hyper API key, and click Save
112275
113− > **Note:** This build is unsigned. On first launch you may need to
114− > right-click → Open, or allow it in System Settings → Privacy & Security.
276+ > **Note:** This build is ad-hoc signed, not signed with an Apple
277+ > Developer ID. On first launch you will still need to right-click →
278+ > **Open**, or allow it in **System Settings → Privacy & Security**.
115279
116280 ### Changes
117281 Built from commit ${{ github.sha }}
118282 files: |
119− HyperCreditsMenubar-${{ env.VERSION }}.zip
120− HyperCreditsMenubar-latest.zip
283+ UsageMenubar-${{ env.VERSION }}.zip
284+ UsageMenubar-latest.zip
121285 draft: false
122286 prerelease: false
CLAUDE.mdnew file+88
@@ -0,0 +1,88 @@
1+# UsageMenubar
2+
3+## 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)
17+- `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)
21+- `VersionFormatter.swift` — Version string formatting
22+
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`).
48+
49+## Claude credentials (strictly read-only)
50+`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
51+Keychain 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.
66+- Missing credentials are **not an error** — the popover just omits the section.
67+- **The app cannot be sandboxed**: spawning `security` and reading `~/.claude` require it
68+ to be off. Distribution is ad-hoc signed via Homebrew, not the App Store.
69+
70+## Build
71+- Uses [xcodegen](https://github.com/yonaskolb/XcodeGen) — `project.yml` is source of truth
72+- `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"`
75+- Swift 5.9, macOS 14.0 deployment target
76+
77+## Conventions
78+- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
79+- No card-heavy/multi-material/gradient-rich designs
80+- Rounded font family at one scale (subhero/section/control/caption/footnote)
81+- Monospaced digits for numbers that change
82+- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
83+ FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
84+- The app is an agent app (LSUIElement=true) — no dock icon, no main window
85+
86+## VCS
87+- jj colocated with git
88+- Push directly to master — no PRs for this repo
HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsondeleted−20
@@ -1,20 +0,0 @@
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−}
HyperCreditsMenubar/CreditsChecker.swiftdeleted−71
@@ -1,71 +0,0 @@
1−import Foundation
2−
3−/// JSON response from `GET https://hyper.charm.land/v1/credits`.
4−struct BalanceResponse: Decodable, Equatable {
5− let balance: Int
6−}
7−
8−/// Errors that can occur while fetching the balance.
9−enum CreditsError: LocalizedError, Equatable {
10− case noAPIKey
11− case invalidURL
12− case requestFailed(String)
13− case decodingFailed(String)
14−
15− var errorDescription: String? {
16− switch self {
17− case .noAPIKey:
18− return "No API key set. Enter your Hyper API key in settings."
19− case .invalidURL:
20− return "The server URL is invalid."
21− case .requestFailed(let message):
22− return "Request failed: \(message)"
23− case .decodingFailed(let message):
24− return "Failed to decode response: \(message)"
25− }
26− }
27−}
28−
29−/// Client for the Hyper (Charm) credits API.
30−final class CreditsChecker {
31− static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
32−
33− private let session: URLSession
34−
35− init(session: URLSession = .shared) {
36− self.session = session
37− }
38−
39− /// Fetches the current credit balance for the given API key.
40− /// - Parameter apiKey: The Hyper API key (starts with `"sk-"`).
41− /// - Returns: The balance as an integer.
42− /// - Throws: `CreditsError` on failure.
43− func fetchBalance(apiKey: String) async throws -> Int {
44− guard !apiKey.isEmpty else { throw CreditsError.noAPIKey }
45−
46− var request = URLRequest(url: Self.endpoint)
47− request.httpMethod = "GET"
48− request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
49− request.setValue("application/json", forHTTPHeaderField: "Accept")
50−
51− do {
52− let (data, response) = try await session.data(for: request)
53− guard let httpResponse = response as? HTTPURLResponse else {
54− throw CreditsError.requestFailed("Invalid response type")
55− }
56− guard (200...299).contains(httpResponse.statusCode) else {
57− throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
58− }
59− do {
60− let decoded = try JSONDecoder().decode(BalanceResponse.self, from: data)
61− return decoded.balance
62− } catch {
63− throw CreditsError.decodingFailed(error.localizedDescription)
64− }
65− } catch let error as CreditsError {
66− throw error
67− } catch {
68− throw CreditsError.requestFailed(error.localizedDescription)
69− }
70− }
71−}
HyperCreditsMenubar/HyperCreditsApp.swiftdeleted−91
@@ -1,91 +0,0 @@
1−import SwiftUI
2−import Combine
3−
4−/// Main app delegate that manages the menu bar item, the refresh timer,
5−/// and the SwiftUI popover hosting `MenuView`.
6−final class AppDelegate: NSObject, NSApplicationDelegate {
7− private var statusItem: NSStatusItem!
8− private var popover: NSPopover!
9− private var refreshTimer: Timer?
10−
11− /// The shared view model. Shared between the menu bar and the popover.
12− let viewModel = ViewModel()
13−
14− /// Combine cancellable for observing balance changes.
15− private var statusTextCancellable: AnyCancellable?
16−
17− private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
18−
19− // MARK: - Lifecycle
20−
21− func applicationDidFinishLaunching(_ notification: Notification) {
22− // Create status bar item
23− statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
24−
25− if let button = statusItem.button {
26− button.image = nil
27− button.title = "⚡?"
28− button.target = self
29− button.action = #selector(togglePopover(_:))
30− button.sendAction(on: [.leftMouseUp, .rightMouseUp])
31− }
32−
33− // Create popover with SwiftUI content
34− popover = NSPopover()
35− popover.behavior = .transient
36− popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
37−
38− // Observe balance changes to update the menu bar title
39− statusTextCancellable = viewModel.$balance
40− .map { [weak viewModel] _ in viewModel?.statusBarItemText ?? "⚡?" }
41− .removeDuplicates()
42− .receive(on: RunLoop.main)
43− .sink { [weak self] text in
44− self?.statusItem.button?.title = text
45− }
46−
47− // Initial fetch
48− viewModel.refresh()
49−
50− // Start periodic refresh
51− let timer = Timer(timeInterval: refreshInterval, repeats: true) { [weak self] _ in
52− self?.viewModel.refresh()
53− }
54− RunLoop.main.add(timer, forMode: .common)
55− refreshTimer = timer
56− }
57−
58− func applicationWillTerminate(_ notification: Notification) {
59− refreshTimer?.invalidate()
60− statusTextCancellable?.cancel()
61− }
62−
63− // MARK: - Popover
64−
65− @objc private func togglePopover(_ sender: Any?) {
66− guard let button = statusItem.button else { return }
67− if popover.isShown {
68− closePopover()
69− } else {
70− popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
71− NSApp.activate(ignoringOtherApps: true)
72− }
73− }
74−
75− private func closePopover() {
76− popover.performClose(nil)
77− }
78−}
79−
80−// MARK: - App Entry Point
81−
82−@main
83−struct HyperCreditsApp: App {
84− @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
85−
86− var body: some Scene {
87− Settings {
88− EmptyView()
89− }
90− }
91−}
HyperCreditsMenubar/HyperCreditsMenubar.entitlementsdeleted−10
@@ -1,10 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>com.apple.security.app-sandbox</key>
6− <false/>
7− <key>com.apple.security.network.client</key>
8− <true/>
9−</dict>
10−</plist>
HyperCreditsMenubar/KeychainHelper.swiftdeleted−47
@@ -1,47 +0,0 @@
1−import Foundation
2−import Security
3−
4−/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
5−enum KeychainHelper {
6− private static let service = "com.ilyakooo0.hyper-credits-menubar"
7− private static let account = "hyper-api-key"
8−
9− /// Saves the given API key to the Keychain, overwriting any existing entry.
10− static func save(_ key: String) {
11− delete()
12− guard let data = key.data(using: .utf8) else { return }
13− let query: [String: Any] = [
14− kSecClass as String: kSecClassGenericPassword,
15− kSecAttrService as String: service,
16− kSecAttrAccount as String: account,
17− kSecValueData as String: data,
18− kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
19− ]
20− SecItemAdd(query as CFDictionary, nil)
21− }
22−
23− /// Retrieves the stored API key, or `nil` if none exists.
24− static func load() -> String? {
25− let query: [String: Any] = [
26− kSecClass as String: kSecClassGenericPassword,
27− kSecAttrService as String: service,
28− kSecAttrAccount as String: account,
29− kSecReturnData as String: true,
30− kSecMatchLimit as String: kSecMatchLimitOne,
31− ]
32− var item: CFTypeRef?
33− let status = SecItemCopyMatching(query as CFDictionary, &item)
34− guard status == errSecSuccess, let data = item as? Data else { return nil }
35− return String(data: data, encoding: .utf8)
36− }
37−
38− /// Deletes the stored API key if it exists.
39− static func delete() {
40− let query: [String: Any] = [
41− kSecClass as String: kSecClassGenericPassword,
42− kSecAttrService as String: service,
43− kSecAttrAccount as String: account,
44− ]
45− SecItemDelete(query as CFDictionary)
46− }
47−}
HyperCreditsMenubar/MenuView.swiftdeleted−188
@@ -1,188 +0,0 @@
1−import Foundation
2−import SwiftUI
3−import Combine
4−import ServiceManagement
5−
6−/// The view model that drives the menu bar display and the popover content.
7−final class ViewModel: ObservableObject {
8− /// The last known balance. `nil` when loading, error, or no API key.
9− @Published var balance: Int?
10−
11− /// `true` while a network request is in flight.
12− @Published var isLoading = false
13−
14− /// A human-readable error message, shown when the balance can't be fetched.
15− @Published var errorMessage: String?
16−
17− /// The API key entered by the user (loaded from Keychain on init).
18− @Published var apiKeyInput: String = ""
19−
20− /// Whether launch-at-login is enabled.
21− @Published var launchAtLogin: Bool {
22− didSet {
23− updateLaunchAtLogin(launchAtLogin)
24− }
25− }
26−
27− private let checker = CreditsChecker()
28−
29− init() {
30− apiKeyInput = KeychainHelper.load() ?? ""
31− launchAtLogin = SMAppService.mainApp.status == .enabled
32− }
33−
34− // MARK: - Balance
35−
36− /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
37− var statusBarItemText: String {
38− if let balance = balance {
39− return "⚡\(balance)"
40− }
41− return "⚡?"
42− }
43−
44− /// The color for the balance display based on thresholds.
45− var balanceColor: Color {
46− guard let balance = balance else { return .secondary }
47− if balance >= 100 { return .green }
48− if balance >= 10 { return .yellow }
49− return .red
50− }
51−
52− /// Refreshes the balance from the API. No-op if no API key is set.
53− func refresh() {
54− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
55− guard !key.isEmpty else {
56− balance = nil
57− errorMessage = nil
58− return
59− }
60−
61− Task { @MainActor in
62− isLoading = true
63− errorMessage = nil
64− do {
65− let result = try await checker.fetchBalance(apiKey: key)
66− balance = result
67− } catch {
68− balance = nil
69− errorMessage = error.localizedDescription
70− }
71− isLoading = false
72− }
73− }
74−
75− // MARK: - API Key
76−
77− /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
78− func saveAPIKey() {
79− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
80− if key.isEmpty {
81− KeychainHelper.delete()
82− balance = nil
83− errorMessage = nil
84− } else {
85− KeychainHelper.save(key)
86− refresh()
87− }
88− }
89−
90− // MARK: - Launch at Login
91−
92− func updateLaunchAtLogin(_ enabled: Bool) {
93− if enabled {
94− try? SMAppService.mainApp.register()
95− } else {
96− try? SMAppService.mainApp.unregister()
97− }
98− }
99−}
100−
101−// MARK: - MenuView
102−
103−/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
104−struct MenuView: View {
105− @ObservedObject var viewModel: ViewModel
106−
107− var body: some View {
108− VStack(spacing: 16) {
109− // Balance display
110− VStack(spacing: 4) {
111− if viewModel.isLoading && viewModel.balance == nil {
112− ProgressView()
113− .scaleEffect(0.8)
114− Text("Loading…")
115− .font(.caption)
116− .foregroundColor(.secondary)
117− } else if let balance = viewModel.balance {
118− Text("\(balance)")
119− .font(.system(size: 42, weight: .bold, design: .rounded))
120− .foregroundColor(viewModel.balanceColor)
121− Text("credits")
122− .font(.caption)
123− .foregroundColor(.secondary)
124− } else {
125− Text("?")
126− .font(.system(size: 42, weight: .bold, design: .rounded))
127− .foregroundColor(.secondary)
128− Text("credits")
129− .font(.caption)
130− .foregroundColor(.secondary)
131− }
132− }
133−
134− if let error = viewModel.errorMessage {
135− Text(error)
136− .font(.caption)
137− .foregroundColor(.red)
138− .multilineTextAlignment(.center)
139− .padding(.horizontal)
140− }
141−
142− Divider()
143−
144− // Refresh button
145− Button(action: { viewModel.refresh() }) {
146− Label("Refresh now", systemImage: "arrow.clockwise")
147− }
148− .disabled(viewModel.isLoading)
149−
150− // API key section
151− VStack(alignment: .leading, spacing: 6) {
152− Text("API Key")
153− .font(.headline)
154− HStack {
155− SecureField("sk-...", text: $viewModel.apiKeyInput)
156− .textFieldStyle(.roundedBorder)
157− Button("Save") {
158− viewModel.saveAPIKey()
159− }
160− }
161− }
162−
163− // Launch at login toggle
164− Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
165− .toggleStyle(.switch)
166−
167− // Links
168− HStack {
169− if let url = URL(string: "https://hyper.charm.land") {
170− Button("Open hyper.charm.land") {
171− NSWorkspace.shared.open(url)
172− }
173− }
174− Spacer()
175− }
176−
177− Divider()
178−
179− // Quit
180− Button("Quit HyperCredits") {
181− NSApplication.shared.terminate(nil)
182− }
183− .keyboardShortcut("q")
184− }
185− .padding(16)
186− .frame(width: 280)
187− }
188−}
HyperCreditsMenubarTests/CreditsCheckerTests.swiftdeleted−51
@@ -1,51 +0,0 @@
1−import XCTest
2−@testable import HyperCreditsMenubar
3−
4−final class CreditsCheckerTests: XCTestCase {
5−
6− // MARK: - BalanceResponse Decoding
7−
8− func testDecodeValidBalance() throws {
9− let json = #"{"balance": 42}"#.data(using: .utf8)!
10− let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
11− XCTAssertEqual(response.balance, 42)
12− }
13−
14− func testDecodeZeroBalance() throws {
15− let json = #"{"balance": 0}"#.data(using: .utf8)!
16− let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
17− XCTAssertEqual(response.balance, 0)
18− }
19−
20− func testDecodeLargeBalance() throws {
21− let json = #"{"balance": 999999}"#.data(using: .utf8)!
22− let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
23− XCTAssertEqual(response.balance, 999999)
24− }
25−
26− func testDecodeMissingBalanceThrows() {
27− let json = #"{"foo": "bar"}"#.data(using: .utf8)!
28− XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
29− }
30−
31− func testDecodeNegativeBalance() throws {
32− // The API could theoretically return a negative balance
33− let json = #"{"balance": -5}"#.data(using: .utf8)!
34− let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
35− XCTAssertEqual(response.balance, -5)
36− }
37−
38− // MARK: - CreditsChecker Errors
39−
40− func testEmptyAPIKeyThrowsNoAPIKey() async {
41− let checker = CreditsChecker()
42− do {
43− _ = try await checker.fetchBalance(apiKey: "")
44− XCTFail("Expected noAPIKey error")
45− } catch CreditsError.noAPIKey {
46− // expected
47− } catch {
48− XCTFail("Unexpected error: \(error)")
49− }
50− }
51−}
README.md+117−48
@@ -1,39 +1,103 @@
1−# HyperCreditsMenubar
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>
24
3−A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance.
5+# UsageMenubar
46
5−![Screenshot placeholder](docs/screenshot-placeholder.png)
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.
68
79## Features
810
9−- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
10−- 🔄 **Auto-refresh** — fetches your balance every 5 minutes
11−- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
12−- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
13−- 🔐 **Secure input** — API key field is masked
11+- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
14+- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
15+- 🖱️ **Right-click menu** — quick access to Refresh and Quit
16+- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
1417- 🚀 **Launch at login** — optional, using `SMAppService`
15−- 📋 **Quick link** — opens [hyper.charm.land](https://hyper.charm.land) from the menu
1618- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
1719
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+
1834## Installation
1935
36+### Homebrew (recommended)
37+
38+```bash
39+brew tap ilyakooo0/tap
40+brew install --cask usage-menubar
41+```
42+
43+Or in one command:
44+
45+```bash
46+brew install --cask ilyakooo0/tap/usage-menubar
47+```
48+
2049### Download the pre-built release
2150
22−1. Go to the [Releases page](https://github.com/ilyakooo0/hyper-credits-menubar/releases)
23−2. Download the latest `HyperCreditsMenubar-*.zip`
24−3. Unzip and move `HyperCreditsMenubar.app` to `/Applications`
25−4. Launch the app — it will appear in your menu bar as ⚡
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
55+
56+> **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.
57+
58+## Setup
2659
27−> **Note:** Releases are currently unsigned. On first launch, right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**.
60+Click the menu bar icon to open the popover. Each service is configured independently:
2861
29−### Set your API key
62+### Hyper credits
3063
31−1. Click the ⚡ icon in your menu bar
32−2. Enter your Hyper API key (starts with `sk-...`) in the **API Key** field
33−3. Click **Save**
34−4. Your balance will appear immediately
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
3567
36−You can get your API key from [hyper.charm.land](https://hyper.charm.land).
68+Get your key from [hyper.charm.land](https://hyper.charm.land).
69+
70+### Claude Code limits (no setup)
71+
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.
73+
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.
75+
76+- **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).
86+
87+## Settings
88+
89+Everything is configured from the popover — click the menu bar icon.
90+
91+| Setting | Where | Notes |
92+| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
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. |
95+| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
96+| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
97+| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
98+| **Refresh interval**| **Refresh every** picker | Choose 1m, 5m, 15m, or 30m. Defaults to 5m. Stored in `UserDefaults` and persisted across launches. |
99+
100+The app also refreshes automatically when your Mac wakes from sleep.
37101
38102## Build from source
39103
@@ -47,20 +111,20 @@ You can get your API key from [hyper.charm.land](https://hyper.charm.land).
47111
48112```bash
49113# Clone the repository
50−git clone https://github.com/ilyakooo0/hyper-credits-menubar.git
51−cd hyper-credits-menubar
114+git clone https://github.com/ilyakooo0/usage-menubar.git
115+cd usage-menubar
52116
53117# Generate the Xcode project
54118xcodegen generate
55119
56120# Open in Xcode
57−open HyperCreditsMenubar.xcodeproj
121+open UsageMenubar.xcodeproj
58122
59123# Or build from the command line
60124xcodebuild \
61− -scheme HyperCreditsMenubar \
125+ -scheme UsageMenubar \
62126 -configuration Release \
63− -archivePath build/HyperCreditsMenubar.xcarchive \
127+ -archivePath build/UsageMenubar.xcarchive \
64128 archive \
65129 CODE_SIGNING_ALLOWED=NO
66130```
@@ -69,7 +133,7 @@ xcodebuild \
69133
70134```bash
71135xcodebuild test \
72− -scheme HyperCreditsMenubar \
136+ -scheme UsageMenubar \
73137 -configuration Debug \
74138 -destination "platform=macOS"
75139```
@@ -89,19 +153,25 @@ The version is derived automatically from the commit timestamp in the GitHub Act
89153## Project structure
90154
91155```
92−hyper-credits-menubar/
156+usage-menubar/
93157├── project.yml # xcodegen project definition
94−├── HyperCreditsMenubar/
95−│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer
96−│ ├── CreditsChecker.swift # API client (GET /v1/credits)
97−│ ├── MenuView.swift # SwiftUI popover view + ViewModel
98−│ ├── KeychainHelper.swift # Keychain wrapper for API key
158+├── UsageMenubar/
159+│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
160+│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
161+│ ├── MenuView.swift # SwiftUI popover view
162+│ ├── CreditsChecker.swift # Hyper API client (GET /v1/credits)
163+│ ├── 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)
99166│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
100167│ ├── Info.plist # LSUIElement=true (agent app)
101−│ ├── HyperCreditsMenubar.entitlements # Network client entitlements
168+│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
102169│ └── Assets.xcassets/ # App icon, accent color
103−├── HyperCreditsMenubarTests/
104−│ ├── CreditsCheckerTests.swift # JSON decoding tests
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
105175│ └── VersionFormatterTests.swift # Version formatting tests
106176├── .github/workflows/
107177│ └── release.yml # Build + release on push to master
@@ -112,19 +182,18 @@ hyper-credits-menubar/
112182
113183## How it works
114184
115−1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`.
116−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView`.
117−3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer {apiKey}` and decodes `{"balance": <int>}`.
118−4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework.
119−5. **Timer**: A `Timer` fires every 5 minutes to refresh the balance.
120−6. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
121−
122−## Credits
123−
124−- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
125−- **API**: [Hyper by Charm](https://hyper.charm.land)
126−- **Built with**: Swift 5.9, SwiftUI, macOS 14+
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.
195+11. **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.
127196
128197## License
129198
130−MIT — see [LICENSE](LICENSE).
199+MIT — see [LICENSE](LICENSE).
HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.jsonrenamed

The content is the same.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsonnew file+68
@@ -0,0 +1,68 @@
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.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-128.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-16.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-256.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-32.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-512.pngnew file

Binary file not shown.

UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-64.pngnew file

Binary file not shown.

HyperCreditsMenubar/Assets.xcassets/Contents.json → UsageMenubar/Assets.xcassets/Contents.jsonrenamed

The content is the same.

UsageMenubar/ClaudeUsageClient.swiftnew file+476
@@ -0,0 +1,476 @@
1+import Foundation
2+
3+// MARK: - Usage
4+
5+/// Usage data from `GET https://api.anthropic.com/api/oauth/usage` — the endpoint
6+/// behind Claude Code's own `/usage` command.
7+///
8+/// Every field is optional. The payload varies by plan, and Anthropic adds and
9+/// removes windows without notice, so an unrecognized shape has to degrade to
10+/// "nothing to show" rather than fail the whole decode.
11+struct ClaudeUsage: Decodable, Equatable {
12+ let fiveHour: UsageWindow?
13+ let sevenDay: UsageWindow?
14+ let sevenDayOpus: UsageWindow?
15+ let limits: [Limit]?
16+
17+ private enum CodingKeys: String, CodingKey {
18+ case fiveHour = "five_hour"
19+ case sevenDay = "seven_day"
20+ case sevenDayOpus = "seven_day_opus"
21+ case limits
22+ }
23+
24+ /// The limit the server itself flags as currently binding — the one worth leading
25+ /// with. `nil` when it flags none, which is the normal state below the thresholds.
26+ var activeLimit: Limit? {
27+ limits?.first { $0.isActive }
28+ }
29+
30+ /// Whether there is anything at all to render. The endpoint answers `{}` for an
31+ /// account that has never used the plan, and a section header over nothing reads
32+ /// as a bug.
33+ var isEmpty: Bool {
34+ fiveHour == nil && sevenDay == nil && sevenDayOpus == nil && (limits?.isEmpty ?? true)
35+ }
36+}
37+
38+/// One usage window, e.g. the rolling 5-hour or 7-day allowance.
39+struct UsageWindow: Decodable, Equatable, ResetCountdown {
40+ /// Percent of the window consumed, 0–100.
41+ let utilization: Double
42+
43+ /// ISO 8601, or `nil` when nothing has been used and so nothing is counting down.
44+ let resetsAt: String?
45+
46+ private enum CodingKeys: String, CodingKey {
47+ case utilization
48+ case resetsAt = "resets_at"
49+ }
50+
51+ init(utilization: Double, resetsAt: String? = nil) {
52+ self.utilization = utilization
53+ self.resetsAt = resetsAt
54+ }
55+
56+ init(from decoder: Decoder) throws {
57+ let container = try decoder.container(keyedBy: CodingKeys.self)
58+ utilization = try container.decodeIfPresent(Double.self, forKey: .utilization) ?? 0
59+ resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
60+ }
61+}
62+
63+/// One entry in the `limits` array: the server's own classification of a limit,
64+/// including whether it is the one currently in effect.
65+struct Limit: Decodable, Equatable, ResetCountdown {
66+ let percent: Int
67+ let resetsAt: String?
68+ let isActive: Bool
69+
70+ private enum CodingKeys: String, CodingKey {
71+ case percent
72+ case resetsAt = "resets_at"
73+ case isActive = "is_active"
74+ }
75+
76+ init(percent: Int, resetsAt: String? = nil, isActive: Bool = false) {
77+ self.percent = percent
78+ self.resetsAt = resetsAt
79+ self.isActive = isActive
80+ }
81+
82+ init(from decoder: Decoder) throws {
83+ let container = try decoder.container(keyedBy: CodingKeys.self)
84+ // Decoded as a Double and rounded: the field is documented as an integer, but
85+ // decoding it as one would throw on a fractional value and take the whole
86+ // payload down with it.
87+ let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percent) ?? 0
88+ percent = Int(rawPercent.rounded())
89+ resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
90+ isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false
91+ }
92+}
93+
94+// MARK: - Reset Countdown
95+
96+/// Anything carrying an ISO 8601 reset timestamp, and so able to render a countdown.
97+protocol ResetCountdown {
98+ var resetsAt: String? { get }
99+}
100+
101+extension ResetCountdown {
102+ /// `resetsAt` as a date, or `nil` when absent or unparseable.
103+ var resetsAtDate: Date? {
104+ guard let resetsAt else { return nil }
105+ return ISO8601.date(from: resetsAt)
106+ }
107+
108+ /// How long until the window resets, e.g. `"3h 20m"`, `"1d 4h"` or `"12m"`.
109+ /// `nil` once the reset time has passed, or when there is no timestamp at all.
110+ ///
111+ /// - Parameter reference: The instant to count from. Injectable for tests.
112+ func resetsIn(from reference: Date = Date()) -> String? {
113+ guard let date = resetsAtDate else { return nil }
114+
115+ let interval = date.timeIntervalSince(reference)
116+ guard interval > 0 else { return nil }
117+
118+ // Rounded to the nearest second, so a window that is exactly "3h 20m" out
119+ // isn't reported a minute short because of the milliseconds spent getting here.
120+ let seconds = Int(interval.rounded())
121+ let days = seconds / 86_400
122+ let hours = (seconds % 86_400) / 3_600
123+ let minutes = (seconds % 3_600) / 60
124+
125+ if days > 0 { return "\(days)d \(hours)h" }
126+ if hours > 0 { return "\(hours)h \(minutes)m" }
127+ return "\(minutes)m"
128+ }
129+
130+ /// The countdown as of now.
131+ var resetsInFormatted: String? {
132+ resetsIn()
133+ }
134+}
135+
136+/// Claude timestamps carry fractional seconds, but not always — both spellings parse.
137+private enum ISO8601 {
138+ private static let fractional: ISO8601DateFormatter = {
139+ let formatter = ISO8601DateFormatter()
140+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
141+ return formatter
142+ }()
143+
144+ private static let whole: ISO8601DateFormatter = {
145+ let formatter = ISO8601DateFormatter()
146+ formatter.formatOptions = [.withInternetDateTime]
147+ return formatter
148+ }()
149+
150+ static func date(from string: String) -> Date? {
151+ fractional.date(from: string) ?? whole.date(from: string)
152+ }
153+}
154+
155+// MARK: - Credentials
156+
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]?
172+ /// `"pro"`, `"max"`, `"team"`, …
173+ let subscriptionType: String?
174+ let rateLimitTier: String?
175+ }
176+}
177+
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.
180+/// Injectable so tests never go near the real Keychain or the real credentials file.
181+protocol ClaudeCredentialStoring {
182+ func load() -> ClaudeOAuthCredentials?
183+}
184+
185+/// The real store: Claude Code's login-Keychain item, falling back to
186+/// `~/.claude/.credentials.json`.
187+///
188+/// The Keychain item belongs to Claude Code, so it is read through the `security`
189+/// CLI rather than `SecItemCopyMatching`: the item's ACL trusts `/usr/bin/security`,
190+/// and macOS asks the user once to allow it. Spawning a process and reading a file in
191+/// the user's home is also why this app cannot be sandboxed.
192+struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
193+ static let defaultKeychainService = "Claude Code-credentials"
194+
195+ /// `NSHomeDirectory()` rather than `FileManager.homeDirectoryForCurrentUser`: the
196+ /// latter can resolve to a container path, while Claude Code always writes to the
197+ /// real home directory.
198+ static var defaultCredentialsFile: URL {
199+ URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude/.credentials.json")
200+ }
201+
202+ private let keychainService: String
203+ private let credentialsFile: URL
204+
205+ init(
206+ keychainService: String = ClaudeCodeCredentialStore.defaultKeychainService,
207+ credentialsFile: URL = ClaudeCodeCredentialStore.defaultCredentialsFile
208+ ) {
209+ self.keychainService = keychainService
210+ self.credentialsFile = credentialsFile
211+ }
212+
213+ func load() -> ClaudeOAuthCredentials? {
214+ if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
215+ let credentials = Self.decode(Data(json.utf8)) {
216+ return credentials
217+ }
218+
219+ guard let data = try? Data(contentsOf: credentialsFile) else { return nil }
220+ return Self.decode(data)
221+ }
222+
223+ private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
224+ try? JSONDecoder().decode(ClaudeOAuthCredentials.self, from: data)
225+ }
226+
227+ /// Runs `/usr/bin/security`, returning its trimmed stdout, or `nil` when it fails
228+ /// or says nothing — which is the ordinary answer when Claude Code was never
229+ /// signed in on this machine.
230+ private static func runSecurity(_ arguments: [String]) -> String? {
231+ let process = Process()
232+ process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
233+ process.arguments = arguments
234+
235+ let output = Pipe()
236+ process.standardOutput = output
237+ // Discarded rather than piped: an undrained error pipe can fill and deadlock.
238+ process.standardError = FileHandle.nullDevice
239+
240+ do {
241+ try process.run()
242+ } catch {
243+ return nil
244+ }
245+
246+ // Drained before waiting, for the same reason.
247+ let data = output.fileHandleForReading.readDataToEndOfFile()
248+ process.waitUntilExit()
249+
250+ guard process.terminationStatus == 0 else { return nil }
251+
252+ let text = String(data: data, encoding: .utf8)?
253+ .trimmingCharacters(in: .whitespacesAndNewlines)
254+ guard let text, !text.isEmpty else { return nil }
255+ return text
256+ }
257+}
258+
259+// MARK: - Errors
260+
261+/// Errors that can occur while fetching Claude usage.
262+enum ClaudeUsageError: LocalizedError, Equatable {
263+ /// Claude Code has never signed in here. Not an error the user needs to see — the
264+ /// popover just leaves the section out.
265+ 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.
270+ case invalidCredentials
271+ case requestFailed(String)
272+ case decodingFailed(String)
273+
274+ var errorDescription: String? {
275+ switch self {
276+ case .noCredentials:
277+ return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
278+ case .invalidCredentials:
279+ return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
280+ case .requestFailed(let message):
281+ return "Claude request failed: \(message)"
282+ case .decodingFailed(let message):
283+ return "Failed to decode Claude usage: \(message)"
284+ }
285+ }
286+}
287+
288+/// What a successful fetch yields: the usage itself, plus the plan it belongs to.
289+struct ClaudeUsageReport: Equatable {
290+ let usage: ClaudeUsage
291+
292+ /// `"pro"`, `"max"`, … straight from the OAuth credentials; `nil` when absent.
293+ let subscriptionType: String?
294+}
295+
296+/// Protocol abstraction for fetching Claude usage, enabling test injection.
297+protocol ClaudeUsageChecking {
298+ func fetchUsage() async throws -> ClaudeUsageReport
299+}
300+
301+// MARK: - Client
302+
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.
315+///
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.
319+actor ClaudeUsageClient: ClaudeUsageChecking {
320+ static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
321+
322+ /// Cloudflare fronts the API and answers 403 `browser_signature_banned` to anything
323+ /// without a browser User-Agent.
324+ static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
325+ + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
326+
327+ /// Retries *after* the initial attempt, so at most 3 requests per call.
328+ static let maxRetries = 2
329+
330+ /// URL error codes worth retrying: the request never reached a server that had an
331+ /// opinion about it, so the same request may well succeed a moment later.
332+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
333+ .timedOut,
334+ .cannotConnectToHost,
335+ .cannotFindHost,
336+ .dnsLookupFailed,
337+ .networkConnectionLost,
338+ .notConnectedToInternet,
339+ .resourceUnavailable,
340+ .badServerResponse,
341+ ]
342+
343+ /// Client-error status codes that are nonetheless worth retrying: both are the
344+ /// server telling us to come back later, not that the request itself is wrong.
345+ private static let retryableStatusCodes: Set<Int> = [
346+ 408, // Request Timeout
347+ 429, // Too Many Requests
348+ ]
349+
350+ private let session: URLSession
351+ private let store: ClaudeCredentialStoring
352+ private let retryBaseDelay: Duration
353+
354+ /// - Parameters:
355+ /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
356+ /// one (2s, then 4s). Injectable so tests need not wait in real time.
357+ init(
358+ session: URLSession? = nil,
359+ store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
360+ retryBaseDelay: Duration = .seconds(2)
361+ ) {
362+ self.store = store
363+ self.retryBaseDelay = retryBaseDelay
364+ if let session {
365+ self.session = session
366+ } else {
367+ let config = URLSessionConfiguration.default
368+ config.timeoutIntervalForRequest = 10
369+ config.timeoutIntervalForResource = 15
370+ self.session = URLSession(configuration: config)
371+ }
372+ }
373+
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.
380+ ///
381+ /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
382+ /// signed in here, which callers should treat as "nothing to show" rather than
383+ /// as a failure.
384+ func fetchUsage() async throws -> ClaudeUsageReport {
385+ guard let credentials = store.load() else { throw ClaudeUsageError.noCredentials }
386+
387+ let token = credentials.claudeAiOauth
388+ let usage = try await requestUsage(token: token.accessToken)
389+ return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
390+ }
391+
392+ // MARK: - Usage Request
393+
394+ private func requestUsage(token: String) async throws -> ClaudeUsage {
395+ var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
396+ request.httpMethod = "GET"
397+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
398+ request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
399+ request.setValue("application/json", forHTTPHeaderField: "Accept")
400+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
401+
402+ let data = try await send(request)
403+
404+ do {
405+ return try JSONDecoder().decode(ClaudeUsage.self, from: data)
406+ } catch {
407+ throw ClaudeUsageError.decodingFailed(error.localizedDescription)
408+ }
409+ }
410+
411+ // MARK: - Transport
412+
413+ /// Sends a request, retrying transient failures with exponential backoff.
414+ private func send(_ request: URLRequest) async throws -> Data {
415+ var attempt = 0
416+
417+ while true {
418+ do {
419+ return try await performAttempt(request)
420+ } catch let failure as AttemptFailure {
421+ guard failure.isRetryable, attempt < Self.maxRetries else {
422+ throw failure.underlying
423+ }
424+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
425+ attempt += 1
426+ }
427+ }
428+ }
429+
430+ /// Performs a single request, tagging the failure with whether a retry could
431+ /// plausibly help.
432+ private func performAttempt(_ request: URLRequest) async throws -> Data {
433+ let data: Data
434+ let response: URLResponse
435+ do {
436+ (data, response) = try await session.data(for: request)
437+ } catch let urlError as URLError {
438+ throw AttemptFailure(
439+ underlying: .requestFailed(urlError.localizedDescription),
440+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
441+ )
442+ } catch {
443+ throw AttemptFailure(
444+ underlying: .requestFailed(error.localizedDescription),
445+ isRetryable: false
446+ )
447+ }
448+
449+ guard let httpResponse = response as? HTTPURLResponse else {
450+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
451+ }
452+
453+ switch httpResponse.statusCode {
454+ case 200...299:
455+ return data
456+ case 401, 403:
457+ throw AttemptFailure(underlying: .invalidCredentials, isRetryable: false)
458+ case 500...599:
459+ throw AttemptFailure(
460+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
461+ isRetryable: true
462+ )
463+ default:
464+ throw AttemptFailure(
465+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
466+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
467+ )
468+ }
469+ }
470+
471+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
472+ private struct AttemptFailure: Error {
473+ let underlying: ClaudeUsageError
474+ let isRetryable: Bool
475+ }
476+}
UsageMenubar/CreditsChecker.swiftnew file+197
@@ -0,0 +1,197 @@
1+import Foundation
2+
3+/// JSON response from `GET https://hyper.charm.land/v1/credits`.
4+///
5+/// The API is documented to return an integer balance, but tolerates a fractional
6+/// one (`{"balance": 42.5}`) by rounding to the nearest integer.
7+struct BalanceResponse: Decodable, Equatable {
8+ let balance: Int
9+
10+ init(balance: Int) {
11+ self.balance = balance
12+ }
13+
14+ private enum CodingKeys: String, CodingKey {
15+ case balance
16+ }
17+
18+ init(from decoder: Decoder) throws {
19+ let container = try decoder.container(keyedBy: CodingKeys.self)
20+
21+ // Try Int first so large values keep their exact magnitude; only fall back to
22+ // Double when the payload is genuinely fractional.
23+ if let exact = try? container.decode(Int.self, forKey: .balance) {
24+ balance = exact
25+ return
26+ }
27+
28+ let fractional = try container.decode(Double.self, forKey: .balance)
29+ guard let rounded = Int(exactly: fractional.rounded(.toNearestOrAwayFromZero)) else {
30+ throw DecodingError.dataCorruptedError(
31+ forKey: .balance,
32+ in: container,
33+ debugDescription: "Balance \(fractional) is not representable as an integer."
34+ )
35+ }
36+ balance = rounded
37+ }
38+}
39+
40+/// Errors that can occur while fetching the balance.
41+enum CreditsError: LocalizedError, Equatable {
42+ case noAPIKey
43+ case invalidAPIKey
44+ case requestFailed(String)
45+ case decodingFailed(String)
46+
47+ var errorDescription: String? {
48+ switch self {
49+ case .noAPIKey:
50+ return "No API key set. Enter your Hyper API key in settings."
51+ case .invalidAPIKey:
52+ return "Invalid API key. Check your key at hyper.charm.land"
53+ case .requestFailed(let message):
54+ return "Request failed: \(message)"
55+ case .decodingFailed(let message):
56+ return "Failed to decode response: \(message)"
57+ }
58+ }
59+}
60+
61+/// Protocol abstraction for fetching credit balance, enabling test injection.
62+protocol CreditsChecking {
63+ func fetchBalance(apiKey: String) async throws -> Int
64+}
65+
66+/// Client for the Hyper (Charm) credits API.
67+final class CreditsChecker: CreditsChecking {
68+ static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
69+
70+ /// Number of retries *after* the initial attempt, so at most 3 requests total.
71+ static let maxRetries = 2
72+
73+ /// URL error codes worth retrying: the request never reached a server that had an
74+ /// opinion about it, so the same request may well succeed a moment later.
75+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
76+ .timedOut,
77+ .cannotConnectToHost,
78+ .cannotFindHost,
79+ .dnsLookupFailed,
80+ .networkConnectionLost,
81+ .notConnectedToInternet,
82+ .resourceUnavailable,
83+ .badServerResponse,
84+ ]
85+
86+ /// Client-error status codes that are nonetheless worth retrying: both are the server
87+ /// telling us to come back later rather than that the request itself is wrong.
88+ private static let retryableStatusCodes: Set<Int> = [
89+ 408, // Request Timeout
90+ 429, // Too Many Requests
91+ ]
92+
93+ private let session: URLSession
94+ private let retryBaseDelay: Duration
95+
96+ /// - Parameter retryBaseDelay: Delay before the first retry; doubles on each
97+ /// subsequent one (2s, then 4s). Injectable so tests need not wait in real time.
98+ init(session: URLSession? = nil, retryBaseDelay: Duration = .seconds(2)) {
99+ self.retryBaseDelay = retryBaseDelay
100+ if let session {
101+ self.session = session
102+ } else {
103+ let config = URLSessionConfiguration.default
104+ config.timeoutIntervalForRequest = 10
105+ config.timeoutIntervalForResource = 15
106+ self.session = URLSession(configuration: config)
107+ }
108+ }
109+
110+ /// Fetches the current credit balance for the given API key.
111+ ///
112+ /// Transient failures (network errors, 5xx) are retried up to `maxRetries` times with
113+ /// exponential backoff. Authentication and other client errors fail immediately.
114+ ///
115+ /// - Parameter apiKey: A Hyper API key (starts with `"sk-"`).
116+ /// - Returns: The balance as an integer.
117+ /// - Throws: `CreditsError` on failure.
118+ func fetchBalance(apiKey: String) async throws -> Int {
119+ guard !apiKey.isEmpty else { throw CreditsError.noAPIKey }
120+
121+ let request = makeRequest(apiKey: apiKey)
122+ var attempt = 0
123+
124+ while true {
125+ do {
126+ return try await performAttempt(request)
127+ } catch let failure as AttemptFailure {
128+ guard failure.isRetryable, attempt < Self.maxRetries else {
129+ throw failure.underlying
130+ }
131+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
132+ attempt += 1
133+ }
134+ }
135+ }
136+
137+ private func makeRequest(apiKey: String) -> URLRequest {
138+ var request = URLRequest(url: Self.endpoint)
139+ request.httpMethod = "GET"
140+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
141+ request.setValue("application/json", forHTTPHeaderField: "Accept")
142+ request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
143+ return request
144+ }
145+
146+ /// Performs a single request, tagging the failure with whether a retry could plausibly help.
147+ private func performAttempt(_ request: URLRequest) async throws -> Int {
148+ let data: Data
149+ let response: URLResponse
150+ do {
151+ (data, response) = try await session.data(for: request)
152+ } catch let urlError as URLError {
153+ throw AttemptFailure(
154+ underlying: .requestFailed(urlError.localizedDescription),
155+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
156+ )
157+ } catch {
158+ throw AttemptFailure(
159+ underlying: .requestFailed(error.localizedDescription),
160+ isRetryable: false
161+ )
162+ }
163+
164+ guard let httpResponse = response as? HTTPURLResponse else {
165+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
166+ }
167+
168+ switch httpResponse.statusCode {
169+ case 200...299:
170+ break
171+ case 401:
172+ throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
173+ case 500...599:
174+ throw AttemptFailure(
175+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
176+ isRetryable: true
177+ )
178+ default:
179+ throw AttemptFailure(
180+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
181+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
182+ )
183+ }
184+
185+ do {
186+ return try JSONDecoder().decode(BalanceResponse.self, from: data).balance
187+ } catch {
188+ throw AttemptFailure(underlying: .decodingFailed(error.localizedDescription), isRetryable: false)
189+ }
190+ }
191+
192+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
193+ private struct AttemptFailure: Error {
194+ let underlying: CreditsError
195+ let isRetryable: Bool
196+ }
197+}
HyperCreditsMenubar/Info.plist → UsageMenubar/Info.plistrenamed+3−3
@@ -3,11 +3,11 @@
33<plist version="1.0">
44<dict>
55 <key>CFBundleName</key>
6− <string>HyperCreditsMenubar</string>
6+ <string>UsageMenubar</string>
77 <key>CFBundleDisplayName</key>
8− <string>Hyper Credits</string>
8+ <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.hyper-credits-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swiftnew file+136
@@ -0,0 +1,136 @@
1+import Foundation
2+import Security
3+
4+/// A keychain-backed store for a single generic-password item.
5+///
6+/// The `SecItem*` calls are injected rather than called directly so that tests can
7+/// exercise the failure paths (locked keychain, denied access) without touching — and
8+/// clobbering — the real login keychain entry the app depends on.
9+struct KeychainStore {
10+ typealias AddItem = (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
11+ typealias CopyMatching = (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
12+ typealias DeleteItem = (CFDictionary) -> OSStatus
13+
14+ private let service: String
15+ private let account: String
16+ private let addItem: AddItem
17+ private let copyMatching: CopyMatching
18+ private let deleteItem: DeleteItem
19+
20+ init(
21+ service: String,
22+ account: String,
23+ addItem: @escaping AddItem = SecItemAdd,
24+ copyMatching: @escaping CopyMatching = SecItemCopyMatching,
25+ deleteItem: @escaping DeleteItem = SecItemDelete
26+ ) {
27+ self.service = service
28+ self.account = account
29+ self.addItem = addItem
30+ self.copyMatching = copyMatching
31+ self.deleteItem = deleteItem
32+ }
33+
34+ private var baseQuery: [String: Any] {
35+ [
36+ kSecClass as String: kSecClassGenericPassword,
37+ kSecAttrService as String: service,
38+ kSecAttrAccount as String: account,
39+ ]
40+ }
41+
42+ /// Saves the given API key, overwriting any existing entry.
43+ /// - Returns: `true` if the key was stored; `false` if the keychain rejected it.
44+ func save(_ key: String) -> Bool {
45+ guard let data = key.data(using: .utf8) else { return false }
46+
47+ var query = baseQuery
48+ query[kSecValueData as String] = data
49+ query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
50+
51+ // Add first and only clear the way once the keychain has told us something is
52+ // already there. Deleting up front would mean a rejected add — locked keychain,
53+ // denied access — leaves the user with no key at all, having destroyed the
54+ // working one they had.
55+ let status = addItem(query as CFDictionary, nil)
56+ if status == errSecSuccess { return true }
57+ guard status == errSecDuplicateItem, delete() else { return false }
58+
59+ return addItem(query as CFDictionary, nil) == errSecSuccess
60+ }
61+
62+ /// Retrieves the stored API key, or `nil` if none exists.
63+ func load() -> String? {
64+ var query = baseQuery
65+ query[kSecReturnData as String] = true
66+ query[kSecMatchLimit as String] = kSecMatchLimitOne
67+
68+ var item: CFTypeRef?
69+ let status = copyMatching(query as CFDictionary, &item)
70+ guard status == errSecSuccess, let data = item as? Data else { return nil }
71+ return String(data: data, encoding: .utf8)
72+ }
73+
74+ /// Deletes the stored API key if it exists.
75+ /// - Returns: `true` if the item is gone afterwards — including when it never existed.
76+ @discardableResult
77+ func delete() -> Bool {
78+ let status = deleteItem(baseQuery as CFDictionary)
79+ return status == errSecSuccess || status == errSecItemNotFound
80+ }
81+}
82+
83+/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
84+enum KeychainHelper {
85+ private static let service = "soy.iko.usage-menubar"
86+ private static let account = "hyper-api-key"
87+
88+ private static let store = KeychainStore(service: service, account: account)
89+
90+ /// Saves the given API key to the Keychain, overwriting any existing entry.
91+ /// - Returns: `true` on success; `false` if the item could not be stored, e.g. because
92+ /// the keychain is locked.
93+ @discardableResult
94+ static func save(_ key: String) -> Bool {
95+ store.save(key)
96+ }
97+
98+ /// Retrieves the stored API key, or `nil` if none exists.
99+ static func load() -> String? {
100+ store.load()
101+ }
102+
103+ /// Deletes the stored API key if it exists.
104+ @discardableResult
105+ static func delete() -> Bool {
106+ store.delete()
107+ }
108+}
109+
110+/// Wrapper around the macOS Keychain for storing the z.ai API key.
111+///
112+/// Uses the same keychain service as the Hyper key but a different account, so the
113+/// two keys coexist without clobbering each other.
114+enum ZaiKeychainHelper {
115+ private static let service = "soy.iko.usage-menubar"
116+ private static let account = "zai-api-key"
117+
118+ private static let store = KeychainStore(service: service, account: account)
119+
120+ /// Saves the given z.ai API key to the Keychain, overwriting any existing entry.
121+ @discardableResult
122+ static func save(_ key: String) -> Bool {
123+ store.save(key)
124+ }
125+
126+ /// Retrieves the stored z.ai API key, or `nil` if none exists.
127+ static func load() -> String? {
128+ store.load()
129+ }
130+
131+ /// Deletes the stored z.ai API key if it exists.
132+ @discardableResult
133+ static func delete() -> Bool {
134+ store.delete()
135+ }
136+}
UsageMenubar/MenuView.swiftnew file+741
@@ -0,0 +1,741 @@
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/UsageMenubar.entitlementsnew file+16
@@ -0,0 +1,16 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<!--
4+ Deliberately not sandboxed. Reading Claude Code's usage limits means reading the
5+ credentials Claude Code owns: its login-Keychain item (via /usr/bin/security) and
6+ ~/.claude/.credentials.json. A sandboxed app can spawn neither the process nor read
7+ a file outside its container, so the sandbox and the feature are mutually exclusive.
8+ The app ships ad-hoc signed through Homebrew rather than through the App Store, where
9+ the sandbox would be mandatory.
10+-->
11+<plist version="1.0">
12+<dict>
13+ <key>com.apple.security.network.client</key>
14+ <true/>
15+</dict>
16+</plist>
UsageMenubar/UsageMenubarApp.swiftnew file+277
@@ -0,0 +1,277 @@
1+import SwiftUI
2+import Combine
3+import UserNotifications
4+
5+/// Main app delegate that manages the menu bar item, the refresh timer,
6+/// the SwiftUI popover hosting `MenuView`, and sleep/wake handling.
7+///
8+/// Marked `@MainActor` because `ViewModel` is `@MainActor`-isolated and the
9+/// delegate accesses it directly. All `NSApplicationDelegate` methods are
10+/// called on the main thread regardless, so this is semantically correct.
11+@MainActor
12+final class AppDelegate: NSObject, NSApplicationDelegate {
13+ private var statusItem: NSStatusItem!
14+ private var popover: NSPopover!
15+ private var refreshTimer: Timer?
16+
17+ /// The shared view model. Shared between the menu bar and the popover.
18+ let viewModel = ViewModel()
19+
20+ /// Combine cancellables for observing published changes.
21+ private var statusTextCancellable: AnyCancellable?
22+
23+ /// The site the "Open" menu item points at.
24+ private static let websiteURL = URL(string: "https://hyper.charm.land")!
25+
26+ // MARK: - Lifecycle
27+
28+ func applicationDidFinishLaunching(_ notification: Notification) {
29+ // Only prompt if the user has never answered; re-asking after a denial
30+ // does nothing, and after an approval it's pointless.
31+ requestNotificationPermissionIfNeeded()
32+
33+ // Create status bar item
34+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
35+
36+ if let button = statusItem.button {
37+ button.image = nil
38+ button.title = "…"
39+ button.target = self
40+ button.action = #selector(statusItemClicked(_:))
41+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
42+ }
43+
44+ // Create popover with SwiftUI content
45+ popover = NSPopover()
46+ popover.behavior = .transient
47+ popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
48+
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.
52+ //
53+ // The title is built from the values the publisher hands us, not by reading the
54+ // view model back: `@Published` emits in `willSet`, so the property still holds
55+ // its old value while this closure runs.
56+ //
57+ // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
58+ // whose Combine scheduler only runs in the default run loop mode — the title
59+ // 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+ }
101+
102+ // Register for sleep/wake notifications
103+ registerSleepWakeNotifications()
104+
105+ // Restart the timer whenever the user picks a new interval in the popover.
106+ NotificationCenter.default.addObserver(
107+ self,
108+ selector: #selector(refreshIntervalDidChange(_:)),
109+ name: .refreshIntervalDidChange,
110+ object: nil
111+ )
112+
113+ // Initial fetch
114+ viewModel.refresh()
115+
116+ // Start periodic refresh
117+ startRefreshTimer()
118+ }
119+
120+ func applicationWillTerminate(_ notification: Notification) {
121+ refreshTimer?.invalidate()
122+ statusTextCancellable?.cancel()
123+ NotificationCenter.default.removeObserver(self)
124+ NSWorkspace.shared.notificationCenter.removeObserver(self)
125+ }
126+
127+ // MARK: - Refresh Timer
128+
129+ /// (Re)starts the periodic refresh using the interval stored in the view model.
130+ private func startRefreshTimer() {
131+ refreshTimer?.invalidate()
132+
133+ let minutes = max(1, viewModel.refreshIntervalMinutes)
134+ let interval = TimeInterval(minutes * 60)
135+
136+ let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
137+ Task { @MainActor in self?.viewModel.refresh() }
138+ }
139+ RunLoop.main.add(timer, forMode: .common)
140+ refreshTimer = timer
141+ }
142+
143+ @objc private func refreshIntervalDidChange(_ notification: Notification) {
144+ startRefreshTimer()
145+ }
146+
147+ // MARK: - Notification Permission
148+
149+ private func requestNotificationPermissionIfNeeded() {
150+ let center = UNUserNotificationCenter.current()
151+ center.getNotificationSettings { settings in
152+ guard settings.authorizationStatus == .notDetermined else { return }
153+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in
154+ // Permission result is handled silently; notifications are optional.
155+ }
156+ }
157+ }
158+
159+ // MARK: - Sleep / Wake
160+
161+ private func registerSleepWakeNotifications() {
162+ let workspace = NSWorkspace.shared
163+ let notificationCenter = workspace.notificationCenter
164+
165+ notificationCenter.addObserver(
166+ self,
167+ selector: #selector(systemWillSleep(_:)),
168+ name: NSWorkspace.willSleepNotification,
169+ object: nil
170+ )
171+
172+ notificationCenter.addObserver(
173+ self,
174+ selector: #selector(systemDidWake(_:)),
175+ name: NSWorkspace.didWakeNotification,
176+ object: nil
177+ )
178+ }
179+
180+ @objc private func systemWillSleep(_ notification: Notification) {
181+ // Nothing to do on sleep; timer will pause naturally.
182+ }
183+
184+ @objc private func systemDidWake(_ notification: Notification) {
185+ // Refresh after wake to get a fresh balance.
186+ viewModel.refresh()
187+ }
188+
189+ // MARK: - Status Item Click
190+
191+ /// Left-click toggles the popover; right-click (or control-click) opens the context menu.
192+ @objc private func statusItemClicked(_ sender: Any?) {
193+ let event = NSApp.currentEvent
194+ let isRightClick = event?.type == .rightMouseUp
195+ || event?.type == .rightMouseDown
196+ || event?.modifierFlags.contains(.control) == true
197+
198+ if isRightClick {
199+ showContextMenu()
200+ } else {
201+ togglePopover()
202+ }
203+ }
204+
205+ // MARK: - Context Menu
206+
207+ private func showContextMenu() {
208+ if popover.isShown {
209+ popover.performClose(nil)
210+ }
211+
212+ let menu = NSMenu()
213+
214+ let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu(_:)), keyEquivalent: "r")
215+ refreshItem.target = self
216+ menu.addItem(refreshItem)
217+
218+ let openItem = NSMenuItem(title: "Open hyper.charm.land", action: #selector(openWebsite(_:)), keyEquivalent: "")
219+ openItem.target = self
220+ menu.addItem(openItem)
221+
222+ menu.addItem(.separator())
223+
224+ let quitItem = NSMenuItem(title: "Quit UsageMenubar", action: #selector(quit(_:)), keyEquivalent: "q")
225+ quitItem.target = self
226+ menu.addItem(quitItem)
227+
228+ // Attaching the menu makes the next click present it (with the correct
229+ // highlight + placement), then we detach so left-clicks keep firing our
230+ // action instead of reopening the menu.
231+ statusItem.menu = menu
232+ statusItem.button?.performClick(nil)
233+ statusItem.menu = nil
234+ }
235+
236+ @objc private func refreshFromMenu(_ sender: Any?) {
237+ viewModel.refresh()
238+ }
239+
240+ @objc private func openWebsite(_ sender: Any?) {
241+ NSWorkspace.shared.open(Self.websiteURL)
242+ }
243+
244+ @objc private func quit(_ sender: Any?) {
245+ NSApplication.shared.terminate(nil)
246+ }
247+
248+ // MARK: - Popover
249+
250+ /// Shows the popover if it isn't already visible.
251+ ///
252+ /// We don't toggle: the popover has `.transient` behavior, which closes it
253+ /// automatically when the user clicks outside it — including on the status
254+ /// item. By the time our `.leftMouseUp` action fires, a previously-shown
255+ /// popover has already closed, so `isShown` is `false` and a toggle would
256+ /// reopen it instead of dismissing it. Always showing means the first click
257+ /// opens and `.transient` handles the rest.
258+ private func togglePopover() {
259+ guard let button = statusItem.button else { return }
260+ guard !popover.isShown else { return }
261+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
262+ NSApp.activate(ignoringOtherApps: true)
263+ }
264+}
265+
266+// MARK: - App Entry Point
267+
268+@main
269+struct UsageMenubarApp: App {
270+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
271+
272+ var body: some Scene {
273+ Settings {
274+ EmptyView()
275+ }
276+ }
277+}
HyperCreditsMenubar/VersionFormatter.swift → UsageMenubar/VersionFormatter.swiftrenamed

The content is the same.

UsageMenubar/ViewModel.swiftnew file+899
@@ -0,0 +1,899 @@
1+import Foundation
2+import SwiftUI
3+import Combine
4+import ServiceManagement
5+import UserNotifications
6+
7+/// Posted when the user changes the refresh interval, so the app delegate can
8+/// restart its timer with the new period.
9+extension Notification.Name {
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
11+}
12+
13+/// The direction a metric has moved over the most recent history points.
14+enum MetricTrend {
15+ case increasing
16+ case decreasing
17+ case stable
18+ /// Not enough history to say.
19+ case unknown
20+
21+ /// SF Symbol for the trend, or `nil` when there is nothing worth showing.
22+ var symbolName: String? {
23+ switch self {
24+ case .increasing: return "arrow.up.right"
25+ case .decreasing: return "arrow.down.right"
26+ case .stable: return "arrow.right"
27+ case .unknown: return nil
28+ }
29+ }
30+}
31+
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
34+struct MetricPoint: Equatable {
35+ let date: Date
36+ let value: Int
37+}
38+
39+/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
40+/// the app delegate can read the options without hopping onto the main actor.
41+enum RefreshInterval {
42+ /// `UserDefaults` key for the refresh period, in minutes.
43+ static let defaultsKey = "refreshIntervalMinutes"
44+
45+ /// The intervals offered in the settings picker.
46+ static let options = [1, 5, 15, 30]
47+
48+ /// Used when nothing valid is stored.
49+ static let defaultMinutes = 5
50+
51+ /// Reads the stored interval, falling back to the default when unset or when
52+ /// the stored value isn't one we offer.
53+ static func stored(in defaults: UserDefaults = .standard) -> Int {
54+ let minutes = defaults.integer(forKey: defaultsKey)
55+ guard options.contains(minutes) else { return defaultMinutes }
56+ return minutes
57+ }
58+}
59+
60+/// The view model that drives the menu bar display and the popover content.
61+@MainActor
62+final class ViewModel: ObservableObject {
63+ /// The last known balance. `nil` when there has never been a successful fetch.
64+ @Published var balance: Int?
65+
66+ /// `true` while a balance request is in flight.
67+ @Published var isLoading = false
68+
69+ /// A human-readable error message, shown when the balance can't be fetched.
70+ @Published var errorMessage: String?
71+
72+ /// Claude Code's subscription usage. `nil` when Claude Code has never signed in on
73+ /// this machine, in which case the popover leaves the section out entirely rather
74+ /// than nagging someone who doesn't use it.
75+ @Published var claudeUsage: ClaudeUsage?
76+
77+ /// A human-readable error message from the Claude fetch. Independent of
78+ /// `errorMessage`: the two services fail separately.
79+ @Published var claudeError: String?
80+
81+ /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
82+ @Published var claudePlan: String?
83+
84+ /// z.ai Coding Plan usage. `nil` when no z.ai API key has been entered, in which
85+ /// case the popover leaves the section out entirely.
86+ @Published var zaiUsage: ZaiUsageReport?
87+
88+ /// A human-readable error message from the z.ai fetch. Independent of
89+ /// `errorMessage` and `claudeError`: the three services fail separately.
90+ @Published var zaiError: String?
91+
92+ /// The z.ai API key draft bound to the text field. Two-way bound to the
93+ /// `SecureField`, so it changes on every keystroke — never fetch with it, use
94+ /// `activeZaiAPIKey`.
95+ @Published var zaiAPIKeyInput: String = ""
96+
97+ /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
98+ /// Drives whether the `⚡` segment appears in the menu bar at all: an
99+ /// unconfigured service should not take up space in the title.
100+ @Published private(set) var hyperConfigured = false
101+
102+ /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
103+ /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
104+ @Published var apiKeyInput: String = ""
105+
106+ /// Timestamp of the last successful balance fetch.
107+ @Published var lastUpdated: Date?
108+
109+ /// Brief "✓ Saved" confirmation shown after saving the API key.
110+ @Published var savedConfirmation: Bool = false
111+
112+ /// Brief "✓ Copied" confirmation shown after copying the balance.
113+ @Published var copiedConfirmation: Bool = false
114+
115+ /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116+ @Published var copiedClaudeConfirmation: Bool = false
117+
118+ /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119+ @Published var copiedZaiConfirmation: Bool = false
120+
121+ /// Successful balance observations from the last 24 hours, oldest first.
122+ @Published private(set) var history: [MetricPoint] = []
123+
124+ /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125+ @Published private(set) var claudeHistory: [MetricPoint] = []
126+
127+ /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128+ @Published private(set) var zaiHistory: [MetricPoint] = []
129+
130+ /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131+ /// changing it posts `.refreshIntervalDidChange` so the app delegate can
132+ /// restart its timer.
133+ @Published var refreshIntervalMinutes: Int {
134+ didSet {
135+ guard refreshIntervalMinutes != oldValue else { return }
136+ defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
137+ NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
138+ }
139+ }
140+
141+ /// Whether launch-at-login is enabled.
142+ @Published var launchAtLogin: Bool {
143+ didSet {
144+ guard !isInitializingLaunchAtLogin else { return }
145+ updateLaunchAtLogin(launchAtLogin)
146+ }
147+ }
148+
149+ /// Flag to suppress the didSet during initial setup.
150+ private var isInitializingLaunchAtLogin = true
151+
152+ /// The key requests actually go out with: what is in the Keychain, not what is
153+ /// currently in the text field. Keeping these separate stops a timer or wake
154+ /// refresh from firing with a half-typed key — or, if the field has been cleared
155+ /// to paste a new one, from taking the empty-key path and wiping the balance,
156+ /// last-updated time and history out from under the user.
157+ private var activeAPIKey: String
158+
159+ /// The z.ai key requests actually go out with: what is in the Keychain, not what
160+ /// is currently in the text field. Same rationale as `activeAPIKey`.
161+ private var activeZaiAPIKey: String
162+
163+ private let checker: CreditsChecking
164+ private let claudeChecker: ClaudeUsageChecking
165+ private let zaiChecker: ZaiUsageChecking
166+ private let defaults: UserDefaults
167+
168+ /// The currently in-flight refresh task, if any. Used to cancel
169+ /// overlapping refreshes (timer + user click + wake).
170+ private var refreshTask: Task<Void, Never>?
171+
172+ /// Resets `copiedConfirmation`; held so rapid clicks re-arm the delay
173+ /// instead of the first click's reset firing early.
174+ private var copiedResetTask: Task<Void, Never>?
175+
176+ /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177+ private var savedResetTask: Task<Void, Never>?
178+
179+ /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180+ private var copiedClaudeResetTask: Task<Void, Never>?
181+
182+ /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183+ private var copiedZaiResetTask: Task<Void, Never>?
184+
185+ // MARK: - History Configuration
186+
187+ /// 24 hours at the shortest (1 minute) interval would overflow this, so the
188+ /// count cap and the age cutoff both apply; whichever bites first wins.
189+ private static let maxHistoryEntries = 300
190+ private static let historyWindow: TimeInterval = 24 * 60 * 60
191+
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
195+ init(
196+ checker: CreditsChecking = CreditsChecker(),
197+ claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198+ zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199+ defaults: UserDefaults = .standard
200+ ) {
201+ self.checker = checker
202+ self.claudeChecker = claudeChecker
203+ self.zaiChecker = zaiChecker
204+ self.defaults = defaults
205+ let storedKey = KeychainHelper.load() ?? ""
206+ apiKeyInput = storedKey
207+ activeAPIKey = storedKey
208+ hyperConfigured = !storedKey.isEmpty
209+ let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210+ zaiAPIKeyInput = storedZaiKey
211+ activeZaiAPIKey = storedZaiKey
212+ // Assigning in init doesn't fire didSet, so this load doesn't re-post
213+ // the change notification or write back to defaults.
214+ refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
215+ launchAtLogin = SMAppService.mainApp.status == .enabled
216+ isInitializingLaunchAtLogin = false
217+ }
218+
219+ // MARK: - Balance
220+
221+ /// Cached number formatter for balance display.
222+ private static let balanceFormatter: NumberFormatter = {
223+ let formatter = NumberFormatter()
224+ formatter.numberStyle = .decimal
225+ return formatter
226+ }()
227+
228+ /// Cached relative time formatter for "Updated Xm ago" text.
229+ private static let relativeFormatter: RelativeDateTimeFormatter = {
230+ let formatter = RelativeDateTimeFormatter()
231+ formatter.unitsStyle = .short
232+ return formatter
233+ }()
234+
235+ /// Formats an integer with thousands grouping separator.
236+ private static func formatBalance(_ value: Int) -> String {
237+ balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238+ }
239+
240+ /// The text to show in the menu bar. Each configured provider contributes a
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
242+ /// (middle dot). Within a segment, windows are listed as bare percentages
243+ /// separated by spaces. An unconfigured service produces no segment.
244+ /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245+ ///
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247+ ///
248+ /// Takes its values as parameters rather than reading the properties: `@Published`
249+ /// publishes in `willSet`, so a Combine subscriber that called back into the view
250+ /// model would see the *pre-change* value and render a title one update behind.
251+ static func statusBarText(
252+ balance: Int?,
253+ isLoading: Bool,
254+ hyperConfigured: Bool,
255+ claudeFiveHourPercent: Int?,
256+ claudeSevenDayPercent: Int?,
257+ zaiFiveHourPercent: Int?,
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
260+ ) -> String {
261+ var segments: [String] = []
262+
263+ // Hyper
264+ if hyperConfigured, let balance = balance {
265+ segments.append("⚡\(formatBalance(balance))")
266+ }
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
272+ }
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
275+ }
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
278+ }
279+
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
284+ }
285+ if let zaiWeekly = zaiWeeklyPercent {
286+ zaiWindows.append("\(zaiWeekly)%")
287+ }
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
294+ }
295+
296+ if segments.isEmpty {
297+ // No service is configured or has anything to show. While loading,
298+ // show an ellipsis; otherwise the title is empty.
299+ return isLoading ? "…" : ""
300+ }
301+ return segments.joined(separator: " · ")
302+ }
303+
304+ /// The text to show in the menu bar for the current state.
305+ var statusBarItemText: String {
306+ Self.statusBarText(
307+ balance: balance,
308+ isLoading: isLoading,
309+ hyperConfigured: hyperConfigured,
310+ claudeFiveHourPercent: claudeFiveHourPercent,
311+ claudeSevenDayPercent: claudeSevenDayPercent,
312+ zaiFiveHourPercent: zaiFiveHourPercent,
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
315+ )
316+ }
317+
318+ /// Whether z.ai is currently in peak hours (06:00–10:00 UTC).
319+ static var zaiInPeakHours: Bool {
320+ let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321+ return hour >= 6 && hour < 10
322+ }
323+
324+ /// The formatted balance string for display in the popover.
325+ var formattedBalance: String {
326+ guard let balance = balance else { return "?" }
327+ return Self.formatBalance(balance)
328+ }
329+
330+ /// The color for the balance display based on thresholds.
331+ var balanceColor: Color {
332+ guard let balance = balance else { return .secondary }
333+ if balance >= 100 { return .green }
334+ if balance >= 10 { return .yellow }
335+ return .red
336+ }
337+
338+ /// Relative time string for the last successful update, or nil if never updated.
339+ var lastUpdatedText: String? {
340+ guard let lastUpdated = lastUpdated else { return nil }
341+ return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342+ }
343+
344+ // MARK: - History (Hyper)
345+
346+ /// The last 24 hours of successful balance fetches, oldest first.
347+ var balanceHistory: [(date: Date, balance: Int)] {
348+ history.map { (date: $0.date, balance: $0.value) }
349+ }
350+
351+ /// The direction of travel across the most recent handful of observations.
352+ /// `.unknown` until there are at least two points to compare.
353+ var balanceTrend: MetricTrend {
354+ metricTrend(history)
355+ }
356+
357+ /// The sparkline's data for the Hyper balance.
358+ var balanceSparklineValues: [Int] {
359+ history.map { $0.value }
360+ }
361+
362+ // MARK: - History (Claude)
363+
364+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365+ var claudeSparklineValues: [Int] {
366+ claudeHistory.map { $0.value }
367+ }
368+
369+ /// The direction of travel for the Claude 5-hour %.
370+ var claudeTrend: MetricTrend {
371+ metricTrend(claudeHistory)
372+ }
373+
374+ // MARK: - History (z.ai)
375+
376+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377+ var zaiSparklineValues: [Int] {
378+ zaiHistory.map { $0.value }
379+ }
380+
381+ /// The direction of travel for the z.ai 5-hour %.
382+ var zaiTrend: MetricTrend {
383+ metricTrend(zaiHistory)
384+ }
385+
386+ // MARK: - History Helpers
387+
388+ /// Computes the trend from a generic history array.
389+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390+ guard points.count >= 2 else { return .unknown }
391+ let recent = points.suffix(5)
392+ guard let first = recent.first, let last = recent.last else { return .unknown }
393+ if last.value > first.value { return .increasing }
394+ if last.value < first.value { return .decreasing }
395+ return .stable
396+ }
397+
398+ /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400+ array.append(MetricPoint(date: date, value: value))
401+
402+ let cutoff = date.addingTimeInterval(-Self.historyWindow)
403+ array.removeAll { $0.date < cutoff }
404+
405+ if array.count > Self.maxHistoryEntries {
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
407+ }
408+ }
409+
410+ // MARK: - Refresh
411+
412+ /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
413+ ///
414+ /// The three are independent: any can fail, or not be configured at all, without
415+ /// touching the others. Cancels any previously in-flight refresh to prevent races.
416+ func refresh() {
417+ refreshTask?.cancel()
418+
419+ // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
420+ let key = activeAPIKey
421+ let zaiKey = activeZaiAPIKey
422+ if key.isEmpty {
423+ // Nothing to fetch, and nothing worth keeping: the balance, its history and
424+ // any error all belonged to a key that is no longer there. Claude and z.ai
425+ // are left alone — they don't depend on the Hyper key.
426+ balance = nil
427+ errorMessage = nil
428+ lastUpdated = nil
429+ history.removeAll()
430+ }
431+
432+ refreshTask = Task { @MainActor in
433+ // Check cancellation before setting isLoading — a previous
434+ // refresh() may have cancelled us and already set isLoading = false.
435+ guard !Task.isCancelled else { return }
436+ // Loading state covers all fetches: the popover's loading view depends
437+ // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
438+ isLoading = true
439+ // Don't clear the errors here — keep showing the last one
440+ // until we have a successful result.
441+
442+ // All three requests go out at once; none waits on the others.
443+ async let balanceOutcome = fetchBalance(key: key)
444+ async let claudeOutcome = fetchClaudeUsage()
445+ async let zaiOutcome = fetchZaiUsage(key: zaiKey)
446+
447+ let balanceResult = await balanceOutcome
448+ let claudeResult = await claudeOutcome
449+ let zaiResult = await zaiOutcome
450+ guard !Task.isCancelled else { return }
451+
452+ applyBalance(balanceResult)
453+ applyClaude(claudeResult)
454+ applyZai(zaiResult)
455+ isLoading = false
456+ }
457+ }
458+
459+ /// The balance fetch, or `nil` when there is no key to fetch with.
460+ private func fetchBalance(key: String) async -> Result<Int, Error>? {
461+ guard !key.isEmpty else { return nil }
462+ do {
463+ return .success(try await checker.fetchBalance(apiKey: key))
464+ } catch {
465+ return .failure(error)
466+ }
467+ }
468+
469+ private func applyBalance(_ result: Result<Int, Error>?) {
470+ guard let result else { return }
471+
472+ switch result {
473+ case .success(let newBalance):
474+ // Capture the previous balance before overwriting it: the low-balance
475+ // notification fires on the crossing, not on the value.
476+ let previousBalance = balance
477+ balance = newBalance
478+ errorMessage = nil
479+ let now = Date()
480+ lastUpdated = now
481+ recordMetric(newBalance, into: &history, at: now)
482+
483+ // Low-balance notification: on threshold crossing (≥10 → <10)
484+ // OR on first successful fetch if already below threshold.
485+ if let previous = previousBalance, previous >= 10, newBalance < 10 {
486+ sendLowBalanceNotification(balance: newBalance)
487+ } else if previousBalance == nil && newBalance < 10 {
488+ sendLowBalanceNotification(balance: newBalance)
489+ }
490+
491+ case .failure(let error):
492+ // Keep the stale balance value — don't wipe it on error.
493+ // Only set balance = nil if there was never a successful fetch
494+ // (it's already nil in that case, so nothing to do).
495+ errorMessage = error.localizedDescription
496+ }
497+ }
498+
499+ // MARK: - Claude Usage
500+
501+ /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
502+ /// plenty of people don't use Claude Code, and an error telling them so would be
503+ /// noise rather than news.
504+ private enum ClaudeOutcome {
505+ case notConfigured
506+ case usage(ClaudeUsageReport)
507+ case failure(String)
508+ }
509+
510+ private func fetchClaudeUsage() async -> ClaudeOutcome {
511+ do {
512+ return .usage(try await claudeChecker.fetchUsage())
513+ } catch ClaudeUsageError.noCredentials {
514+ return .notConfigured
515+ } catch {
516+ return .failure(error.localizedDescription)
517+ }
518+ }
519+
520+ private func applyClaude(_ outcome: ClaudeOutcome) {
521+ switch outcome {
522+ case .notConfigured:
523+ claudeUsage = nil
524+ claudePlan = nil
525+ claudeError = nil
526+ claudeHistory.removeAll()
527+
528+ case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
531+ claudeUsage = report.usage
532+ claudePlan = report.subscriptionType
533+ claudeError = nil
534+
535+ // Record history and fire high-usage notification based on the 5-hour %.
536+ if let percent = claudeFiveHourPercent {
537+ let now = Date()
538+ recordMetric(percent, into: &claudeHistory, at: now)
539+
540+ // High-usage notification: on threshold crossing (<90 → ≥90)
541+ // OR on first successful fetch if already above threshold.
542+ if let previous = previousPercent, previous < Self.highUsageThreshold,
543+ percent >= Self.highUsageThreshold {
544+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
545+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
546+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
547+ }
548+ }
549+
550+ case .failure(let message):
551+ // As with the balance: keep the last good numbers on screen and say what
552+ // went wrong, rather than blanking the section on a transient failure.
553+ claudeError = message
554+ }
555+ }
556+
557+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
558+ var claudeFiveHourPercent: Int? {
559+ guard let fiveHour = claudeUsage?.fiveHour else { return nil }
560+ return Int(fiveHour.utilization.rounded())
561+ }
562+
563+ /// The 7-day window percentage for the menu bar title, or `nil` when absent.
564+ /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
565+ /// has been used — on Max plans the Opus cap is the one that binds first.
566+ var claudeSevenDayPercent: Int? {
567+ if let sevenDay = claudeUsage?.sevenDay {
568+ return Int(sevenDay.utilization.rounded())
569+ }
570+ if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571+ return Int(opus.utilization.rounded())
572+ }
573+ return nil
574+ }
575+
576+ /// Display label for the Claude plan, e.g. "Pro" or "Max".
577+ var claudePlanLabel: String? {
578+ guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
579+ !plan.isEmpty else { return nil }
580+
581+ switch plan.lowercased() {
582+ case "free": return "Free"
583+ case "pro": return "Pro"
584+ case "max": return "Max"
585+ case "team": return "Team"
586+ case "enterprise": return "Enterprise"
587+ default: return plan.capitalized
588+ }
589+ }
590+
591+ // MARK: - z.ai Usage
592+
593+ /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
594+ /// plenty of people don't use z.ai, and an error telling them so would be noise.
595+ private enum ZaiOutcome {
596+ case notConfigured
597+ case usage(ZaiUsageReport)
598+ case failure(String)
599+ }
600+
601+ private func fetchZaiUsage(key: String) async -> ZaiOutcome {
602+ guard !key.isEmpty else { return .notConfigured }
603+ do {
604+ return .usage(try await zaiChecker.fetchUsage(apiKey: key))
605+ } catch ZaiUsageError.noAPIKey {
606+ return .notConfigured
607+ } catch {
608+ return .failure(error.localizedDescription)
609+ }
610+ }
611+
612+ private func applyZai(_ outcome: ZaiOutcome) {
613+ switch outcome {
614+ case .notConfigured:
615+ zaiUsage = nil
616+ zaiError = nil
617+ zaiHistory.removeAll()
618+
619+ case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
622+ zaiUsage = report
623+ zaiError = nil
624+
625+ // Record history and fire high-usage notification based on the 5-hour %.
626+ if let percent = zaiFiveHourPercent {
627+ let now = Date()
628+ recordMetric(percent, into: &zaiHistory, at: now)
629+
630+ // High-usage notification: on threshold crossing (<90 → ≥90)
631+ // OR on first successful fetch if already above threshold.
632+ if let previous = previousPercent, previous < Self.highUsageThreshold,
633+ percent >= Self.highUsageThreshold {
634+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637+ }
638+ }
639+
640+ case .failure(let message):
641+ // As with the balance and Claude: keep the last good numbers on screen
642+ // and say what went wrong, rather than blanking the section on a
643+ // transient failure.
644+ zaiError = message
645+ }
646+ }
647+
648+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
649+ var zaiFiveHourPercent: Int? {
650+ zaiUsage?.fiveHourPercent
651+ }
652+
653+ /// The weekly window percentage for the popover, or `nil` when absent.
654+ var zaiWeeklyPercent: Int? {
655+ zaiUsage?.weeklyPercent
656+ }
657+
658+ /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
659+ /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
660+ var zaiPlanLabel: String? {
661+ guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
662+ !level.isEmpty else { return nil }
663+
664+ // The subscription endpoint returns a product name like "GLM Coding Max";
665+ // extract the tier from it. The quota endpoint returns just the tier.
666+ let lowered = level.lowercased()
667+ if lowered.contains("max") { return "Max" }
668+ if lowered.contains("pro") { return "Pro" }
669+ if lowered.contains("lite") { return "Lite" }
670+ return level.capitalized
671+ }
672+
673+ // MARK: - Clipboard
674+
675+ /// Copies the current balance to the clipboard as plain digits (no grouping
676+ /// separator), and shows a brief "✓ Copied" confirmation.
677+ func copyBalanceToClipboard() {
678+ guard let balance = balance else { return }
679+
680+ let pasteboard = NSPasteboard.general
681+ pasteboard.clearContents()
682+ pasteboard.setString(String(balance), forType: .string)
683+
684+ copiedConfirmation = true
685+ copiedResetTask?.cancel()
686+ copiedResetTask = Task { @MainActor in
687+ try? await Task.sleep(nanoseconds: 1_500_000_000)
688+ guard !Task.isCancelled else { return }
689+ copiedConfirmation = false
690+ }
691+ }
692+
693+ /// Copies the current Claude 5-hour percentage to the clipboard.
694+ func copyClaudePercentToClipboard() {
695+ guard let percent = claudeFiveHourPercent else { return }
696+
697+ let pasteboard = NSPasteboard.general
698+ pasteboard.clearContents()
699+ pasteboard.setString("\(percent)%", forType: .string)
700+
701+ copiedClaudeConfirmation = true
702+ copiedClaudeResetTask?.cancel()
703+ copiedClaudeResetTask = Task { @MainActor in
704+ try? await Task.sleep(nanoseconds: 1_500_000_000)
705+ guard !Task.isCancelled else { return }
706+ copiedClaudeConfirmation = false
707+ }
708+ }
709+
710+ /// Copies the current z.ai 5-hour percentage to the clipboard.
711+ func copyZaiPercentToClipboard() {
712+ guard let percent = zaiFiveHourPercent else { return }
713+
714+ let pasteboard = NSPasteboard.general
715+ pasteboard.clearContents()
716+ pasteboard.setString("\(percent)%", forType: .string)
717+
718+ copiedZaiConfirmation = true
719+ copiedZaiResetTask?.cancel()
720+ copiedZaiResetTask = Task { @MainActor in
721+ try? await Task.sleep(nanoseconds: 1_500_000_000)
722+ guard !Task.isCancelled else { return }
723+ copiedZaiConfirmation = false
724+ }
725+ }
726+
727+ // MARK: - Notifications
728+
729+ private func sendLowBalanceNotification(balance: Int) {
730+ // Build the body here, on the main actor. `getNotificationSettings` calls back on
731+ // a background queue, and the shared `balanceFormatter` is main-actor state — the
732+ // status bar title formats against the very same instance.
733+ let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
734+
735+ let center = UNUserNotificationCenter.current()
736+ center.getNotificationSettings { settings in
737+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
738+ return
739+ }
740+ let content = UNMutableNotificationContent()
741+ content.title = "Low Usage Menubar"
742+ content.body = body
743+ content.sound = .default
744+
745+ // A fixed identifier makes a later alert replace the delivered one in place,
746+ // which updates the text without ever notifying the user again.
747+ let request = UNNotificationRequest(
748+ identifier: "low-balance-alert-\(UUID().uuidString)",
749+ content: content,
750+ trigger: nil
751+ )
752+ center.add(request)
753+ }
754+ }
755+
756+ /// Sends a notification when a usage percentage crosses the high-usage threshold.
757+ private func sendHighUsageNotification(provider: String, percent: Int) {
758+ let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
759+
760+ let center = UNUserNotificationCenter.current()
761+ center.getNotificationSettings { settings in
762+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
763+ return
764+ }
765+ let content = UNMutableNotificationContent()
766+ content.title = "High Usage Menubar"
767+ content.body = body
768+ content.sound = .default
769+
770+ let request = UNNotificationRequest(
771+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
772+ content: content,
773+ trigger: nil
774+ )
775+ center.add(request)
776+ }
777+ }
778+
779+ // MARK: - API Key
780+
781+ /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
782+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
783+ func saveAPIKey() {
784+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
785+ if key.isEmpty {
786+ let deleted = KeychainHelper.delete()
787+ guard deleted else {
788+ errorMessage = "Could not delete API key from Keychain. It may be locked."
789+ return
790+ }
791+ apiKeyInput = ""
792+ activeAPIKey = ""
793+ hyperConfigured = false
794+ // refresh() with an empty key clears balance, error, history, and
795+ // cancels any in-flight task from the previous key.
796+ refresh()
797+ } else {
798+ let saved = KeychainHelper.save(key)
799+ guard saved else {
800+ errorMessage = "Could not save API key to Keychain. It may be locked."
801+ return
802+ }
803+ // A different key is a different account, so everything derived from the old
804+ // one has to go — not just the history behind the sparkline and trend. A
805+ // carried-over balance would be displayed as the new account's until the
806+ // fetch lands, and it would stand in as `previousBalance` for the low-balance
807+ // threshold check, which would then compare two unrelated accounts.
808+ if key != activeAPIKey {
809+ balance = nil
810+ lastUpdated = nil
811+ errorMessage = nil
812+ history.removeAll()
813+ }
814+ // Show the trimmed key that was actually stored.
815+ apiKeyInput = key
816+ activeAPIKey = key
817+ hyperConfigured = true
818+ refresh()
819+ }
820+
821+ // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
822+ // Cancel any previous reset so rapid Save clicks don't hide the
823+ // confirmation prematurely.
824+ savedConfirmation = true
825+ savedResetTask?.cancel()
826+ savedResetTask = Task { @MainActor in
827+ try? await Task.sleep(nanoseconds: 2_000_000_000)
828+ guard !Task.isCancelled else { return }
829+ savedConfirmation = false
830+ }
831+ }
832+
833+ // MARK: - z.ai API Key
834+
835+ /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
836+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
837+ func saveZaiAPIKey() {
838+ let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
839+ if key.isEmpty {
840+ let deleted = ZaiKeychainHelper.delete()
841+ guard deleted else {
842+ zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
843+ return
844+ }
845+ zaiAPIKeyInput = ""
846+ activeZaiAPIKey = ""
847+ // refresh() with an empty key clears zai usage and error, and
848+ // cancels any in-flight task from the previous key.
849+ refresh()
850+ } else {
851+ let saved = ZaiKeychainHelper.save(key)
852+ guard saved else {
853+ zaiError = "Could not save z.ai API key to Keychain. It may be locked."
854+ return
855+ }
856+ // A different key is a different account, so everything derived from the
857+ // old one has to go.
858+ if key != activeZaiAPIKey {
859+ zaiUsage = nil
860+ zaiError = nil
861+ zaiHistory.removeAll()
862+ }
863+ // Show the trimmed key that was actually stored.
864+ zaiAPIKeyInput = key
865+ activeZaiAPIKey = key
866+ refresh()
867+ }
868+
869+ // Reuse the same "✓ Saved" confirmation — the user sees one per save action,
870+ // regardless of which key it was for.
871+ savedConfirmation = true
872+ savedResetTask?.cancel()
873+ savedResetTask = Task { @MainActor in
874+ try? await Task.sleep(nanoseconds: 2_000_000_000)
875+ guard !Task.isCancelled else { return }
876+ savedConfirmation = false
877+ }
878+ }
879+
880+ // MARK: - Launch at Login
881+
882+ func updateLaunchAtLogin(_ enabled: Bool) {
883+ do {
884+ if enabled {
885+ try SMAppService.mainApp.register()
886+ } else {
887+ try SMAppService.mainApp.unregister()
888+ }
889+ } catch {
890+ errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
891+ + error.localizedDescription
892+ // Snap the switch back to what the system actually did, suppressing the
893+ // didSet so this doesn't recurse into another register/unregister attempt.
894+ isInitializingLaunchAtLogin = true
895+ launchAtLogin = SMAppService.mainApp.status == .enabled
896+ isInitializingLaunchAtLogin = false
897+ }
898+ }
899+}
UsageMenubar/ZaiUsageClient.swiftnew file+388
@@ -0,0 +1,388 @@
1+import Foundation
2+
3+// MARK: - Usage
4+
5+/// Usage data from z.ai's coding plan API — two endpoints that together give the
6+/// plan level, the 5-hour token quota, and the weekly token quota.
7+///
8+/// Every field in the report is optional. The quota endpoint may return fewer limits
9+/// than expected, and the subscription endpoint may return an empty list, so an
10+/// unrecognized shape degrades to "nothing to show" rather than failing the decode.
11+struct ZaiUsageReport: Equatable {
12+ /// Percent of the 5-hour token window consumed, 0–100.
13+ let fiveHourPercent: Int?
14+ /// Percent of the weekly token window consumed, 0–100.
15+ let weeklyPercent: Int?
16+ /// When the 5-hour window resets, or `nil` when not provided.
17+ let fiveHourResetsAt: Date?
18+ /// When the weekly window resets, or `nil` when not provided.
19+ let weeklyResetsAt: Date?
20+ /// The plan tier from the quota response: `"lite"`, `"pro"`, `"max"`.
21+ let planLevel: String?
22+
23+ /// Whether there is anything at all to render. A report with all-nil fields reads
24+ /// as a section header over nothing, which looks like a bug.
25+ var isEmpty: Bool {
26+ fiveHourPercent == nil && weeklyPercent == nil && planLevel == nil
27+ }
28+}
29+
30+// MARK: - API Response Models
31+
32+/// Response from `GET https://api.z.ai/api/monitor/usage/quota/limit`.
33+struct ZaiQuotaResponse: Decodable, Equatable {
34+ let code: Int
35+ let msg: String?
36+ let data: ZaiQuotaData?
37+ let success: Bool
38+
39+ private enum CodingKeys: String, CodingKey {
40+ case code, msg, data, success
41+ }
42+
43+ init(from decoder: Decoder) throws {
44+ let container = try decoder.container(keyedBy: CodingKeys.self)
45+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
46+ msg = try container.decodeIfPresent(String.self, forKey: .msg)
47+ data = try container.decodeIfPresent(ZaiQuotaData.self, forKey: .data)
48+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
49+ }
50+}
51+
52+struct ZaiQuotaData: Decodable, Equatable {
53+ let limits: [ZaiLimit]?
54+ let level: String?
55+
56+ private enum CodingKeys: String, CodingKey {
57+ case limits, level
58+ }
59+
60+ init(from decoder: Decoder) throws {
61+ let container = try decoder.container(keyedBy: CodingKeys.self)
62+ limits = try container.decodeIfPresent([ZaiLimit].self, forKey: .limits)
63+ level = try container.decodeIfPresent(String.self, forKey: .level)
64+ }
65+}
66+
67+/// One limit entry from the quota response.
68+///
69+/// `unit` identifies the window: 3 = 5-hour, 6 = weekly, 5 = monthly tools.
70+/// `type` is `"TOKENS_LIMIT"` for token quotas or `"TIME_LIMIT"` for tool-call quotas.
71+struct ZaiLimit: Decodable, Equatable {
72+ let type: String
73+ let unit: Int
74+ let percentage: Int
75+ /// Epoch milliseconds, or `nil` when nothing has been used.
76+ let nextResetTime: Int64?
77+
78+ private enum CodingKeys: String, CodingKey {
79+ case type, unit, percentage, nextResetTime
80+ }
81+
82+ init(from decoder: Decoder) throws {
83+ let container = try decoder.container(keyedBy: CodingKeys.self)
84+ type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
85+ unit = try container.decodeIfPresent(Int.self, forKey: .unit) ?? 0
86+ // Decoded as Double and rounded: the field is documented as an integer, but
87+ // a fractional value would throw and take the whole payload down with it.
88+ let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percentage) ?? 0
89+ percentage = Int(rawPercent.rounded())
90+ nextResetTime = try container.decodeIfPresent(Int64.self, forKey: .nextResetTime)
91+ }
92+
93+ init(type: String, unit: Int, percentage: Int, nextResetTime: Int64? = nil) {
94+ self.type = type
95+ self.unit = unit
96+ self.percentage = percentage
97+ self.nextResetTime = nextResetTime
98+ }
99+}
100+
101+/// Response from `GET https://api.z.ai/api/biz/subscription/list`.
102+struct ZaiSubscriptionResponse: Decodable, Equatable {
103+ let code: Int
104+ let data: [ZaiSubscription]?
105+ let success: Bool
106+
107+ private enum CodingKeys: String, CodingKey {
108+ case code, data, success
109+ }
110+
111+ init(from decoder: Decoder) throws {
112+ let container = try decoder.container(keyedBy: CodingKeys.self)
113+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
114+ data = try container.decodeIfPresent([ZaiSubscription].self, forKey: .data)
115+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
116+ }
117+}
118+
119+struct ZaiSubscription: Decodable, Equatable {
120+ let productName: String?
121+ let status: String?
122+
123+ private enum CodingKeys: String, CodingKey {
124+ case productName, status
125+ }
126+
127+ init(from decoder: Decoder) throws {
128+ let container = try decoder.container(keyedBy: CodingKeys.self)
129+ productName = try container.decodeIfPresent(String.self, forKey: .productName)
130+ status = try container.decodeIfPresent(String.self, forKey: .status)
131+ }
132+
133+ init(productName: String? = nil, status: String? = nil) {
134+ self.productName = productName
135+ self.status = status
136+ }
137+}
138+
139+// MARK: - Errors
140+
141+/// Errors that can occur while fetching z.ai usage.
142+enum ZaiUsageError: LocalizedError, Equatable {
143+ /// No API key has been entered. Not an error the user needs to see — the popover
144+ /// just leaves the section out, same as missing Claude credentials.
145+ case noAPIKey
146+
147+ /// The API key was rejected by z.ai.
148+ case invalidAPIKey
149+ case requestFailed(String)
150+ case decodingFailed(String)
151+
152+ var errorDescription: String? {
153+ switch self {
154+ case .noAPIKey:
155+ return nil
156+ case .invalidAPIKey:
157+ return "z.ai API key is invalid. Check your key at z.ai/manage-apikey/coding-plan/personal/my-plan"
158+ case .requestFailed(let message):
159+ return "z.ai request failed: \(message)"
160+ case .decodingFailed(let message):
161+ return "Failed to decode z.ai usage: \(message)"
162+ }
163+ }
164+}
165+
166+// MARK: - Protocol
167+
168+/// Protocol abstraction for fetching z.ai usage, enabling test injection.
169+protocol ZaiUsageChecking {
170+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport
171+}
172+
173+// MARK: - Client
174+
175+/// Client for z.ai's coding plan usage API, authenticating with an API key the user
176+/// enters in settings (stored in Keychain, separate from the Hyper key).
177+///
178+/// Fetches the quota/limit and subscription endpoints concurrently to get both usage
179+/// percentages and the plan tier in one round-trip. Retries transient failures with
180+/// exponential backoff, same pattern as `CreditsChecker` and `ClaudeUsageClient`.
181+actor ZaiUsageClient: ZaiUsageChecking {
182+ static let baseURL = URL(string: "https://api.z.ai/api")!
183+ static let quotaEndpoint = URL(string: "https://api.z.ai/api/monitor/usage/quota/limit")!
184+ static let subscriptionEndpoint = URL(string: "https://api.z.ai/api/biz/subscription/list")!
185+
186+ /// Retries *after* the initial attempt, so at most 3 requests per call.
187+ static let maxRetries = 2
188+
189+ /// URL error codes worth retrying: the request never reached a server that had an
190+ /// opinion about it, so the same request may well succeed a moment later.
191+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
192+ .timedOut,
193+ .cannotConnectToHost,
194+ .cannotFindHost,
195+ .dnsLookupFailed,
196+ .networkConnectionLost,
197+ .notConnectedToInternet,
198+ .resourceUnavailable,
199+ .badServerResponse,
200+ ]
201+
202+ /// Client-error status codes that are nonetheless worth retrying: both are the
203+ /// server telling us to come back later, not that the request itself is wrong.
204+ private static let retryableStatusCodes: Set<Int> = [
205+ 408, // Request Timeout
206+ 429, // Too Many Requests
207+ ]
208+
209+ private let session: URLSession
210+ private let retryBaseDelay: Duration
211+
212+ /// - Parameters:
213+ /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
214+ /// one (2s, then 4s). Injectable so tests need not wait in real time.
215+ init(
216+ session: URLSession? = nil,
217+ retryBaseDelay: Duration = .seconds(2)
218+ ) {
219+ self.retryBaseDelay = retryBaseDelay
220+ if let session {
221+ self.session = session
222+ } else {
223+ let config = URLSessionConfiguration.default
224+ config.timeoutIntervalForRequest = 10
225+ config.timeoutIntervalForResource = 15
226+ self.session = URLSession(configuration: config)
227+ }
228+ }
229+
230+ /// Fetches current usage with the given API key.
231+ ///
232+ /// The quota and subscription endpoints are fetched concurrently; neither waits on
233+ /// the other. The subscription endpoint is best-effort — if it fails, the plan
234+/// level from the quota response (`data.level`) is used instead.
235+ ///
236+ /// - Throws: `ZaiUsageError`. `.noAPIKey` means no key was provided, which callers
237+ /// should treat as "nothing to show" rather than as a failure.
238+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport {
239+ guard !apiKey.isEmpty else { throw ZaiUsageError.noAPIKey }
240+
241+ async let quotaOutcome = fetchQuota(apiKey: apiKey)
242+ async let subscriptionOutcome = fetchSubscription(apiKey: apiKey)
243+
244+ let quota = try await quotaOutcome
245+ let subscription = try? await subscriptionOutcome
246+
247+ // Extract percentages from the limits array.
248+ var fiveHourPercent: Int?
249+ var weeklyPercent: Int?
250+ var fiveHourResetsAt: Date?
251+ var weeklyResetsAt: Date?
252+
253+ for limit in quota?.limits ?? [] {
254+ switch limit.unit {
255+ case 3: // 5-hour window
256+ fiveHourPercent = limit.percentage
257+ fiveHourResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
258+ case 6: // weekly window
259+ weeklyPercent = limit.percentage
260+ weeklyResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
261+ default:
262+ break
263+ }
264+ }
265+
266+ // Plan level: prefer the product name from the subscription, fall back to the
267+ // quota response's `level` field.
268+ var planLevel: String? = quota?.level
269+ if let sub = subscription?.data?.first(where: { $0.status == "VALID" }),
270+ let name = sub.productName {
271+ planLevel = name
272+ }
273+
274+ return ZaiUsageReport(
275+ fiveHourPercent: fiveHourPercent,
276+ weeklyPercent: weeklyPercent,
277+ fiveHourResetsAt: fiveHourResetsAt,
278+ weeklyResetsAt: weeklyResetsAt,
279+ planLevel: planLevel
280+ )
281+ }
282+
283+ // MARK: - Quota Request
284+
285+ private func fetchQuota(apiKey: String) async throws -> ZaiQuotaData? {
286+ var request = URLRequest(url: Self.quotaEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
287+ request.httpMethod = "GET"
288+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
289+ request.setValue("application/json", forHTTPHeaderField: "Accept")
290+
291+ let data = try await send(request)
292+
293+ do {
294+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: data)
295+ guard response.success else {
296+ throw ZaiUsageError.requestFailed(response.msg ?? "Unknown error")
297+ }
298+ return response.data
299+ } catch let error as ZaiUsageError {
300+ throw error
301+ } catch {
302+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
303+ }
304+ }
305+
306+ // MARK: - Subscription Request
307+
308+ private func fetchSubscription(apiKey: String) async throws -> ZaiSubscriptionResponse {
309+ var request = URLRequest(url: Self.subscriptionEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
310+ request.httpMethod = "GET"
311+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
312+ request.setValue("application/json", forHTTPHeaderField: "Accept")
313+
314+ let data = try await send(request)
315+
316+ do {
317+ return try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: data)
318+ } catch {
319+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
320+ }
321+ }
322+
323+ // MARK: - Transport
324+
325+ /// Sends a request, retrying transient failures with exponential backoff.
326+ private func send(_ request: URLRequest) async throws -> Data {
327+ var attempt = 0
328+
329+ while true {
330+ do {
331+ return try await performAttempt(request)
332+ } catch let failure as AttemptFailure {
333+ guard failure.isRetryable, attempt < Self.maxRetries else {
334+ throw failure.underlying
335+ }
336+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
337+ attempt += 1
338+ }
339+ }
340+ }
341+
342+ /// Performs a single request, tagging the failure with whether a retry could
343+ /// plausibly help.
344+ private func performAttempt(_ request: URLRequest) async throws -> Data {
345+ let data: Data
346+ let response: URLResponse
347+ do {
348+ (data, response) = try await session.data(for: request)
349+ } catch let urlError as URLError {
350+ throw AttemptFailure(
351+ underlying: .requestFailed(urlError.localizedDescription),
352+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
353+ )
354+ } catch {
355+ throw AttemptFailure(
356+ underlying: .requestFailed(error.localizedDescription),
357+ isRetryable: false
358+ )
359+ }
360+
361+ guard let httpResponse = response as? HTTPURLResponse else {
362+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
363+ }
364+
365+ switch httpResponse.statusCode {
366+ case 200...299:
367+ return data
368+ case 401:
369+ throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
370+ case 500...599:
371+ throw AttemptFailure(
372+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
373+ isRetryable: true
374+ )
375+ default:
376+ throw AttemptFailure(
377+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
378+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
379+ )
380+ }
381+ }
382+
383+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
384+ private struct AttemptFailure: Error {
385+ let underlying: ZaiUsageError
386+ let isRetryable: Bool
387+ }
388+}
UsageMenubarTests/ClaudeUsageClientTests.swiftnew file+557
@@ -0,0 +1,557 @@
1+import XCTest
2+import Foundation
3+@testable import UsageMenubar
4+
5+/// The instant the countdown tests reckon from, so "3h 20m" is a fact rather than a race
6+/// against the wall clock.
7+private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
8+
9+final class ClaudeUsageClientTests: XCTestCase {
10+
11+ override func setUp() {
12+ super.setUp()
13+ StubURLProtocol.reset()
14+ }
15+
16+ override func tearDown() {
17+ StubURLProtocol.reset()
18+ super.tearDown()
19+ }
20+
21+ // MARK: - Fixtures
22+
23+ /// A client whose requests are served by `StubURLProtocol` and whose credentials
24+ /// come from memory, so no test here touches the real Keychain, the real
25+ /// `~/.claude/.credentials.json`, or the network. The backoff is short enough that
26+ /// the retry tests run in milliseconds rather than seconds.
27+ private func makeClient(
28+ store: ClaudeCredentialStoring,
29+ retryBaseDelay: Duration = .milliseconds(1)
30+ ) -> ClaudeUsageClient {
31+ let config = URLSessionConfiguration.ephemeral
32+ config.protocolClasses = [StubURLProtocol.self]
33+ return ClaudeUsageClient(
34+ session: URLSession(configuration: config),
35+ store: store,
36+ retryBaseDelay: retryBaseDelay
37+ )
38+ }
39+
40+ private func credentials(
41+ accessToken: String = "access-1",
42+ subscriptionType: String? = "max"
43+ ) -> ClaudeOAuthCredentials {
44+ ClaudeOAuthCredentials(
45+ claudeAiOauth: .init(
46+ accessToken: accessToken,
47+ scopes: ["user:inference"],
48+ subscriptionType: subscriptionType,
49+ rateLimitTier: nil
50+ )
51+ )
52+ }
53+
54+ private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
55+ Data("""
56+ {
57+ "five_hour": {"utilization": \(fiveHour), "resets_at": "2023-11-14T23:53:20.000Z"},
58+ "seven_day": {"utilization": \(sevenDay), "resets_at": "2023-11-20T23:53:20.000Z"}
59+ }
60+ """.utf8)
61+ }
62+
63+ // MARK: - Usage decoding
64+
65+ func testDecodesUsagePayload() throws {
66+ let json = Data("""
67+ {
68+ "five_hour": {"utilization": 68.4, "resets_at": "2026-07-11T20:00:00.000Z"},
69+ "seven_day": {"utilization": 41, "resets_at": "2026-07-15T20:00:00.000Z"},
70+ "seven_day_opus": {"utilization": 12, "resets_at": null},
71+ "limits": [
72+ {"percent": 41, "is_active": false, "resets_at": "2026-07-15T20:00:00.000Z"},
73+ {"percent": 68, "is_active": true, "resets_at": "2026-07-11T20:00:00.000Z"}
74+ ]
75+ }
76+ """.utf8)
77+
78+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
79+
80+ XCTAssertEqual(usage.fiveHour?.utilization, 68.4)
81+ XCTAssertEqual(usage.sevenDay?.utilization, 41)
82+ XCTAssertEqual(usage.sevenDayOpus?.utilization, 12)
83+ XCTAssertNil(usage.sevenDayOpus?.resetsAt)
84+ XCTAssertEqual(usage.limits?.count, 2)
85+ XCTAssertFalse(usage.isEmpty)
86+ }
87+
88+ func testDecodesEmptyPayload() throws {
89+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: Data("{}".utf8))
90+
91+ XCTAssertNil(usage.fiveHour)
92+ XCTAssertNil(usage.limits)
93+ XCTAssertTrue(usage.isEmpty, "An account with no usage has nothing worth a section")
94+ }
95+
96+ func testIgnoresWindowsItDoesNotModel() throws {
97+ // Anthropic adds and retires windows without notice; an unknown one must not
98+ // take the rest of the payload down with it.
99+ let json = Data("""
100+ {
101+ "five_hour": {"utilization": 5},
102+ "tangelo": {"utilization": 99},
103+ "iguana_necktie": {"utilization": 99},
104+ "spend": {"percent": 3, "enabled": true}
105+ }
106+ """.utf8)
107+
108+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
109+
110+ XCTAssertEqual(usage.fiveHour?.utilization, 5)
111+ XCTAssertFalse(usage.isEmpty)
112+ }
113+
114+ func testMissingUtilizationDefaultsToZero() throws {
115+ let json = Data(#"{"five_hour": {"resets_at": "2026-07-11T20:00:00.000Z"}}"#.utf8)
116+
117+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
118+
119+ XCTAssertEqual(usage.fiveHour?.utilization, 0)
120+ }
121+
122+ func testFractionalPercentIsRounded() throws {
123+ // The field is documented as an integer, but decoding it as one would throw on
124+ // a fractional value and lose the whole payload.
125+ let json = Data(#"{"limits": [{"percent": 68.6, "is_active": true}]}"#.utf8)
126+
127+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
128+
129+ XCTAssertEqual(usage.activeLimit?.percent, 69)
130+ }
131+
132+ func testActiveLimitIsTheOneTheServerFlags() throws {
133+ let json = Data("""
134+ {"limits": [
135+ {"percent": 41, "is_active": false},
136+ {"percent": 68, "is_active": true}
137+ ]}
138+ """.utf8)
139+
140+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
141+
142+ XCTAssertEqual(usage.activeLimit?.percent, 68)
143+ }
144+
145+ func testActiveLimitIsNilWhenNoneIsFlagged() throws {
146+ let json = Data(#"{"limits": [{"percent": 41}, {"percent": 12}]}"#.utf8)
147+
148+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
149+
150+ XCTAssertNil(usage.activeLimit)
151+ }
152+
153+ // MARK: - Reset countdown
154+
155+ func testResetsInFormatsHoursAndMinutes() {
156+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(3 * 3600 + 20 * 60)))
157+
158+ XCTAssertEqual(window.resetsIn(from: fixedNow), "3h 20m")
159+ }
160+
161+ func testResetsInFormatsDaysAndHours() {
162+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(28 * 3600)))
163+
164+ XCTAssertEqual(window.resetsIn(from: fixedNow), "1d 4h")
165+ }
166+
167+ func testResetsInFormatsMinutesAlone() {
168+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(12 * 60)))
169+
170+ XCTAssertEqual(window.resetsIn(from: fixedNow), "12m")
171+ }
172+
173+ func testResetsInIsNilOnceTheWindowHasPassed() {
174+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(-60)))
175+
176+ XCTAssertNil(window.resetsIn(from: fixedNow))
177+ }
178+
179+ func testResetsInIsNilWithoutATimestamp() {
180+ XCTAssertNil(UsageWindow(utilization: 0).resetsIn(from: fixedNow))
181+ }
182+
183+ func testResetsAtParsesTimestampsWithoutFractionalSeconds() {
184+ // Claude's timestamps normally carry fractional seconds; this spelling has to
185+ // parse too, rather than silently costing us the countdown.
186+ let window = UsageWindow(utilization: 50, resetsAt: "2026-07-11T20:00:00Z")
187+
188+ XCTAssertEqual(window.resetsAtDate?.timeIntervalSince1970, 1_783_800_000)
189+ }
190+
191+ func testResetsAtIsNilForAnUnparseableTimestamp() {
192+ XCTAssertNil(UsageWindow(utilization: 50, resetsAt: "not a date").resetsAtDate)
193+ }
194+
195+ func testLimitAlsoCountsDown() {
196+ let limit = Limit(percent: 68, resetsAt: iso(fixedNow.addingTimeInterval(90 * 60)), isActive: true)
197+
198+ XCTAssertEqual(limit.resetsIn(from: fixedNow), "1h 30m")
199+ }
200+
201+ // MARK: - Fetching
202+
203+ func testFetchSendsTheOAuthHeaders() async throws {
204+ let store = FakeClaudeCredentialStore(credentials: credentials())
205+ StubURLProtocol.enqueue(.status(200, usageJSON()))
206+
207+ let report = try await makeClient(store: store).fetchUsage()
208+
209+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
210+ XCTAssertEqual(report.subscriptionType, "max")
211+
212+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
213+ XCTAssertEqual(request.url, ClaudeUsageClient.usageEndpoint)
214+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-1")
215+ XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-beta"), "oauth-2025-04-20")
216+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
217+ }
218+
219+ func testMissingCredentialsThrowWithoutTouchingTheNetwork() async {
220+ let store = FakeClaudeCredentialStore(credentials: nil)
221+
222+ do {
223+ _ = try await makeClient(store: store).fetchUsage()
224+ XCTFail("Expected noCredentials error")
225+ } catch ClaudeUsageError.noCredentials {
226+ // expected — the caller reads this as "Claude Code isn't set up here"
227+ } catch {
228+ XCTFail("Unexpected error: \(error)")
229+ }
230+
231+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
232+ }
233+
234+ func testTheStoredTokenIsSpentExactlyAsItIs() async throws {
235+ let store = FakeClaudeCredentialStore(credentials: credentials())
236+ StubURLProtocol.enqueue(.status(200, usageJSON()))
237+
238+ _ = try await makeClient(store: store).fetchUsage()
239+
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.
242+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
243+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].url, ClaudeUsageClient.usageEndpoint)
244+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].httpMethod, "GET")
245+ }
246+
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()))
253+
254+ let client = makeClient(store: store)
255+ _ = try await client.fetchUsage()
256+
257+ store.replace(with: credentials(accessToken: "access-2"))
258+ _ = try await client.fetchUsage()
259+
260+ XCTAssertEqual(store.loadCount, 2)
261+ let sent = StubURLProtocol.recordedRequests.map { $0.value(forHTTPHeaderField: "Authorization") }
262+ XCTAssertEqual(sent, ["Bearer access-1", "Bearer access-2"])
263+ }
264+
265+ // MARK: - Rejected tokens
266+
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.
270+ let store = FakeClaudeCredentialStore(credentials: credentials())
271+ StubURLProtocol.enqueue(.status(401, Data()))
272+
273+ do {
274+ _ = try await makeClient(store: store).fetchUsage()
275+ XCTFail("Expected invalidCredentials error")
276+ } catch ClaudeUsageError.invalidCredentials {
277+ // expected
278+ } catch {
279+ XCTFail("Unexpected error: \(error)")
280+ }
281+
282+ XCTAssertEqual(
283+ StubURLProtocol.requestCount,
284+ 1,
285+ "A 401 must end the fetch — no refresh, and no retry of the request either"
286+ )
287+ }
288+
289+ func testForbiddenIsTreatedAsARejectedToken() async {
290+ let store = FakeClaudeCredentialStore(credentials: credentials())
291+ StubURLProtocol.enqueue(.status(403, Data()))
292+
293+ do {
294+ _ = try await makeClient(store: store).fetchUsage()
295+ XCTFail("Expected invalidCredentials error")
296+ } catch ClaudeUsageError.invalidCredentials {
297+ // expected
298+ } catch {
299+ XCTFail("Unexpected error: \(error)")
300+ }
301+
302+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
303+ }
304+
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.")
310+ }
311+
312+ // MARK: - Retries
313+
314+ func testRetriesOnServerErrorThenSucceeds() async throws {
315+ let store = FakeClaudeCredentialStore(credentials: credentials())
316+ StubURLProtocol.enqueue(
317+ .status(500, Data()),
318+ .status(503, Data()),
319+ .status(200, usageJSON())
320+ )
321+
322+ let report = try await makeClient(store: store).fetchUsage()
323+
324+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
325+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
326+ }
327+
328+ func testRetriesOnRateLimitThenSucceeds() async throws {
329+ let store = FakeClaudeCredentialStore(credentials: credentials())
330+ StubURLProtocol.enqueue(.status(429, Data()), .status(200, usageJSON()))
331+
332+ _ = try await makeClient(store: store).fetchUsage()
333+
334+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
335+ }
336+
337+ func testRetriesOnNetworkErrorThenSucceeds() async throws {
338+ let store = FakeClaudeCredentialStore(credentials: credentials())
339+ StubURLProtocol.enqueue(.failure(URLError(.timedOut)), .status(200, usageJSON()))
340+
341+ _ = try await makeClient(store: store).fetchUsage()
342+
343+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
344+ }
345+
346+ func testGivesUpAfterTwoRetries() async {
347+ let store = FakeClaudeCredentialStore(credentials: credentials())
348+ StubURLProtocol.enqueue(.status(500, Data())) // repeats
349+
350+ do {
351+ _ = try await makeClient(store: store).fetchUsage()
352+ XCTFail("Expected requestFailed error")
353+ } catch ClaudeUsageError.requestFailed(let message) {
354+ XCTAssertEqual(message, "HTTP 500")
355+ } catch {
356+ XCTFail("Unexpected error: \(error)")
357+ }
358+
359+ // Initial attempt + 2 retries.
360+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
361+ }
362+
363+ func testDoesNotRetryOnClientError() async {
364+ let store = FakeClaudeCredentialStore(credentials: credentials())
365+ StubURLProtocol.enqueue(.status(404, Data()))
366+
367+ do {
368+ _ = try await makeClient(store: store).fetchUsage()
369+ XCTFail("Expected requestFailed error")
370+ } catch ClaudeUsageError.requestFailed(let message) {
371+ XCTAssertEqual(message, "HTTP 404")
372+ } catch {
373+ XCTFail("Unexpected error: \(error)")
374+ }
375+
376+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
377+ }
378+
379+ func testDoesNotRetryOnDecodingFailure() async {
380+ let store = FakeClaudeCredentialStore(credentials: credentials())
381+ StubURLProtocol.enqueue(.status(200, Data("not json".utf8)))
382+
383+ do {
384+ _ = try await makeClient(store: store).fetchUsage()
385+ XCTFail("Expected decodingFailed error")
386+ } catch ClaudeUsageError.decodingFailed {
387+ // expected
388+ } catch {
389+ XCTFail("Unexpected error: \(error)")
390+ }
391+
392+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
393+ }
394+
395+ // MARK: - Errors
396+
397+ func testErrorsAreEquatable() {
398+ XCTAssertEqual(ClaudeUsageError.noCredentials, .noCredentials)
399+ XCTAssertNotEqual(ClaudeUsageError.noCredentials, .invalidCredentials)
400+ XCTAssertEqual(ClaudeUsageError.requestFailed("HTTP 500"), .requestFailed("HTTP 500"))
401+ }
402+
403+ func testClaudeUsageClientConformsToProtocol() {
404+ let checker: ClaudeUsageChecking = ClaudeUsageClient()
405+ // If this compiles, ClaudeUsageClient conforms to ClaudeUsageChecking.
406+ XCTAssertNotNil(checker)
407+ }
408+
409+ // MARK: - Helpers
410+
411+ /// An ISO 8601 timestamp with fractional seconds, as Claude's API spells them.
412+ private func iso(_ date: Date) -> String {
413+ let formatter = ISO8601DateFormatter()
414+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
415+ return formatter.string(from: date)
416+ }
417+}
418+
419+// MARK: - Credential store
420+
421+/// These drive `ClaudeCodeCredentialStore` against a throwaway file and a Keychain
422+/// service that cannot exist, so `security` always misses and the file fallback is what
423+/// gets exercised. Nothing here can read — let alone write — the real Claude Code
424+/// credentials, which is the whole point: a test that clobbered them would sign the
425+/// user out of their own CLI.
426+final class ClaudeCodeCredentialStoreTests: XCTestCase {
427+
428+ private var directory: URL!
429+
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.
432+ private let credentialsJSON = """
433+ {
434+ "claudeAiOauth": {
435+ "accessToken": "access-1",
436+ "refreshToken": "refresh-1",
437+ "expiresAt": 4000000000000,
438+ "scopes": ["user:inference", "user:profile"],
439+ "subscriptionType": "max",
440+ "somethingWeDoNotModel": "keep me"
441+ },
442+ "anotherTopLevelKey": "keep me too"
443+ }
444+ """
445+
446+ override func setUpWithError() throws {
447+ try super.setUpWithError()
448+ directory = URL(fileURLWithPath: NSTemporaryDirectory())
449+ .appendingPathComponent("claude-credentials-\(UUID().uuidString)")
450+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
451+ }
452+
453+ override func tearDownWithError() throws {
454+ if let directory {
455+ try? FileManager.default.removeItem(at: directory)
456+ }
457+ try super.tearDownWithError()
458+ }
459+
460+ private func makeStore(file: URL) -> ClaudeCodeCredentialStore {
461+ ClaudeCodeCredentialStore(
462+ keychainService: "com.example.tests.absent-\(UUID().uuidString)",
463+ credentialsFile: file
464+ )
465+ }
466+
467+ private func writeCredentialsFile() throws -> URL {
468+ let file = directory.appendingPathComponent(".credentials.json")
469+ try Data(credentialsJSON.utf8).write(to: file)
470+ return file
471+ }
472+
473+ func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
474+ let file = try writeCredentialsFile()
475+
476+ let credentials = try XCTUnwrap(makeStore(file: file).load())
477+
478+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
479+ XCTAssertEqual(credentials.claudeAiOauth.subscriptionType, "max")
480+ XCTAssertEqual(credentials.claudeAiOauth.scopes, ["user:inference", "user:profile"])
481+ }
482+
483+ func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
484+ let missing = directory.appendingPathComponent("nothing-here.json")
485+
486+ XCTAssertNil(makeStore(file: missing).load())
487+ }
488+
489+ func testReturnsNilForACorruptedFile() throws {
490+ let file = directory.appendingPathComponent(".credentials.json")
491+ try Data("{ not json".utf8).write(to: file)
492+
493+ XCTAssertNil(makeStore(file: file).load())
494+ }
495+
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)
501+
502+ let credentials = try XCTUnwrap(makeStore(file: file).load())
503+
504+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
505+ XCTAssertNil(credentials.claudeAiOauth.subscriptionType)
506+ }
507+
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.
512+ let file = try writeCredentialsFile()
513+ let before = try Data(contentsOf: file)
514+
515+ _ = makeStore(file: file).load()
516+
517+ XCTAssertEqual(try Data(contentsOf: file), before)
518+ }
519+}
520+
521+// MARK: - Fake credential store
522+
523+/// 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.
528+final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
529+ private let lock = NSLock()
530+ private var current: ClaudeOAuthCredentials?
531+ private var loads = 0
532+
533+ init(credentials: ClaudeOAuthCredentials?) {
534+ current = credentials
535+ }
536+
537+ /// How many times the client has gone back to the store — it should be once per fetch.
538+ var loadCount: Int {
539+ lock.lock()
540+ defer { lock.unlock() }
541+ return loads
542+ }
543+
544+ /// Stands in for the CLI refreshing its own token between two fetches.
545+ func replace(with credentials: ClaudeOAuthCredentials?) {
546+ lock.lock()
547+ defer { lock.unlock() }
548+ current = credentials
549+ }
550+
551+ func load() -> ClaudeOAuthCredentials? {
552+ lock.lock()
553+ defer { lock.unlock() }
554+ loads += 1
555+ return current
556+ }
557+}
UsageMenubarTests/CreditsCheckerTests.swiftnew file+646
@@ -0,0 +1,646 @@
1+import XCTest
2+import Foundation
3+import Security
4+@testable import UsageMenubar
5+
6+final class CreditsCheckerTests: XCTestCase {
7+
8+ override func setUp() {
9+ super.setUp()
10+ StubURLProtocol.reset()
11+ }
12+
13+ override func tearDown() {
14+ StubURLProtocol.reset()
15+ super.tearDown()
16+ }
17+
18+ /// A checker whose requests are served by `StubURLProtocol`, with a backoff short
19+ /// enough that the retry tests run in milliseconds rather than seconds.
20+ private func makeStubbedChecker(retryBaseDelay: Duration = .milliseconds(1)) -> CreditsChecker {
21+ let config = URLSessionConfiguration.ephemeral
22+ config.protocolClasses = [StubURLProtocol.self]
23+ return CreditsChecker(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
24+ }
25+
26+ // MARK: - BalanceResponse Decoding
27+
28+ func testDecodeValidBalance() throws {
29+ let json = #"{"balance": 42}"#.data(using: .utf8)!
30+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
31+ XCTAssertEqual(response.balance, 42)
32+ }
33+
34+ func testDecodeZeroBalance() throws {
35+ let json = #"{"balance": 0}"#.data(using: .utf8)!
36+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
37+ XCTAssertEqual(response.balance, 0)
38+ }
39+
40+ func testDecodeLargeBalance() throws {
41+ let json = #"{"balance": 999999}"#.data(using: .utf8)!
42+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
43+ XCTAssertEqual(response.balance, 999999)
44+ }
45+
46+ func testDecodeMissingBalanceThrows() {
47+ let json = #"{"foo": "bar"}"#.data(using: .utf8)!
48+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
49+ }
50+
51+ func testDecodeNegativeBalance() throws {
52+ // The API could theoretically return a negative balance
53+ let json = #"{"balance": -5}"#.data(using: .utf8)!
54+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
55+ XCTAssertEqual(response.balance, -5)
56+ }
57+
58+ // MARK: - Fractional balance decoding
59+
60+ func testDecodeFractionalBalanceRoundsUp() throws {
61+ let json = #"{"balance": 42.5}"#.data(using: .utf8)!
62+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
63+ XCTAssertEqual(response.balance, 43)
64+ }
65+
66+ func testDecodeFractionalBalanceRoundsDown() throws {
67+ let json = #"{"balance": 42.4}"#.data(using: .utf8)!
68+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
69+ XCTAssertEqual(response.balance, 42)
70+ }
71+
72+ func testDecodeNegativeFractionalBalanceRoundsAwayFromZero() throws {
73+ let json = #"{"balance": -2.5}"#.data(using: .utf8)!
74+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
75+ XCTAssertEqual(response.balance, -3)
76+ }
77+
78+ func testDecodeFractionalZeroBalance() throws {
79+ let json = #"{"balance": 0.2}"#.data(using: .utf8)!
80+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
81+ XCTAssertEqual(response.balance, 0)
82+ }
83+
84+ func testDecodeIntegerValuedDoubleBalance() throws {
85+ let json = #"{"balance": 100.0}"#.data(using: .utf8)!
86+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
87+ XCTAssertEqual(response.balance, 100)
88+ }
89+
90+ func testDecodeBalanceTooLargeForIntThrows() {
91+ // Not representable as an Int, so rounding cannot save it.
92+ let json = #"{"balance": 1e30}"#.data(using: .utf8)!
93+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
94+ }
95+
96+ func testDecodeStringBalanceThrows() {
97+ let json = #"{"balance": "42"}"#.data(using: .utf8)!
98+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
99+ }
100+
101+ func testFetchBalanceRoundsFractionalResponse() async throws {
102+ StubURLProtocol.enqueue(.status(200, #"{"balance": 12.7}"#.data(using: .utf8)!))
103+
104+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
105+
106+ XCTAssertEqual(balance, 13)
107+ }
108+
109+ // MARK: - Request construction
110+
111+ func testRequestSendsNoCacheAndAuthorizationHeaders() async throws {
112+ StubURLProtocol.enqueue(.status(200, #"{"balance": 1}"#.data(using: .utf8)!))
113+
114+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
115+
116+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
117+ XCTAssertEqual(request.value(forHTTPHeaderField: "Cache-Control"), "no-cache")
118+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer sk-test")
119+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
120+ }
121+
122+ // MARK: - Retry behaviour
123+
124+ func testRetriesOnServerErrorThenSucceeds() async throws {
125+ StubURLProtocol.enqueue(
126+ .status(500, Data()),
127+ .status(503, Data()),
128+ .status(200, #"{"balance": 7}"#.data(using: .utf8)!)
129+ )
130+
131+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
132+
133+ XCTAssertEqual(balance, 7)
134+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
135+ }
136+
137+ func testRetriesOnNetworkTimeoutThenSucceeds() async throws {
138+ StubURLProtocol.enqueue(
139+ .failure(URLError(.timedOut)),
140+ .status(200, #"{"balance": 5}"#.data(using: .utf8)!)
141+ )
142+
143+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
144+
145+ XCTAssertEqual(balance, 5)
146+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
147+ }
148+
149+ func testGivesUpAfterTwoRetries() async {
150+ // The last enqueued outcome repeats, so every attempt sees a 500.
151+ StubURLProtocol.enqueue(.status(500, Data()))
152+
153+ do {
154+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
155+ XCTFail("Expected requestFailed error")
156+ } catch CreditsError.requestFailed(let message) {
157+ XCTAssertEqual(message, "HTTP 500")
158+ } catch {
159+ XCTFail("Unexpected error: \(error)")
160+ }
161+
162+ // Initial attempt + 2 retries.
163+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
164+ }
165+
166+ func testGivesUpAfterTwoRetriesOnPersistentNetworkFailure() async {
167+ StubURLProtocol.enqueue(.failure(URLError(.networkConnectionLost)))
168+
169+ do {
170+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
171+ XCTFail("Expected requestFailed error")
172+ } catch CreditsError.requestFailed {
173+ // expected
174+ } catch {
175+ XCTFail("Unexpected error: \(error)")
176+ }
177+
178+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
179+ }
180+
181+ func testRetriesOnRateLimitThenSucceeds() async throws {
182+ StubURLProtocol.enqueue(
183+ .status(429, Data()),
184+ .status(200, #"{"balance": 3}"#.data(using: .utf8)!)
185+ )
186+
187+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
188+
189+ XCTAssertEqual(balance, 3)
190+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
191+ }
192+
193+ func testDoesNotRetryOnUnauthorized() async {
194+ StubURLProtocol.enqueue(.status(401, Data()))
195+
196+ do {
197+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-bad")
198+ XCTFail("Expected invalidAPIKey error")
199+ } catch CreditsError.invalidAPIKey {
200+ // expected
201+ } catch {
202+ XCTFail("Unexpected error: \(error)")
203+ }
204+
205+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
206+ }
207+
208+ func testDoesNotRetryOnClientError() async {
209+ StubURLProtocol.enqueue(.status(404, Data()))
210+
211+ do {
212+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
213+ XCTFail("Expected requestFailed error")
214+ } catch CreditsError.requestFailed(let message) {
215+ XCTAssertEqual(message, "HTTP 404")
216+ } catch {
217+ XCTFail("Unexpected error: \(error)")
218+ }
219+
220+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
221+ }
222+
223+ func testDoesNotRetryOnDecodingFailure() async {
224+ StubURLProtocol.enqueue(.status(200, #"{"nope": true}"#.data(using: .utf8)!))
225+
226+ do {
227+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
228+ XCTFail("Expected decodingFailed error")
229+ } catch CreditsError.decodingFailed {
230+ // expected
231+ } catch {
232+ XCTFail("Unexpected error: \(error)")
233+ }
234+
235+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
236+ }
237+
238+ func testSucceedsWithoutRetryOnFirstAttempt() async throws {
239+ StubURLProtocol.enqueue(.status(200, #"{"balance": 99}"#.data(using: .utf8)!))
240+
241+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
242+
243+ XCTAssertEqual(balance, 99)
244+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
245+ }
246+
247+ func testBackoffGrowsExponentially() async {
248+ StubURLProtocol.enqueue(.status(500, Data()))
249+
250+ let clock = ContinuousClock()
251+ let start = clock.now
252+ _ = try? await makeStubbedChecker(retryBaseDelay: .milliseconds(20)).fetchBalance(apiKey: "sk-test")
253+ let elapsed = clock.now - start
254+
255+ // Two retries wait 20ms then 40ms. Only a lower bound is asserted: sleeps can
256+ // overshoot under load, but they never return early.
257+ XCTAssertGreaterThanOrEqual(elapsed, .milliseconds(60))
258+ }
259+
260+ // MARK: - CreditsChecker Errors
261+
262+ func testEmptyAPIKeyThrowsNoAPIKey() async {
263+ let checker = CreditsChecker()
264+ do {
265+ _ = try await checker.fetchBalance(apiKey: "")
266+ XCTFail("Expected noAPIKey error")
267+ } catch CreditsError.noAPIKey {
268+ // expected
269+ } catch {
270+ XCTFail("Unexpected error: \(error)")
271+ }
272+ }
273+
274+ func testEmptyAPIKeyDoesNotHitTheNetwork() async {
275+ _ = try? await makeStubbedChecker().fetchBalance(apiKey: "")
276+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
277+ }
278+
279+ // MARK: - CreditsError cases
280+
281+ func testInvalidAPIKeyErrorDescription() {
282+ XCTAssertEqual(
283+ CreditsError.invalidAPIKey.errorDescription,
284+ "Invalid API key. Check your key at hyper.charm.land"
285+ )
286+ }
287+
288+ func testInvalidAPIKeyIsEquatable() {
289+ XCTAssertEqual(CreditsError.invalidAPIKey, .invalidAPIKey)
290+ XCTAssertNotEqual(CreditsError.invalidAPIKey, .noAPIKey)
291+ }
292+
293+ // MARK: - CreditsChecking protocol
294+
295+ func testCreditsCheckerConformsToProtocol() {
296+ let checker: CreditsChecking = CreditsChecker()
297+ // If this compiles, CreditsChecker conforms to CreditsChecking.
298+ XCTAssertNotNil(checker)
299+ }
300+
301+ // MARK: - Mock CreditsChecker for ViewModel tests
302+
303+ func testMockCreditsCheckerReturnsBalance() async throws {
304+ let mock = MockCreditsChecker(balanceToReturn: 42)
305+ let result = try await mock.fetchBalance(apiKey: "sk-test")
306+ XCTAssertEqual(result, 42)
307+ }
308+
309+ func testMockCreditsCheckerThrowsError() async {
310+ let mock = MockCreditsChecker(errorToThrow: CreditsError.invalidAPIKey)
311+ do {
312+ _ = try await mock.fetchBalance(apiKey: "sk-test")
313+ XCTFail("Expected invalidAPIKey error")
314+ } catch CreditsError.invalidAPIKey {
315+ // expected
316+ } catch {
317+ XCTFail("Unexpected error: \(error)")
318+ }
319+ }
320+}
321+
322+// MARK: - KeychainStore
323+
324+/// These tests drive `KeychainStore` with stubbed `SecItem*` calls rather than
325+/// `KeychainHelper`, which would read and write the user's real login keychain entry —
326+/// and `save()` deletes before adding, so a test run would destroy a real API key.
327+final class KeychainStoreTests: XCTestCase {
328+
329+ private func makeStore(backing: FakeKeychain) -> KeychainStore {
330+ KeychainStore(
331+ service: "com.example.tests",
332+ account: "test-account",
333+ addItem: backing.add,
334+ copyMatching: backing.copyMatching,
335+ deleteItem: backing.delete
336+ )
337+ }
338+
339+ func testSaveReturnsTrueOnSuccess() {
340+ let store = makeStore(backing: FakeKeychain())
341+ XCTAssertTrue(store.save("sk-test"))
342+ }
343+
344+ func testSaveReturnsFalseWhenItemCannotBeAdded() {
345+ let backing = FakeKeychain()
346+ backing.addStatus = errSecInteractionNotAllowed // e.g. the keychain is locked
347+ let store = makeStore(backing: backing)
348+
349+ XCTAssertFalse(store.save("sk-test"))
350+ XCTAssertNil(store.load(), "A rejected save must not leave a value behind")
351+ }
352+
353+ func testSaveReturnsFalseWhenKeychainIsUnavailable() {
354+ let backing = FakeKeychain()
355+ backing.addStatus = errSecNotAvailable
356+ XCTAssertFalse(makeStore(backing: backing).save("sk-test"))
357+ }
358+
359+ func testSaveThenLoadRoundTrips() {
360+ let store = makeStore(backing: FakeKeychain())
361+
362+ XCTAssertTrue(store.save("sk-round-trip"))
363+ XCTAssertEqual(store.load(), "sk-round-trip")
364+ }
365+
366+ func testSaveOverwritesExistingKey() {
367+ let backing = FakeKeychain()
368+ let store = makeStore(backing: backing)
369+
370+ XCTAssertTrue(store.save("sk-first"))
371+ XCTAssertTrue(store.save("sk-second"))
372+
373+ XCTAssertEqual(store.load(), "sk-second")
374+ // The first save adds straight into an empty keychain. Only the second hits
375+ // errSecDuplicateItem and has to delete before re-adding.
376+ XCTAssertEqual(backing.deleteCallCount, 1)
377+ }
378+
379+ func testRejectedSaveLeavesTheExistingKeyIntact() {
380+ // Deleting before adding would make a rejected save destroy the working key the
381+ // user already had, leaving them with nothing.
382+ let backing = FakeKeychain()
383+ let store = makeStore(backing: backing)
384+
385+ XCTAssertTrue(store.save("sk-original"))
386+
387+ backing.addStatus = errSecInteractionNotAllowed // e.g. the keychain locked meanwhile
388+ XCTAssertFalse(store.save("sk-replacement"))
389+
390+ XCTAssertEqual(store.load(), "sk-original")
391+ }
392+
393+ func testSaveEmptyStringIsStored() {
394+ // An empty string is still valid UTF-8, so this is a successful save of an empty
395+ // value — not a failure. The failure path is the keychain rejecting the add.
396+ let store = makeStore(backing: FakeKeychain())
397+
398+ XCTAssertTrue(store.save(""))
399+ XCTAssertEqual(store.load(), "")
400+ }
401+
402+ func testLoadReturnsNilWhenNothingStored() {
403+ XCTAssertNil(makeStore(backing: FakeKeychain()).load())
404+ }
405+
406+ func testLoadReturnsNilWhenKeychainErrors() {
407+ let backing = FakeKeychain()
408+ backing.copyStatus = errSecInteractionNotAllowed
409+ XCTAssertNil(makeStore(backing: backing).load())
410+ }
411+
412+ func testDeleteRemovesStoredKey() {
413+ let backing = FakeKeychain()
414+ let store = makeStore(backing: backing)
415+
416+ XCTAssertTrue(store.save("sk-test"))
417+ XCTAssertTrue(store.delete())
418+ XCTAssertNil(store.load())
419+ }
420+
421+ func testDeleteOfMissingItemSucceeds() {
422+ // Deleting something that was never there is not an error.
423+ XCTAssertTrue(makeStore(backing: FakeKeychain()).delete())
424+ }
425+
426+ func testDeleteReturnsFalseOnRealFailure() {
427+ let backing = FakeKeychain()
428+ backing.deleteStatus = errSecInteractionNotAllowed
429+ XCTAssertFalse(makeStore(backing: backing).delete())
430+ }
431+}
432+
433+// MARK: - Fake keychain
434+
435+/// An in-memory stand-in for the `SecItem*` API, with overridable statuses so the failure
436+/// paths can be exercised. Never touches the real keychain.
437+final class FakeKeychain {
438+ /// When set, `add` fails with this status instead of storing.
439+ var addStatus: OSStatus?
440+ /// When set, `copyMatching` fails with this status instead of reading.
441+ var copyStatus: OSStatus?
442+ /// When set, `delete` fails with this status instead of removing.
443+ var deleteStatus: OSStatus?
444+
445+ private(set) var addCallCount = 0
446+ private(set) var deleteCallCount = 0
447+
448+ private var storage: [String: Data] = [:]
449+
450+ func add(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
451+ addCallCount += 1
452+ if let addStatus { return addStatus }
453+
454+ let attributes = Self.attributes(from: query)
455+ guard let account = attributes[kSecAttrAccount as String] as? String,
456+ let data = attributes[kSecValueData as String] as? Data
457+ else { return errSecParam }
458+
459+ guard storage[account] == nil else { return errSecDuplicateItem }
460+ storage[account] = data
461+ return errSecSuccess
462+ }
463+
464+ func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
465+ if let copyStatus { return copyStatus }
466+
467+ let attributes = Self.attributes(from: query)
468+ guard let account = attributes[kSecAttrAccount as String] as? String,
469+ let data = storage[account]
470+ else { return errSecItemNotFound }
471+
472+ result?.pointee = data as CFData
473+ return errSecSuccess
474+ }
475+
476+ func delete(_ query: CFDictionary) -> OSStatus {
477+ deleteCallCount += 1
478+ if let deleteStatus { return deleteStatus }
479+
480+ let attributes = Self.attributes(from: query)
481+ guard let account = attributes[kSecAttrAccount as String] as? String,
482+ storage.removeValue(forKey: account) != nil
483+ else { return errSecItemNotFound }
484+
485+ return errSecSuccess
486+ }
487+
488+ private static func attributes(from query: CFDictionary) -> [String: Any] {
489+ (query as NSDictionary) as? [String: Any] ?? [:]
490+ }
491+}
492+
493+// MARK: - Stub URLProtocol
494+
495+/// Serves canned responses to `URLSession` so the retry logic can be driven without a network.
496+final class StubURLProtocol: URLProtocol {
497+
498+ enum Outcome {
499+ case status(Int, Data)
500+ case failure(Error)
501+ }
502+
503+ private enum StubError: Error {
504+ case noOutcomeConfigured
505+ case missingURL
506+ }
507+
508+ private static let lock = NSLock()
509+ private static var outcomes: [Outcome] = []
510+ private static var lastOutcome: Outcome?
511+ private static var requests: [URLRequest] = []
512+ private static var bodies: [Data?] = []
513+
514+ /// Queues the outcomes for successive requests. Once the queue is drained the final
515+ /// outcome repeats, so a persistent failure needs only one entry.
516+ static func enqueue(_ outcomes: Outcome...) {
517+ lock.lock()
518+ defer { lock.unlock() }
519+ Self.outcomes.append(contentsOf: outcomes)
520+ }
521+
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+
533+ static func reset() {
534+ lock.lock()
535+ defer { lock.unlock() }
536+ outcomes = []
537+ urlMatchedOutcomes = [:]
538+ lastOutcome = nil
539+ requests = []
540+ bodies = []
541+ }
542+
543+ static var recordedRequests: [URLRequest] {
544+ lock.lock()
545+ defer { lock.unlock() }
546+ return requests
547+ }
548+
549+ /// The recorded requests' bodies, in the same order. Captured here rather than read
550+ /// from `URLRequest.httpBody` afterwards, because `URLSession` hands a protocol the
551+ /// body as a stream — one that has to be drained while the request is being loaded.
552+ static var recordedBodies: [Data?] {
553+ lock.lock()
554+ defer { lock.unlock() }
555+ return bodies
556+ }
557+
558+ static var requestCount: Int {
559+ recordedRequests.count
560+ }
561+
562+ /// 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.
565+ private static func nextOutcome(for request: URLRequest) -> Outcome {
566+ lock.lock()
567+ defer { lock.unlock() }
568+ requests.append(request)
569+ bodies.append(drainBody(from: request))
570+
571+ if let path = request.url?.path,
572+ let matched = urlMatchedOutcomes[path] {
573+ return matched
574+ }
575+
576+ if !outcomes.isEmpty {
577+ lastOutcome = outcomes.removeFirst()
578+ }
579+ return lastOutcome ?? .failure(StubError.noOutcomeConfigured)
580+ }
581+
582+ private static func drainBody(from request: URLRequest) -> Data? {
583+ if let body = request.httpBody { return body }
584+ guard let stream = request.httpBodyStream else { return nil }
585+
586+ stream.open()
587+ defer { stream.close() }
588+
589+ var body = Data()
590+ var buffer = [UInt8](repeating: 0, count: 4096)
591+ while stream.hasBytesAvailable {
592+ let read = stream.read(&buffer, maxLength: buffer.count)
593+ guard read > 0 else { break }
594+ body.append(buffer, count: read)
595+ }
596+ return body
597+ }
598+
599+ override class func canInit(with request: URLRequest) -> Bool { true }
600+
601+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
602+
603+ override func startLoading() {
604+ switch Self.nextOutcome(for: request) {
605+ case .failure(let error):
606+ client?.urlProtocol(self, didFailWithError: error)
607+
608+ case .status(let statusCode, let body):
609+ guard let url = request.url else {
610+ client?.urlProtocol(self, didFailWithError: StubError.missingURL)
611+ return
612+ }
613+ let response = HTTPURLResponse(
614+ url: url,
615+ statusCode: statusCode,
616+ httpVersion: "HTTP/1.1",
617+ headerFields: ["Content-Type": "application/json"]
618+ )!
619+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
620+ client?.urlProtocol(self, didLoad: body)
621+ client?.urlProtocolDidFinishLoading(self)
622+ }
623+ }
624+
625+ override func stopLoading() {}
626+}
627+
628+// MARK: - Mock CreditsChecker
629+
630+/// A mock `CreditsChecking` implementation for testing the ViewModel.
631+final class MockCreditsChecker: CreditsChecking {
632+ var balanceToReturn: Int?
633+ var errorToThrow: Error?
634+
635+ init(balanceToReturn: Int? = nil, errorToThrow: Error? = nil) {
636+ self.balanceToReturn = balanceToReturn
637+ self.errorToThrow = errorToThrow
638+ }
639+
640+ func fetchBalance(apiKey: String) async throws -> Int {
641+ if let errorToThrow {
642+ throw errorToThrow
643+ }
644+ return balanceToReturn ?? 0
645+ }
646+}
UsageMenubarTests/StatusBarTextTests.swiftnew file+378
@@ -0,0 +1,378 @@
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+}
HyperCreditsMenubarTests/VersionFormatterTests.swift → UsageMenubarTests/VersionFormatterTests.swiftrenamed+1−1
@@ -1,5 +1,5 @@
11import XCTest
2−@testable import HyperCreditsMenubar
2+@testable import UsageMenubar
33
44final class VersionFormatterTests: XCTestCase {
55
UsageMenubarTests/ZaiUsageClientTests.swiftnew file+237
@@ -0,0 +1,237 @@
1+import XCTest
2+@testable import UsageMenubar
3+
4+@MainActor
5+final class ZaiUsageClientTests: XCTestCase {
6+
7+ override func setUp() {
8+ super.setUp()
9+ StubURLProtocol.reset()
10+ }
11+
12+ override func tearDown() {
13+ StubURLProtocol.reset()
14+ super.tearDown()
15+ }
16+
17+ /// A client whose requests are served by `StubURLProtocol`, with a backoff short
18+ /// enough that the retry tests run in milliseconds rather than seconds.
19+ private func makeStubbedClient(retryBaseDelay: Duration = .milliseconds(1)) -> ZaiUsageClient {
20+ let config = URLSessionConfiguration.ephemeral
21+ config.protocolClasses = [StubURLProtocol.self]
22+ return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
23+ }
24+
25+ /// Enqueues quota and subscription responses matched to their URL paths, so the
26+ /// order of arrival doesn't matter — each endpoint gets its own response.
27+ private func enqueueQuotaAndSubscription(
28+ quotaStatus: Int = 200,
29+ quotaJSON: Data,
30+ subscriptionStatus: Int = 200,
31+ subscriptionJSON: Data
32+ ) {
33+ StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
34+ StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
35+ }
36+
37+ // MARK: - Decoding
38+
39+ func testDecodeQuotaResponse() throws {
40+ let json = """
41+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
42+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
43+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
44+ ],"level":"max"}}
45+ """.data(using: .utf8)!
46+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
47+ XCTAssertTrue(response.success)
48+ XCTAssertEqual(response.code, 200)
49+ XCTAssertEqual(response.data?.level, "max")
50+ XCTAssertEqual(response.data?.limits?.count, 2)
51+ XCTAssertEqual(response.data?.limits?.first?.percentage, 12)
52+ XCTAssertEqual(response.data?.limits?.first?.unit, 3)
53+ }
54+
55+ func testDecodeQuotaResponseWithFractionalPercentage() throws {
56+ let json = """
57+ {"code":200,"success":true,"data":{"limits":[
58+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12.6,"nextResetTime":1784278018952}
59+ ],"level":"pro"}}
60+ """.data(using: .utf8)!
61+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
62+ XCTAssertEqual(response.data?.limits?.first?.percentage, 13)
63+ }
64+
65+ func testDecodeQuotaResponseWithMissingFields() throws {
66+ let json = """
67+ {"code":200,"success":true,"data":{"limits":[],"level":null}}
68+ """.data(using: .utf8)!
69+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
70+ XCTAssertEqual(response.data?.limits?.count, 0)
71+ XCTAssertNil(response.data?.level)
72+ }
73+
74+ func testDecodeSubscriptionResponse() throws {
75+ let json = """
76+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
77+ """.data(using: .utf8)!
78+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
79+ XCTAssertEqual(response.data?.first?.productName, "GLM Coding Max")
80+ XCTAssertEqual(response.data?.first?.status, "VALID")
81+ }
82+
83+ func testDecodeEmptySubscriptionResponse() throws {
84+ let json = """
85+ {"code":200,"data":[],"success":true}
86+ """.data(using: .utf8)!
87+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
88+ XCTAssertEqual(response.data?.count, 0)
89+ }
90+
91+ // MARK: - Fetch
92+
93+ func testFetchUsageSuccess() async throws {
94+ let quotaJSON = """
95+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
96+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
97+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
98+ ],"level":"max"}}
99+ """.data(using: .utf8)!
100+ let subscriptionJSON = """
101+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
102+ """.data(using: .utf8)!
103+
104+ enqueueQuotaAndSubscription(quotaJSON: quotaJSON, subscriptionJSON: subscriptionJSON)
105+
106+ let client = makeStubbedClient()
107+ let report = try await client.fetchUsage(apiKey: "test-key")
108+
109+ XCTAssertEqual(report.fiveHourPercent, 12)
110+ XCTAssertEqual(report.weeklyPercent, 3)
111+ XCTAssertNotNil(report.fiveHourResetsAt)
112+ XCTAssertNotNil(report.weeklyResetsAt)
113+ }
114+
115+ func testFetchUsageNoKey() async {
116+ let client = makeStubbedClient()
117+ do {
118+ _ = try await client.fetchUsage(apiKey: "")
119+ XCTFail("Expected noAPIKey error")
120+ } catch ZaiUsageError.noAPIKey {
121+ // Expected
122+ } catch {
123+ XCTFail("Unexpected error: \(error)")
124+ }
125+
126+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
127+ }
128+
129+ func testFetchUsageInvalidKey() async {
130+ let errorJSON = """
131+ {"code":401,"msg":"token expired or incorrect","success":false}
132+ """.data(using: .utf8)!
133+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
134+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
135+
136+ let client = makeStubbedClient()
137+ do {
138+ _ = try await client.fetchUsage(apiKey: "bad-key")
139+ XCTFail("Expected invalidAPIKey error")
140+ } catch ZaiUsageError.invalidAPIKey {
141+ // Expected
142+ } catch {
143+ XCTFail("Unexpected error: \(error)")
144+ }
145+ }
146+
147+ // MARK: - Retry
148+
149+ func testRetriesOnServerErrorThenSucceeds() async throws {
150+ let quotaJSON = """
151+ {"code":200,"success":true,"data":{"limits":[
152+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
153+ ],"level":"max"}}
154+ """.data(using: .utf8)!
155+ let subscriptionJSON = """
156+ {"code":200,"data":[],"success":true}
157+ """.data(using: .utf8)!
158+
159+ // Subscription succeeds on first try. Quota fails with 500, then succeeds.
160+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
161+ StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
162+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
163+
164+ let client = makeStubbedClient()
165+ let report = try await client.fetchUsage(apiKey: "test-key")
166+ XCTAssertEqual(report.fiveHourPercent, 12)
167+ }
168+
169+ func testRetriesOnRateLimitThenSucceeds() async throws {
170+ let quotaJSON = """
171+ {"code":200,"success":true,"data":{"limits":[
172+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
173+ ],"level":"max"}}
174+ """.data(using: .utf8)!
175+ let subscriptionJSON = """
176+ {"code":200,"data":[],"success":true}
177+ """.data(using: .utf8)!
178+
179+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
180+ StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
181+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
182+
183+ let client = makeStubbedClient()
184+ let report = try await client.fetchUsage(apiKey: "test-key")
185+ XCTAssertEqual(report.fiveHourPercent, 12)
186+ }
187+
188+ func testDoesNotRetryOn401() async {
189+ let errorJSON = """
190+ {"code":401,"msg":"token expired or incorrect","success":false}
191+ """.data(using: .utf8)!
192+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
193+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
194+
195+ let client = makeStubbedClient()
196+ do {
197+ _ = try await client.fetchUsage(apiKey: "bad-key")
198+ XCTFail("Expected invalidAPIKey error")
199+ } catch ZaiUsageError.invalidAPIKey {
200+ // Expected — should not retry
201+ } catch {
202+ XCTFail("Unexpected error: \(error)")
203+ }
204+ }
205+
206+ // MARK: - ZaiUsageReport
207+
208+ func testEmptyReportIsEmpty() {
209+ let report = ZaiUsageReport(
210+ fiveHourPercent: nil,
211+ weeklyPercent: nil,
212+ fiveHourResetsAt: nil,
213+ weeklyResetsAt: nil,
214+ planLevel: nil
215+ )
216+ XCTAssertTrue(report.isEmpty)
217+ }
218+
219+ func testNonEmptyReportIsNotEmpty() {
220+ let report = ZaiUsageReport(
221+ fiveHourPercent: 12,
222+ weeklyPercent: nil,
223+ fiveHourResetsAt: nil,
224+ weeklyResetsAt: nil,
225+ planLevel: nil
226+ )
227+ XCTAssertFalse(report.isEmpty)
228+ }
229+
230+ // MARK: - Protocol conformance
231+
232+ func testZaiUsageClientConformsToProtocol() {
233+ let checker: ZaiUsageChecking = ZaiUsageClient()
234+ // If this compiles, ZaiUsageClient conforms to ZaiUsageChecking.
235+ XCTAssertNotNil(checker)
236+ }
237+}
project.yml+49−24
@@ -1,6 +1,14 @@
1−name: HyperCreditsMenubar
1+# xcodegen project definition — run `xcodegen generate` to (re)create
2+# UsageMenubar.xcodeproj. The .xcodeproj is not committed; this file is
3+# the source of truth for project structure and build settings.
4+name: UsageMenubar
5+
26options:
3− bundleIdPrefix: com.ilyakooo0
7+ # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
8+ # The app target pins its own id explicitly below, so this is just a default.
9+ bundleIdPrefix: soy.iko
10+ # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
11+ # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
412 deploymentTarget:
513 macOS: "14.0"
614 createIntermediateGroups: true
@@ -9,73 +17,90 @@ options:
917settings:
1018 base:
1119 SWIFT_VERSION: "5.9"
20+ # Local/CI builds are unsigned at compile time. Release builds get an
21+ # ad-hoc signature applied after archiving (see .github/workflows/release.yml).
1222 DEVELOPMENT_TEAM: ""
1323 CODE_SIGN_IDENTITY: ""
1424 CODE_SIGNING_REQUIRED: "NO"
1525 CODE_SIGNING_ALLOWED: "NO"
26+ # Version defaults for local builds only. The release workflow overwrites
27+ # these (and the CFBundle* keys below) with a version derived from the
28+ # commit timestamp, formatted YYYY.MM.DD.HHHH — so editing them by hand
29+ # has no effect on what ships.
1630 MARKETING_VERSION: "1.0.0"
1731 CURRENT_PROJECT_VERSION: "1"
1832
1933targets:
20− HyperCreditsMenubar:
34+ # ── The menu bar app ───────────────────────────────────────────────────
35+ UsageMenubar:
2136 type: application
2237 platform: macOS
2338 sources:
24− - path: HyperCreditsMenubar
39+ - path: UsageMenubar
2540 excludes:
41+ # Referenced explicitly via INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS,
42+ # so they must not also be compiled in as resources.
2643 - "Info.plist"
2744 - "*.entitlements"
28− resources:
29− - HyperCreditsMenubar/Assets.xcassets
3045 info:
31− path: HyperCreditsMenubar/Info.plist
46+ path: UsageMenubar/Info.plist
3247 properties:
33− CFBundleName: HyperCreditsMenubar
34− CFBundleDisplayName: Hyper Credits
35− CFBundleIdentifier: com.ilyakooo0.hyper-credits-menubar
48+ CFBundleName: UsageMenubar
49+ CFBundleDisplayName: Usage Menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
51+ # Overwritten by CI — see the note on MARKETING_VERSION above.
3652 CFBundleVersion: "1"
3753 CFBundleShortVersionString: "1.0.0"
54+ # Agent app: no dock icon and no main window; it lives in the menu bar.
3855 LSUIElement: true
3956 LSApplicationCategoryType: public.app-category.utilities
4057 NSHighResolutionCapable: true
4158 settings:
4259 base:
43− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar
44− PRODUCT_NAME: HyperCreditsMenubar
45− INFOPLIST_FILE: HyperCreditsMenubar/Info.plist
46− CODE_SIGN_ENTITLEMENTS: HyperCreditsMenubar/HyperCreditsMenubar.entitlements
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
61+ PRODUCT_NAME: UsageMenubar
62+ INFOPLIST_FILE: UsageMenubar/Info.plist
63+ # Grants the outgoing-network-connection entitlement needed to reach
64+ # the Hyper and Claude APIs, and deliberately does not sandbox the app —
65+ # see the comment in the entitlements file. Stripped in CI's archive step
66+ # (unsigned builds can't carry entitlements).
67+ CODE_SIGN_ENTITLEMENTS: UsageMenubar/UsageMenubar.entitlements
4768 ENABLE_HARDENED_RUNTIME: YES
69+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
4870 MACOSX_DEPLOYMENT_TARGET: "14.0"
4971 LD_RUNPATH_SEARCH_PATHS:
5072 - "$(inherited)"
5173 - "@executable_path/../Frameworks"
5274
53− HyperCreditsMenubarTests:
75+ # ── Unit tests ─────────────────────────────────────────────────────────
76+ # Hosted by the app target (BUNDLE_LOADER/TEST_HOST) so tests can reach the
77+ # app's internal types without re-declaring them.
78+ UsageMenubarTests:
5479 type: bundle.unit-test
5580 platform: macOS
5681 sources:
57− - path: HyperCreditsMenubarTests
82+ - path: UsageMenubarTests
5883 dependencies:
59− - target: HyperCreditsMenubar
84+ - target: UsageMenubar
6085 settings:
6186 base:
62− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar.tests
63− PRODUCT_NAME: HyperCreditsMenubarTests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
88+ PRODUCT_NAME: UsageMenubarTests
6489 BUNDLE_LOADER: "$(TEST_HOST)"
65− TEST_HOST: "$(BUILT_PRODUCTS_DIR)/HyperCreditsMenubar.app/Contents/MacOS/HyperCreditsMenubar"
90+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"
6691 MACOSX_DEPLOYMENT_TARGET: "14.0"
6792
6893schemes:
69− HyperCreditsMenubar:
94+ UsageMenubar:
7095 build:
7196 targets:
72− HyperCreditsMenubar: all
73− HyperCreditsMenubarTests: [test]
97+ UsageMenubar: all
98+ UsageMenubarTests: [test]
7499 run:
75100 config: Debug
76101 test:
77102 config: Debug
78103 targets:
79− - HyperCreditsMenubarTests
104+ - UsageMenubarTests
80105 archive:
81106 config: Release