on 61daa05 help sign in
36 files changed+578−5466
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+24−192 CLAUDE.md−88 UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.json HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json+20 UsageMenubar/Assets.xcassets/Contents.json → HyperCreditsMenubar/Assets.xcassets/Contents.json HyperCreditsMenubar/CreditsChecker.swift+71 HyperCreditsMenubar/HyperCreditsApp.swift+91 HyperCreditsMenubar/HyperCreditsMenubar.entitlements+10 UsageMenubar/Info.plist → HyperCreditsMenubar/Info.plist+3−3 HyperCreditsMenubar/KeychainHelper.swift+47 HyperCreditsMenubar/MenuView.swift+188 UsageMenubar/VersionFormatter.swift → HyperCreditsMenubar/VersionFormatter.swift HyperCreditsMenubarTests/CreditsCheckerTests.swift+51 UsageMenubarTests/VersionFormatterTests.swift → HyperCreditsMenubarTests/VersionFormatterTests.swift+1−1 README.md+48−117 UsageMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json−68 UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-128.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-16.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-256.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-32.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-512.png UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-64.png UsageMenubar/ClaudeUsageClient.swift−476 UsageMenubar/CreditsChecker.swift−197 UsageMenubar/KeychainHelper.swift−136 UsageMenubar/MenuView.swift−741 UsageMenubar/UsageMenubar.entitlements−16 UsageMenubar/UsageMenubarApp.swift−277 UsageMenubar/ViewModel.swift−899 UsageMenubar/ZaiUsageClient.swift−388 UsageMenubarTests/ClaudeUsageClientTests.swift−557 UsageMenubarTests/CreditsCheckerTests.swift−646 UsageMenubarTests/StatusBarTextTests.swift−378 UsageMenubarTests/ZaiUsageClientTests.swift−237 project.yml+24−49
.github/workflows/release.yml+24−192
@@ -10,7 +10,7 @@ permissions:
1010
1111jobs:
1212 build-and-release:
13− runs-on: macos-15
13+ runs-on: macos-14
1414 outputs:
1515 VERSION: ${{ env.VERSION }}
1616 steps:
@@ -34,226 +34,60 @@ 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−
11137 # ── Generate Xcode project ────────────────────────────────────────
11238 - name: Generate Xcode project
11339 run: xcodegen generate
11440
11541 # ── Run tests ─────────────────────────────────────────────────────
11642 - name: Run tests
43+ continue-on-error: true
11744 run: |
11845 xcodebuild test \
119− -scheme UsageMenubar \
46+ -scheme HyperCreditsMenubar \
12047 -configuration Debug \
12148 -destination "platform=macOS" \
12249 CODE_SIGNING_ALLOWED=NO \
12350 CODE_SIGNING_REQUIRED=NO \
12451 CODE_SIGN_IDENTITY="" \
125− DEVELOPMENT_TEAM=""
52+ DEVELOPMENT_TEAM="" \
53+ || true
12654
12755 # ── Archive ───────────────────────────────────────────────────────
12856 - name: Archive
12957 run: |
13058 xcodebuild archive \
131− -scheme UsageMenubar \
59+ -scheme HyperCreditsMenubar \
13260 -configuration Release \
133− -archivePath build/UsageMenubar.xcarchive \
61+ -archivePath build/HyperCreditsMenubar.xcarchive \
13462 -destination "generic/platform=macOS" \
13563 CODE_SIGNING_ALLOWED=NO \
13664 CODE_SIGNING_REQUIRED=NO \
13765 CODE_SIGN_IDENTITY="" \
13866 DEVELOPMENT_TEAM="" \
139− CODE_SIGN_ENTITLEMENTS=""
67+ CODE_SIGN_ENTITLEMENTS="" \
68+ ENABLE_HARDENED_RUNTIME=NO
14069
14170 # ── Export .app from archive ──────────────────────────────────────
14271 - name: Export app from archive
14372 run: |
14473 mkdir -p build/export
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
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"
16477
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.
78+ # ── Zip the app ───────────────────────────────────────────────────
16979 - name: Zip the app
17080 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
24381 cd build/export
244− rm -f "UsageMenubar-${VERSION}.zip"
245− ditto -c -k --keepParent UsageMenubar.app "UsageMenubar-${VERSION}.zip"
246− # Also create a stable-named zip for the Homebrew Cask
247− cp "UsageMenubar-${VERSION}.zip" "UsageMenubar-latest.zip"
248− cp "UsageMenubar-${VERSION}.zip" ../../
249− cp "UsageMenubar-latest.zip" ../../
82+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${{ env.VERSION }}.zip"
83+ cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" ../../
25084
25185 # ── Upload build artifact ─────────────────────────────────────────
25286 - name: Upload build artifact
25387 uses: actions/upload-artifact@v4
25488 with:
255− name: UsageMenubar-${{ env.VERSION }}
256− path: UsageMenubar-${{ env.VERSION }}.zip
89+ name: HyperCreditsMenubar-${{ env.VERSION }}
90+ path: HyperCreditsMenubar-${{ env.VERSION }}.zip
25791 retention-days: 30
25892
25993 # ── Create GitHub Release ─────────────────────────────────────────
@@ -263,24 +97,22 @@ jobs:
26397 tag_name: ${{ env.VERSION }}
26498 name: ${{ env.VERSION }}
26599 body: |
266− ## UsageMenubar ${{ env.VERSION }}
100+ ## HyperCreditsMenubar ${{ env.VERSION }}
267101
268− **Download:** `UsageMenubar-${{ env.VERSION }}.zip`
102+ **Download:** `HyperCreditsMenubar-${{ env.VERSION }}.zip`
269103
270104 ### Installation
271− 1. Download and unzip `UsageMenubar-${{ env.VERSION }}.zip`
272− 2. Move `UsageMenubar.app` to your `/Applications` folder
105+ 1. Download and unzip `HyperCreditsMenubar-${{ env.VERSION }}.zip`
106+ 2. Move `HyperCreditsMenubar.app` to your `/Applications` folder
273107 3. Launch the app — it will appear in your menu bar as ⚡
274108 4. Click the ⚡ icon, enter your Hyper API key, and click Save
275109
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**.
110+ > **Note:** This build is unsigned. On first launch you may need to
111+ > right-click → Open, or allow it in System Settings → Privacy & Security.
279112
280113 ### Changes
281114 Built from commit ${{ github.sha }}
282115 files: |
283− UsageMenubar-${{ env.VERSION }}.zip
284− UsageMenubar-latest.zip
116+ HyperCreditsMenubar-${{ env.VERSION }}.zip
285117 draft: false
286118 prerelease: false
CLAUDE.mddeleted−88
@@ -1,88 +0,0 @@
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
UsageMenubar/Assets.xcassets/AccentColor.colorset/Contents.json → HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.jsonrenamed

The content is the same.

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

The content is the same.

HyperCreditsMenubar/CreditsChecker.swiftnew file+71
@@ -0,0 +1,71 @@
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.swiftnew file+91
@@ -0,0 +1,91 @@
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.entitlementsnew file+10
@@ -0,0 +1,10 @@
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>
UsageMenubar/Info.plist → HyperCreditsMenubar/Info.plistrenamed+3−3
@@ -3,11 +3,11 @@
33<plist version="1.0">
44<dict>
55 <key>CFBundleName</key>
6− <string>UsageMenubar</string>
6+ <string>HyperCreditsMenubar</string>
77 <key>CFBundleDisplayName</key>
8− <string>Usage Menubar</string>
8+ <string>Hyper Credits</string>
99 <key>CFBundleIdentifier</key>
10− <string>soy.iko.usage-menubar</string>
10+ <string>com.ilyakooo0.hyper-credits-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
HyperCreditsMenubar/KeychainHelper.swiftnew file+47
@@ -0,0 +1,47 @@
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.swiftnew file+188
@@ -0,0 +1,188 @@
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 == .registered
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+}
UsageMenubar/VersionFormatter.swift → HyperCreditsMenubar/VersionFormatter.swiftrenamed

The content is the same.

HyperCreditsMenubarTests/CreditsCheckerTests.swiftnew file+51
@@ -0,0 +1,51 @@
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+}
UsageMenubarTests/VersionFormatterTests.swift → HyperCreditsMenubarTests/VersionFormatterTests.swiftrenamed+1−1
@@ -1,5 +1,5 @@
11import XCTest
2−@testable import UsageMenubar
2+@testable import HyperCreditsMenubar
33
44final class VersionFormatterTests: XCTestCase {
55
README.md+48−117
@@ -1,103 +1,39 @@
1−<p align="center">
2− <img src="UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.png" width="128" height="128" alt="Usage Menubar app icon">
3−</p>
1+# HyperCreditsMenubar
42
5−# UsageMenubar
3+A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance.
64
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.
5+![Screenshot placeholder](docs/screenshot-placeholder.png)
86
97## Features
108
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
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
1714- 🚀 **Launch at login** — optional, using `SMAppService`
15+- 📋 **Quick link** — opens [hyper.charm.land](https://hyper.charm.land) from the menu
1816- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
1917
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−
3418## Installation
3519
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−
4920### Download the pre-built release
5021
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
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 ⚡
5926
60−Click the menu bar icon to open the popover. Each service is configured independently:
27+> **Note:** Releases are currently unsigned. On first launch, right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**.
6128
62−### Hyper credits
29+### Set your API key
6330
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
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
6735
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.
36+You can get your API key from [hyper.charm.land](https://hyper.charm.land).
10137
10238## Build from source
10339
@@ -111,20 +47,20 @@ The app also refreshes automatically when your Mac wakes from sleep.
11147
11248```bash
11349# Clone the repository
114−git clone https://github.com/ilyakooo0/usage-menubar.git
115−cd usage-menubar
50+git clone https://github.com/ilyakooo0/hyper-credits-menubar.git
51+cd hyper-credits-menubar
11652
11753# Generate the Xcode project
11854xcodegen generate
11955
12056# Open in Xcode
121−open UsageMenubar.xcodeproj
57+open HyperCreditsMenubar.xcodeproj
12258
12359# Or build from the command line
12460xcodebuild \
125− -scheme UsageMenubar \
61+ -scheme HyperCreditsMenubar \
12662 -configuration Release \
127− -archivePath build/UsageMenubar.xcarchive \
63+ -archivePath build/HyperCreditsMenubar.xcarchive \
12864 archive \
12965 CODE_SIGNING_ALLOWED=NO
13066```
@@ -133,7 +69,7 @@ xcodebuild \
13369
13470```bash
13571xcodebuild test \
136− -scheme UsageMenubar \
72+ -scheme HyperCreditsMenubar \
13773 -configuration Debug \
13874 -destination "platform=macOS"
13975```
@@ -153,25 +89,19 @@ The version is derived automatically from the commit timestamp in the GitHub Act
15389## Project structure
15490
15591```
156−usage-menubar/
92+hyper-credits-menubar/
15793├── project.yml # xcodegen project definition
158−├── UsageMenubar/
159−│ ├── UsageMenubarApp.swift # @main App, NSStatusBar, timer, sleep/wake
160−│ ├── ViewModel.swift # Observable state: balance, Claude usage, z.ai usage, refresh
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)
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
16699│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
167100│ ├── Info.plist # LSUIElement=true (agent app)
168−│ ├── UsageMenubar.entitlements # Network client; deliberately not sandboxed
101+│ ├── HyperCreditsMenubar.entitlements # Network client entitlements
169102│ └── Assets.xcassets/ # App icon, accent color
170−├── UsageMenubarTests/
171−│ ├── CreditsCheckerTests.swift # Hyper API, retry, keychain, StubURLProtocol
172−│ ├── ClaudeUsageClientTests.swift # Usage decoding, read-only credential store, retry
173−│ ├── ZaiUsageClientTests.swift # z.ai API, retry, decoding
174−│ ├── StatusBarTextTests.swift # Menu bar title formatting
103+├── HyperCreditsMenubarTests/
104+│ ├── CreditsCheckerTests.swift # JSON decoding tests
175105│ └── VersionFormatterTests.swift # Version formatting tests
176106├── .github/workflows/
177107│ └── release.yml # Build + release on push to master
@@ -182,18 +112,19 @@ usage-menubar/
182112
183113## How it works
184114
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.
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+
196127
197128## License
198129
199−MIT — see [LICENSE](LICENSE).
130+MIT — see [LICENSE](LICENSE).
UsageMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsondeleted−68
@@ -1,68 +0,0 @@
1−{
2− "images": [
3− {
4− "filename": "icon-16.png",
5− "idiom": "mac",
6− "scale": "1x",
7− "size": "16x16"
8− },
9− {
10− "filename": "icon-32.png",
11− "idiom": "mac",
12− "scale": "2x",
13− "size": "16x16"
14− },
15− {
16− "filename": "icon-32.png",
17− "idiom": "mac",
18− "scale": "1x",
19− "size": "32x32"
20− },
21− {
22− "filename": "icon-64.png",
23− "idiom": "mac",
24− "scale": "2x",
25− "size": "32x32"
26− },
27− {
28− "filename": "icon-128.png",
29− "idiom": "mac",
30− "scale": "1x",
31− "size": "128x128"
32− },
33− {
34− "filename": "icon-256.png",
35− "idiom": "mac",
36− "scale": "2x",
37− "size": "128x128"
38− },
39− {
40− "filename": "icon-256.png",
41− "idiom": "mac",
42− "scale": "1x",
43− "size": "256x256"
44− },
45− {
46− "filename": "icon-512.png",
47− "idiom": "mac",
48− "scale": "2x",
49− "size": "256x256"
50− },
51− {
52− "filename": "icon-512.png",
53− "idiom": "mac",
54− "scale": "1x",
55− "size": "512x512"
56− },
57− {
58− "filename": "icon-1024.png",
59− "idiom": "mac",
60− "scale": "2x",
61− "size": "512x512"
62− }
63− ],
64− "info": {
65− "author": "xcode",
66− "version": 1
67− }
68−}
UsageMenubar/Assets.xcassets/AppIcon.appiconset/icon-1024.pngdeleted

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

UsageMenubar/ClaudeUsageClient.swiftdeleted−476
@@ -1,476 +0,0 @@
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.swiftdeleted−197
@@ -1,197 +0,0 @@
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−}
UsageMenubar/KeychainHelper.swiftdeleted−136
@@ -1,136 +0,0 @@
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.swiftdeleted−741
@@ -1,741 +0,0 @@
1−import SwiftUI
2−
3−/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
4−struct MenuView: View {
5− @ObservedObject var viewModel: ViewModel
6−
7− private static let websiteURL = URL(string: "https://hyper.charm.land")!
8− private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
9−
10− // MARK: - Type Scale
11−
12− /// One rounded family, sized so that every provider's headline number reads at the
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15− /// numbers from jittering as their digits change.
16− private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
17− .monospacedDigit()
18− private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
19− private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
20− private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
21− private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
22−
23− var body: some View {
24− VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
27− }
28− if showsClaude {
29− serviceBreak
30− claude
31− }
32− if showsZai {
33− serviceBreak
34− zai
35− }
36− hairline
37− settings
38− hairline
39− footer
40− }
41− .padding(.horizontal, 18)
42− .padding(.top, 18)
43− .padding(.bottom, 14)
44− .frame(width: 280)
45− // Set as the environment font so the controls inherit the rounded design too.
46− .font(Self.controlFont)
47− .background(Color(nsColor: .windowBackgroundColor))
48− .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
49− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
52− .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53− .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54− .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55− .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56− .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57− .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58− .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59− .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60− .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
61− .animation(.easeInOut(duration: 0.2), value: viewModel.zaiError)
62− }
63−
64− /// A section rule light enough to read as a pause rather than a border.
65− private var hairline: some View {
66− Divider().opacity(0.3)
67− }
68−
69− /// The rule between services. Wider than the others on purpose: the providers are
70− /// unrelated accounts, and the gap should say so before the headers do.
71− private var serviceBreak: some View {
72− hairline.padding(.vertical, 5)
73− }
74−
75− /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
77− private func sectionHeader(_ title: String, systemImage: String) -> some View {
78− HStack(spacing: 5) {
79− Image(systemName: systemImage)
80− .font(.system(size: 11, weight: .semibold))
81−
82− Text(title)
83− .font(Self.sectionFont)
84− }
85− }
86−
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88− /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89− /// copy-confirmation slot so the layout doesn't shift.
90− private func headlineNumber(
91− text: String,
92− color: Color,
93− trend: MetricTrend,
94− copiedConfirmation: Bool,
95− captionText: String,
96− copyAction: @escaping () -> Void
97− ) -> some View {
98− VStack(spacing: 6) {
99− Button(action: copyAction) {
100− HStack(alignment: .firstTextBaseline, spacing: 6) {
101− Text(text)
102− .font(Self.subheroFont)
103− .foregroundColor(color)
104− .contentTransition(.opacity)
105−
106− if let symbol = trend.symbolName {
107− Image(systemName: symbol)
108− .font(.system(size: 12, weight: .semibold))
109− .foregroundColor(.secondary)
110− .transition(.opacity)
111− }
112− }
113− .contentShape(Rectangle())
114− }
115− .buttonStyle(.plain)
116− .help("Click to copy")
117−
118− // Caption doubles as copy confirmation so the layout doesn't shift.
119− Group {
120− if copiedConfirmation {
121− Text("✓ Copied")
122− .foregroundColor(.green)
123− } else {
124− Text(captionText)
125− .foregroundColor(.secondary)
126− }
127− }
128− .font(Self.captionFont)
129− }
130− }
131−
132− /// A sparkline shown when there are at least 2 history points.
133− @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
135− if values.count >= 2 {
136− Sparkline(values: values, color: color)
137− .frame(height: 26)
138− .padding(.top, 4)
139− .transition(.opacity)
140− }
141− }
142−
143− /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144− /// message, and a tinted rounded background with no border.
145− private func errorRow(_ message: String) -> some View {
146− HStack(alignment: .top, spacing: 6) {
147− Image(systemName: "exclamationmark.triangle.fill")
148− .font(.system(size: 10, weight: .semibold))
149−
150− Text(message)
151− .font(Self.captionFont)
152− // Long messages wrap instead of being truncated to one line.
153− .fixedSize(horizontal: false, vertical: true)
154−
155− Spacer(minLength: 0)
156− }
157− .foregroundColor(.red)
158− .padding(.horizontal, 10)
159− .padding(.vertical, 7)
160− .background(
161− RoundedRectangle(cornerRadius: 8, style: .continuous)
162− .fill(Color.red.opacity(0.1))
163− )
164− .padding(.top, 2)
165− }
166−
167− // MARK: - Hyper
168−
169− /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170− /// configured — the section doesn't appear, same as Claude and z.ai.
171− private var showsHyper: Bool {
172− viewModel.hyperConfigured
173− }
174−
175− /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176− private var hyper: some View {
177− VStack(alignment: .leading, spacing: 12) {
178− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
179−
180− if viewModel.isLoading && viewModel.balance == nil {
181− Text("…")
182− .font(Self.subheroFont)
183− .foregroundColor(.secondary)
184− } else if let balance = viewModel.balance {
185− VStack(spacing: 8) {
186− headlineNumber(
187− text: "⚡ \(viewModel.formattedBalance)",
188− color: viewModel.balanceColor,
189− trend: viewModel.balanceTrend,
190− copiedConfirmation: viewModel.copiedConfirmation,
191− captionText: "credits",
192− copyAction: { viewModel.copyBalanceToClipboard() }
193− )
194−
195− sparkline(
196− values: viewModel.balanceSparklineValues,
197− color: viewModel.balanceColor
198− )
199− }
200− .transition(.opacity)
201− }
202−
203− if let lastUpdatedText = viewModel.lastUpdatedText {
204− Text(lastUpdatedText)
205− .font(Self.footnoteFont)
206− .foregroundColor(.secondary)
207− .transition(.opacity)
208− }
209−
210− if let error = viewModel.errorMessage {
211− errorRow(error)
212− .transition(.opacity)
213− }
214− }
215− }
216−
217− // MARK: - Claude
218−
219− /// Whether there is anything to say about Claude. Nothing at all when Claude Code
220− /// isn't signed in on this machine — the section doesn't appear, and no error is
221− /// shown for a service the user may simply not use.
222− private var showsClaude: Bool {
223− if viewModel.claudeError != nil { return true }
224− if let usage = viewModel.claudeUsage { return !usage.isEmpty }
225− return false
226− }
227−
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229− /// headline number, bars under every window, a sparkline, and error row.
230− private var claude: some View {
231− VStack(alignment: .leading, spacing: 12) {
232− HStack(spacing: 8) {
233− sectionHeader("Claude Code", systemImage: "sparkles")
234−
235− Spacer(minLength: 0)
236−
237− if let plan = viewModel.claudePlanLabel {
238− Text(plan)
239− .font(Self.captionFont)
240− .foregroundColor(.secondary)
241− }
242− }
243−
244− if let usage = viewModel.claudeUsage, !usage.isEmpty {
245− usageDetail(usage)
246− }
247−
248− if let error = viewModel.claudeError {
249− errorRow(error)
250− }
251− }
252− }
253−
254− private func usageDetail(_ usage: ClaudeUsage) -> some View {
255− VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
257− if let fiveHour = usage.fiveHour {
258− let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
261− headlineNumber(
262− text: "⁕ \\(percent)%",
263− color: Self.usageColor(percent),
264− trend: viewModel.claudeTrend,
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
268− )
269−
270− Spacer(minLength: 0)
271−
272− if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
274− .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
276− }
277− }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281− }
282− }
283−
284− VStack(alignment: .leading, spacing: 9) {
285− if let sevenDay = usage.sevenDay {
286− window("7-day", sevenDay)
287− }
288− // Opus has its own weekly cap on Max plans, but it is only worth a row
289− // once it has actually been used.
290− if let opus = usage.sevenDayOpus, opus.utilization > 0 {
291− window("7-day Opus", opus)
292− }
293− }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300− }
301− }
302−
303− /// One window: its name, its share of the allowance, and a hairline bar. The bar is
304− /// the only chrome here, and it carries the same color as the number beside it.
305− private func window(_ label: String, _ window: UsageWindow) -> some View {
306− let percent = Int(window.utilization.rounded())
307−
308− return VStack(alignment: .leading, spacing: 5) {
309− HStack(spacing: 6) {
310− Text(label)
311− .font(Self.captionFont)
312− .foregroundColor(.secondary)
313−
314− Spacer(minLength: 0)
315−
316− Text("\(percent)%")
317− .font(Self.captionFont.monospacedDigit())
318− .foregroundColor(Self.usageColor(percent))
319− }
320−
321− UsageBar(percent: percent, color: Self.usageColor(percent))
322− }
323− }
324−
325− /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326− private var claudeFiveHourPercent: Int? {
327− viewModel.claudeFiveHourPercent
328− }
329−
330− /// The balance colors read the other way round here, because for a limit more is
331− /// worse: green with room to spare, yellow closing in, red nearly spent.
332− private static func usageColor(_ percent: Int) -> Color {
333− if percent >= 90 { return .red }
334− if percent >= 70 { return .yellow }
335− return .green
336− }
337−
338− // MARK: - z.ai
339−
340− /// Whether there is anything to say about z.ai. Nothing at all when no API key has
341− /// been entered — the section doesn't appear, and no error is shown for a service
342− /// the user may simply not use.
343− private var showsZai: Bool {
344− if viewModel.zaiError != nil { return true }
345− if let usage = viewModel.zaiUsage { return !usage.isEmpty }
346− return false
347− }
348−
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
351− private var zai: some View {
352− VStack(alignment: .leading, spacing: 12) {
353− HStack(spacing: 8) {
354− sectionHeader("z.ai Coding", systemImage: "bolt.horizontal")
355−
356− Spacer(minLength: 0)
357−
358− if let plan = viewModel.zaiPlanLabel {
359− Text(plan)
360− .font(Self.captionFont)
361− .foregroundColor(.secondary)
362− }
363− }
364−
365− if let usage = viewModel.zaiUsage, !usage.isEmpty {
366− zaiUsageDetail(usage)
367− }
368−
369− if let error = viewModel.zaiError {
370− errorRow(error)
371− }
372− }
373− }
374−
375− private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376− VStack(alignment: .leading, spacing: 12) {
377− // The 5-hour window leads as the headline number.
378− if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
381− headlineNumber(
382− text: "Z \\(fiveHour)%",
383− color: Self.usageColor(fiveHour),
384− trend: viewModel.zaiTrend,
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
388− )
389−
390− Spacer(minLength: 0)
391−
392− if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
396− }
397− }
398−
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401− }
402− }
403−
404− VStack(alignment: .leading, spacing: 9) {
405− if let weekly = usage.weeklyPercent {
406− zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407− }
408− }
409−
410− // Peak hours notice.
411− zaiPeakHours
412−
413− // Sparkline for the z.ai 5-hour % history, with an orange tint
414− // when currently in peak hours.
415− if viewModel.zaiSparklineValues.count >= 2 {
416− Sparkline(
417− values: viewModel.zaiSparklineValues,
418− color: Self.usageColor(zaiFiveHourPercent ?? 0)
419− )
420− .frame(height: 26)
421− .padding(.top, 4)
422− .transition(.opacity)
423− .background(
424− ViewModel.zaiInPeakHours
425− ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426− .fill(Color.orange.opacity(0.08))
427− : nil
428− )
429− }
430− }
431− }
432−
433− /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434− private var zaiFiveHourPercent: Int? {
435− viewModel.zaiFiveHourPercent
436− }
437−
438− /// One window row for z.ai: label, percentage, and a hairline bar.
439− private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440− VStack(alignment: .leading, spacing: 5) {
441− HStack(spacing: 6) {
442− Text(label)
443− .font(Self.captionFont)
444− .foregroundColor(.secondary)
445−
446− Spacer(minLength: 0)
447−
448− Text("\(percent)%")
449− .font(Self.captionFont.monospacedDigit())
450− .foregroundColor(Self.usageColor(percent))
451− }
452−
453− UsageBar(percent: percent, color: Self.usageColor(percent))
454− }
455− }
456−
457− /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458− /// Limits are most likely to bind during this window. Displayed in the
459− /// user's local timezone so they know when to expect slowdowns.
460− private var zaiPeakHours: some View {
461− let inPeak = ViewModel.zaiInPeakHours
462−
463− return HStack(spacing: 6) {
464− Circle()
465− .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466− .frame(width: 6, height: 6)
467−
468− Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469− .font(Self.footnoteFont)
470− .foregroundColor(inPeak ? .orange : .secondary)
471− }
472− }
473−
474− /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475− private static var zaiPeakHoursLocal: String {
476− let formatter = DateFormatter()
477− formatter.timeZone = TimeZone.current
478− formatter.dateFormat = "HH:mm"
479−
480− var utc = DateComponents()
481− utc.timeZone = TimeZone(identifier: "UTC")
482− utc.hour = 6
483− var calendar = Calendar(identifier: .gregorian)
484− calendar.timeZone = TimeZone(identifier: "UTC")!
485− let start = calendar.date(from: utc)!
486−
487− utc.hour = 10
488− let end = calendar.date(from: utc)!
489−
490− return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491− }
492−
493− /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494− private static func zaiResetsIn(from date: Date) -> String {
495− let interval = date.timeIntervalSince(Date())
496− guard interval > 0 else { return "soon" }
497−
498− let seconds = Int(interval.rounded())
499− let days = seconds / 86_400
500− let hours = (seconds % 86_400) / 3_600
501− let minutes = (seconds % 3_600) / 60
502−
503− if days > 0 { return "\(days)d \(hours)h" }
504− if hours > 0 { return "\(hours)h \(minutes)m" }
505− return "\(minutes)m"
506− }
507−
508− // MARK: - Settings
509−
510− private var settings: some View {
511− VStack(alignment: .leading, spacing: 14) {
512− VStack(alignment: .leading, spacing: 8) {
513− HStack(spacing: 8) {
514− sectionHeader("Hyper API Key", systemImage: "bolt.fill")
515−
516− Spacer(minLength: 0)
517−
518− // The confirmation takes the link's place rather than adding a row
519− // of its own, so saving never resizes the popover.
520− if viewModel.savedConfirmation {
521− Text("✓ Saved")
522− .font(Self.captionFont)
523− .foregroundColor(.green)
524− .transition(.opacity)
525− } else {
526− Link("Get key →", destination: Self.websiteURL)
527− .font(Self.captionFont)
528− .transition(.opacity)
529− }
530− }
531−
532− HStack(spacing: 8) {
533− SecureField("sk-…", text: $viewModel.apiKeyInput)
534− .textFieldStyle(.roundedBorder)
535−
536− Button("Save") {
537− viewModel.saveAPIKey()
538− }
539− }
540− .controlSize(.small)
541− }
542−
543− VStack(alignment: .leading, spacing: 8) {
544− HStack(spacing: 8) {
545− sectionHeader("z.ai API Key", systemImage: "bolt.horizontal")
546−
547− Spacer(minLength: 0)
548−
549− // The confirmation takes the link's place rather than adding a row
550− // of its own, so saving never resizes the popover.
551− if viewModel.savedConfirmation {
552− Text("✓ Saved")
553− .font(Self.captionFont)
554− .foregroundColor(.green)
555− .transition(.opacity)
556− } else {
557− Link("Get key →", destination: Self.zaiKeyURL)
558− .font(Self.captionFont)
559− .transition(.opacity)
560− }
561− }
562−
563− HStack(spacing: 8) {
564− SecureField("your z.ai key", text: $viewModel.zaiAPIKeyInput)
565− .textFieldStyle(.roundedBorder)
566−
567− Button("Save") {
568− viewModel.saveZaiAPIKey()
569− }
570− }
571− .controlSize(.small)
572− }
573−
574− HStack {
575− Text("Refresh every")
576−
577− Spacer(minLength: 0)
578−
579− Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
580− ForEach(RefreshInterval.options, id: \.self) { minutes in
581− Text("\(minutes)m").tag(minutes)
582− }
583− }
584− .pickerStyle(.menu)
585− .labelsHidden()
586− .fixedSize()
587− .controlSize(.small)
588− }
589−
590− Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
591− .toggleStyle(.switch)
592− .controlSize(.small)
593− }
594− }
595−
596− // MARK: - Footer
597−
598− private var footer: some View {
599− VStack(spacing: 14) {
600− HStack {
601− Button(action: { viewModel.refresh() }) {
602− Label("Refresh", systemImage: "arrow.clockwise")
603− }
604− .disabled(viewModel.isLoading)
605−
606− Spacer(minLength: 12)
607−
608− Button("Quit") {
609− NSApplication.shared.terminate(nil)
610− }
611− .keyboardShortcut("q")
612− }
613− .controlSize(.small)
614− }
615− }
616−}
617−
618−// MARK: - Usage Bar
619−
620−/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
621−/// rule under the label rather than as a control.
622−private struct UsageBar: View {
623− /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
624− let percent: Int
625− let color: Color
626−
627− private let height: CGFloat = 3
628−
629− var body: some View {
630− GeometryReader { geometry in
631− let fraction = min(max(Double(percent) / 100, 0), 1)
632−
633− ZStack(alignment: .leading) {
634− Capsule()
635− .fill(Color.secondary.opacity(0.15))
636−
637− Capsule()
638− .fill(color)
639− .frame(width: geometry.size.width * fraction)
640− }
641− }
642− .frame(height: height)
643− }
644−}
645−
646−// MARK: - Sparkline
647−
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649−/// takes the metric's color so the trend reads as part of the number above it, with a
650−/// faint fade underneath for weight and a dot marking the latest point.
651−///
652−/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
653−/// readable when fetches are irregular (wake-ups, manual refreshes).
654−struct Sparkline: View {
655− let values: [Int]
656− var color: Color = .secondary
657−
658− /// Keeps the stroke and the dot from being clipped at the edges.
659− private let verticalInset: CGFloat = 3
660− private let dotRadius: CGFloat = 2.5
661−
662− var body: some View {
663− GeometryReader { geometry in
664− let points = points(in: geometry.size)
665−
666− if points.count >= 2 {
667− ZStack {
668− fill(under: points, height: geometry.size.height)
669− .fill(
670− LinearGradient(
671− colors: [color.opacity(0.18), color.opacity(0)],
672− startPoint: .top,
673− endPoint: .bottom
674− )
675− )
676−
677− line(through: points)
678− .stroke(
679− color.opacity(0.9),
680− style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
681− )
682−
683− if let last = points.last {
684− Circle()
685− .fill(color)
686− .frame(width: dotRadius * 2, height: dotRadius * 2)
687− .position(last)
688− }
689− }
690− }
691− }
692− }
693−
694− /// Normalizes the values into the available space, oldest at the left.
695− private func points(in size: CGSize) -> [CGPoint] {
696− guard values.count >= 2 else { return [] }
697−
698− let minValue = values.min() ?? 0
699− let maxValue = values.max() ?? 0
700− let range = Double(maxValue - minValue)
701−
702− // The dot is centered on the last point, so the line stops a radius short of
703− // the trailing edge rather than letting the dot spill outside the frame.
704− let usableWidth = max(size.width - dotRadius, 1)
705− let usableHeight = max(size.height - verticalInset * 2, 1)
706− let stepX = usableWidth / CGFloat(values.count - 1)
707−
708− return values.enumerated().map { index, value in
709− // A flat series has no range to normalize against; center it.
710− let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
711− return CGPoint(
712− x: CGFloat(index) * stepX,
713− y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
714− )
715− }
716− }
717−
718− private func line(through points: [CGPoint]) -> Path {
719− Path { path in
720− guard let first = points.first else { return }
721− path.move(to: first)
722− for point in points.dropFirst() {
723− path.addLine(to: point)
724− }
725− }
726− }
727−
728− /// The line closed down to the baseline, so it can be filled.
729− private func fill(under points: [CGPoint], height: CGFloat) -> Path {
730− Path { path in
731− guard let first = points.first, let last = points.last else { return }
732− path.move(to: CGPoint(x: first.x, y: height))
733− path.addLine(to: first)
734− for point in points.dropFirst() {
735− path.addLine(to: point)
736− }
737− path.addLine(to: CGPoint(x: last.x, y: height))
738− path.closeSubpath()
739− }
740− }
741−}
UsageMenubar/UsageMenubar.entitlementsdeleted−16
@@ -1,16 +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−<!--
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.swiftdeleted−277
@@ -1,277 +0,0 @@
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−}
UsageMenubar/ViewModel.swiftdeleted−899
@@ -1,899 +0,0 @@
1−import Foundation
2−import SwiftUI
3−import Combine
4−import ServiceManagement
5−import UserNotifications
6−
7−/// Posted when the user changes the refresh interval, so the app delegate can
8−/// restart its timer with the new period.
9−extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
11−}
12−
13−/// The direction a metric has moved over the most recent history points.
14−enum MetricTrend {
15− case increasing
16− case decreasing
17− case stable
18− /// Not enough history to say.
19− case unknown
20−
21− /// SF Symbol for the trend, or `nil` when there is nothing worth showing.
22− var symbolName: String? {
23− switch self {
24− case .increasing: return "arrow.up.right"
25− case .decreasing: return "arrow.down.right"
26− case .stable: return "arrow.right"
27− case .unknown: return nil
28− }
29− }
30−}
31−
32−/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33−/// and z.ai 5-hour % alike.
34−struct MetricPoint: Equatable {
35− let date: Date
36− let value: Int
37−}
38−
39−/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
40−/// the app delegate can read the options without hopping onto the main actor.
41−enum RefreshInterval {
42− /// `UserDefaults` key for the refresh period, in minutes.
43− static let defaultsKey = "refreshIntervalMinutes"
44−
45− /// The intervals offered in the settings picker.
46− static let options = [1, 5, 15, 30]
47−
48− /// Used when nothing valid is stored.
49− static let defaultMinutes = 5
50−
51− /// Reads the stored interval, falling back to the default when unset or when
52− /// the stored value isn't one we offer.
53− static func stored(in defaults: UserDefaults = .standard) -> Int {
54− let minutes = defaults.integer(forKey: defaultsKey)
55− guard options.contains(minutes) else { return defaultMinutes }
56− return minutes
57− }
58−}
59−
60−/// The view model that drives the menu bar display and the popover content.
61−@MainActor
62−final class ViewModel: ObservableObject {
63− /// The last known balance. `nil` when there has never been a successful fetch.
64− @Published var balance: Int?
65−
66− /// `true` while a balance request is in flight.
67− @Published var isLoading = false
68−
69− /// A human-readable error message, shown when the balance can't be fetched.
70− @Published var errorMessage: String?
71−
72− /// Claude Code's subscription usage. `nil` when Claude Code has never signed in on
73− /// this machine, in which case the popover leaves the section out entirely rather
74− /// than nagging someone who doesn't use it.
75− @Published var claudeUsage: ClaudeUsage?
76−
77− /// A human-readable error message from the Claude fetch. Independent of
78− /// `errorMessage`: the two services fail separately.
79− @Published var claudeError: String?
80−
81− /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
82− @Published var claudePlan: String?
83−
84− /// z.ai Coding Plan usage. `nil` when no z.ai API key has been entered, in which
85− /// case the popover leaves the section out entirely.
86− @Published var zaiUsage: ZaiUsageReport?
87−
88− /// A human-readable error message from the z.ai fetch. Independent of
89− /// `errorMessage` and `claudeError`: the three services fail separately.
90− @Published var zaiError: String?
91−
92− /// The z.ai API key draft bound to the text field. Two-way bound to the
93− /// `SecureField`, so it changes on every keystroke — never fetch with it, use
94− /// `activeZaiAPIKey`.
95− @Published var zaiAPIKeyInput: String = ""
96−
97− /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
98− /// Drives whether the `⚡` segment appears in the menu bar at all: an
99− /// unconfigured service should not take up space in the title.
100− @Published private(set) var hyperConfigured = false
101−
102− /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
103− /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
104− @Published var apiKeyInput: String = ""
105−
106− /// Timestamp of the last successful balance fetch.
107− @Published var lastUpdated: Date?
108−
109− /// Brief "✓ Saved" confirmation shown after saving the API key.
110− @Published var savedConfirmation: Bool = false
111−
112− /// Brief "✓ Copied" confirmation shown after copying the balance.
113− @Published var copiedConfirmation: Bool = false
114−
115− /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116− @Published var copiedClaudeConfirmation: Bool = false
117−
118− /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119− @Published var copiedZaiConfirmation: Bool = false
120−
121− /// Successful balance observations from the last 24 hours, oldest first.
122− @Published private(set) var history: [MetricPoint] = []
123−
124− /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125− @Published private(set) var claudeHistory: [MetricPoint] = []
126−
127− /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128− @Published private(set) var zaiHistory: [MetricPoint] = []
129−
130− /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131− /// changing it posts `.refreshIntervalDidChange` so the app delegate can
132− /// restart its timer.
133− @Published var refreshIntervalMinutes: Int {
134− didSet {
135− guard refreshIntervalMinutes != oldValue else { return }
136− defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
137− NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
138− }
139− }
140−
141− /// Whether launch-at-login is enabled.
142− @Published var launchAtLogin: Bool {
143− didSet {
144− guard !isInitializingLaunchAtLogin else { return }
145− updateLaunchAtLogin(launchAtLogin)
146− }
147− }
148−
149− /// Flag to suppress the didSet during initial setup.
150− private var isInitializingLaunchAtLogin = true
151−
152− /// The key requests actually go out with: what is in the Keychain, not what is
153− /// currently in the text field. Keeping these separate stops a timer or wake
154− /// refresh from firing with a half-typed key — or, if the field has been cleared
155− /// to paste a new one, from taking the empty-key path and wiping the balance,
156− /// last-updated time and history out from under the user.
157− private var activeAPIKey: String
158−
159− /// The z.ai key requests actually go out with: what is in the Keychain, not what
160− /// is currently in the text field. Same rationale as `activeAPIKey`.
161− private var activeZaiAPIKey: String
162−
163− private let checker: CreditsChecking
164− private let claudeChecker: ClaudeUsageChecking
165− private let zaiChecker: ZaiUsageChecking
166− private let defaults: UserDefaults
167−
168− /// The currently in-flight refresh task, if any. Used to cancel
169− /// overlapping refreshes (timer + user click + wake).
170− private var refreshTask: Task<Void, Never>?
171−
172− /// Resets `copiedConfirmation`; held so rapid clicks re-arm the delay
173− /// instead of the first click's reset firing early.
174− private var copiedResetTask: Task<Void, Never>?
175−
176− /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177− private var savedResetTask: Task<Void, Never>?
178−
179− /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180− private var copiedClaudeResetTask: Task<Void, Never>?
181−
182− /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183− private var copiedZaiResetTask: Task<Void, Never>?
184−
185− // MARK: - History Configuration
186−
187− /// 24 hours at the shortest (1 minute) interval would overflow this, so the
188− /// count cap and the age cutoff both apply; whichever bites first wins.
189− private static let maxHistoryEntries = 300
190− private static let historyWindow: TimeInterval = 24 * 60 * 60
191−
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195− init(
196− checker: CreditsChecking = CreditsChecker(),
197− claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
198− zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
199− defaults: UserDefaults = .standard
200− ) {
201− self.checker = checker
202− self.claudeChecker = claudeChecker
203− self.zaiChecker = zaiChecker
204− self.defaults = defaults
205− let storedKey = KeychainHelper.load() ?? ""
206− apiKeyInput = storedKey
207− activeAPIKey = storedKey
208− hyperConfigured = !storedKey.isEmpty
209− let storedZaiKey = ZaiKeychainHelper.load() ?? ""
210− zaiAPIKeyInput = storedZaiKey
211− activeZaiAPIKey = storedZaiKey
212− // Assigning in init doesn't fire didSet, so this load doesn't re-post
213− // the change notification or write back to defaults.
214− refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
215− launchAtLogin = SMAppService.mainApp.status == .enabled
216− isInitializingLaunchAtLogin = false
217− }
218−
219− // MARK: - Balance
220−
221− /// Cached number formatter for balance display.
222− private static let balanceFormatter: NumberFormatter = {
223− let formatter = NumberFormatter()
224− formatter.numberStyle = .decimal
225− return formatter
226− }()
227−
228− /// Cached relative time formatter for "Updated Xm ago" text.
229− private static let relativeFormatter: RelativeDateTimeFormatter = {
230− let formatter = RelativeDateTimeFormatter()
231− formatter.unitsStyle = .short
232− return formatter
233− }()
234−
235− /// Formats an integer with thousands grouping separator.
236− private static func formatBalance(_ value: Int) -> String {
237− balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238− }
239−
240− /// The text to show in the menu bar. Each configured provider contributes a
241− /// segment prefixed with a provider emoji; segments are separated by ` · `
242− /// (middle dot). Within a segment, windows are listed as bare percentages
243− /// separated by spaces. An unconfigured service produces no segment.
244− /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245− ///
246− /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247− ///
248− /// Takes its values as parameters rather than reading the properties: `@Published`
249− /// publishes in `willSet`, so a Combine subscriber that called back into the view
250− /// model would see the *pre-change* value and render a title one update behind.
251− static func statusBarText(
252− balance: Int?,
253− isLoading: Bool,
254− hyperConfigured: Bool,
255− claudeFiveHourPercent: Int?,
256− claudeSevenDayPercent: Int?,
257− zaiFiveHourPercent: Int?,
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
260− ) -> String {
261− var segments: [String] = []
262−
263− // Hyper
264− if hyperConfigured, let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
266− }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
271− claudeWindows.append("\(fiveHour)%")
272− }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
275− }
276− if !claudeWindows.isEmpty {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
278− }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
283− zaiWindows.append("\(zaiFiveHour)%")
284− }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
287− }
288− if !zaiWindows.isEmpty {
289− var segment = "Z" + zaiWindows.joined(separator: " ")
290− if zaiInPeakHours {
291− segment += "🔶"
292− }
293− segments.append(segment)
294− }
295−
296− if segments.isEmpty {
297− // No service is configured or has anything to show. While loading,
298− // show an ellipsis; otherwise the title is empty.
299− return isLoading ? "…" : ""
300− }
301− return segments.joined(separator: " · ")
302− }
303−
304− /// The text to show in the menu bar for the current state.
305− var statusBarItemText: String {
306− Self.statusBarText(
307− balance: balance,
308− isLoading: isLoading,
309− hyperConfigured: hyperConfigured,
310− claudeFiveHourPercent: claudeFiveHourPercent,
311− claudeSevenDayPercent: claudeSevenDayPercent,
312− zaiFiveHourPercent: zaiFiveHourPercent,
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
315− )
316− }
317−
318− /// Whether z.ai is currently in peak hours (06:00–10:00 UTC).
319− static var zaiInPeakHours: Bool {
320− let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321− return hour >= 6 && hour < 10
322− }
323−
324− /// The formatted balance string for display in the popover.
325− var formattedBalance: String {
326− guard let balance = balance else { return "?" }
327− return Self.formatBalance(balance)
328− }
329−
330− /// The color for the balance display based on thresholds.
331− var balanceColor: Color {
332− guard let balance = balance else { return .secondary }
333− if balance >= 100 { return .green }
334− if balance >= 10 { return .yellow }
335− return .red
336− }
337−
338− /// Relative time string for the last successful update, or nil if never updated.
339− var lastUpdatedText: String? {
340− guard let lastUpdated = lastUpdated else { return nil }
341− return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342− }
343−
344− // MARK: - History (Hyper)
345−
346− /// The last 24 hours of successful balance fetches, oldest first.
347− var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
349− }
350−
351− /// The direction of travel across the most recent handful of observations.
352− /// `.unknown` until there are at least two points to compare.
353− var balanceTrend: MetricTrend {
354− metricTrend(history)
355− }
356−
357− /// The sparkline's data for the Hyper balance.
358− var balanceSparklineValues: [Int] {
359− history.map { $0.value }
360− }
361−
362− // MARK: - History (Claude)
363−
364− /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365− var claudeSparklineValues: [Int] {
366− claudeHistory.map { $0.value }
367− }
368−
369− /// The direction of travel for the Claude 5-hour %.
370− var claudeTrend: MetricTrend {
371− metricTrend(claudeHistory)
372− }
373−
374− // MARK: - History (z.ai)
375−
376− /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377− var zaiSparklineValues: [Int] {
378− zaiHistory.map { $0.value }
379− }
380−
381− /// The direction of travel for the z.ai 5-hour %.
382− var zaiTrend: MetricTrend {
383− metricTrend(zaiHistory)
384− }
385−
386− // MARK: - History Helpers
387−
388− /// Computes the trend from a generic history array.
389− private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
390− guard points.count >= 2 else { return .unknown }
391− let recent = points.suffix(5)
392− guard let first = recent.first, let last = recent.last else { return .unknown }
393− if last.value > first.value { return .increasing }
394− if last.value < first.value { return .decreasing }
395− return .stable
396− }
397−
398− /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399− private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400− array.append(MetricPoint(date: date, value: value))
401−
402− let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
404−
405− if array.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
407− }
408− }
409−
410− // MARK: - Refresh
411−
412− /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
413− ///
414− /// The three are independent: any can fail, or not be configured at all, without
415− /// touching the others. Cancels any previously in-flight refresh to prevent races.
416− func refresh() {
417− refreshTask?.cancel()
418−
419− // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
420− let key = activeAPIKey
421− let zaiKey = activeZaiAPIKey
422− if key.isEmpty {
423− // Nothing to fetch, and nothing worth keeping: the balance, its history and
424− // any error all belonged to a key that is no longer there. Claude and z.ai
425− // are left alone — they don't depend on the Hyper key.
426− balance = nil
427− errorMessage = nil
428− lastUpdated = nil
429− history.removeAll()
430− }
431−
432− refreshTask = Task { @MainActor in
433− // Check cancellation before setting isLoading — a previous
434− // refresh() may have cancelled us and already set isLoading = false.
435− guard !Task.isCancelled else { return }
436− // Loading state covers all fetches: the popover's loading view depends
437− // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
438− isLoading = true
439− // Don't clear the errors here — keep showing the last one
440− // until we have a successful result.
441−
442− // All three requests go out at once; none waits on the others.
443− async let balanceOutcome = fetchBalance(key: key)
444− async let claudeOutcome = fetchClaudeUsage()
445− async let zaiOutcome = fetchZaiUsage(key: zaiKey)
446−
447− let balanceResult = await balanceOutcome
448− let claudeResult = await claudeOutcome
449− let zaiResult = await zaiOutcome
450− guard !Task.isCancelled else { return }
451−
452− applyBalance(balanceResult)
453− applyClaude(claudeResult)
454− applyZai(zaiResult)
455− isLoading = false
456− }
457− }
458−
459− /// The balance fetch, or `nil` when there is no key to fetch with.
460− private func fetchBalance(key: String) async -> Result<Int, Error>? {
461− guard !key.isEmpty else { return nil }
462− do {
463− return .success(try await checker.fetchBalance(apiKey: key))
464− } catch {
465− return .failure(error)
466− }
467− }
468−
469− private func applyBalance(_ result: Result<Int, Error>?) {
470− guard let result else { return }
471−
472− switch result {
473− case .success(let newBalance):
474− // Capture the previous balance before overwriting it: the low-balance
475− // notification fires on the crossing, not on the value.
476− let previousBalance = balance
477− balance = newBalance
478− errorMessage = nil
479− let now = Date()
480− lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
482−
483− // Low-balance notification: on threshold crossing (≥10 → <10)
484− // OR on first successful fetch if already below threshold.
485− if let previous = previousBalance, previous >= 10, newBalance < 10 {
486− sendLowBalanceNotification(balance: newBalance)
487− } else if previousBalance == nil && newBalance < 10 {
488− sendLowBalanceNotification(balance: newBalance)
489− }
490−
491− case .failure(let error):
492− // Keep the stale balance value — don't wipe it on error.
493− // Only set balance = nil if there was never a successful fetch
494− // (it's already nil in that case, so nothing to do).
495− errorMessage = error.localizedDescription
496− }
497− }
498−
499− // MARK: - Claude Usage
500−
501− /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
502− /// plenty of people don't use Claude Code, and an error telling them so would be
503− /// noise rather than news.
504− private enum ClaudeOutcome {
505− case notConfigured
506− case usage(ClaudeUsageReport)
507− case failure(String)
508− }
509−
510− private func fetchClaudeUsage() async -> ClaudeOutcome {
511− do {
512− return .usage(try await claudeChecker.fetchUsage())
513− } catch ClaudeUsageError.noCredentials {
514− return .notConfigured
515− } catch {
516− return .failure(error.localizedDescription)
517− }
518− }
519−
520− private func applyClaude(_ outcome: ClaudeOutcome) {
521− switch outcome {
522− case .notConfigured:
523− claudeUsage = nil
524− claudePlan = nil
525− claudeError = nil
526− claudeHistory.removeAll()
527−
528− case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531− claudeUsage = report.usage
532− claudePlan = report.subscriptionType
533− claudeError = nil
534−
535− // Record history and fire high-usage notification based on the 5-hour %.
536− if let percent = claudeFiveHourPercent {
537− let now = Date()
538− recordMetric(percent, into: &claudeHistory, at: now)
539−
540− // High-usage notification: on threshold crossing (<90 → ≥90)
541− // OR on first successful fetch if already above threshold.
542− if let previous = previousPercent, previous < Self.highUsageThreshold,
543− percent >= Self.highUsageThreshold {
544− sendHighUsageNotification(provider: "Claude Code", percent: percent)
545− } else if previousPercent == nil, percent >= Self.highUsageThreshold {
546− sendHighUsageNotification(provider: "Claude Code", percent: percent)
547− }
548− }
549−
550− case .failure(let message):
551− // As with the balance: keep the last good numbers on screen and say what
552− // went wrong, rather than blanking the section on a transient failure.
553− claudeError = message
554− }
555− }
556−
557− /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
558− var claudeFiveHourPercent: Int? {
559− guard let fiveHour = claudeUsage?.fiveHour else { return nil }
560− return Int(fiveHour.utilization.rounded())
561− }
562−
563− /// The 7-day window percentage for the menu bar title, or `nil` when absent.
564− /// Falls back to the 7-day Opus window when the regular 7-day is absent but Opus
565− /// has been used — on Max plans the Opus cap is the one that binds first.
566− var claudeSevenDayPercent: Int? {
567− if let sevenDay = claudeUsage?.sevenDay {
568− return Int(sevenDay.utilization.rounded())
569− }
570− if let opus = claudeUsage?.sevenDayOpus, opus.utilization > 0 {
571− return Int(opus.utilization.rounded())
572− }
573− return nil
574− }
575−
576− /// Display label for the Claude plan, e.g. "Pro" or "Max".
577− var claudePlanLabel: String? {
578− guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
579− !plan.isEmpty else { return nil }
580−
581− switch plan.lowercased() {
582− case "free": return "Free"
583− case "pro": return "Pro"
584− case "max": return "Max"
585− case "team": return "Team"
586− case "enterprise": return "Enterprise"
587− default: return plan.capitalized
588− }
589− }
590−
591− // MARK: - z.ai Usage
592−
593− /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
594− /// plenty of people don't use z.ai, and an error telling them so would be noise.
595− private enum ZaiOutcome {
596− case notConfigured
597− case usage(ZaiUsageReport)
598− case failure(String)
599− }
600−
601− private func fetchZaiUsage(key: String) async -> ZaiOutcome {
602− guard !key.isEmpty else { return .notConfigured }
603− do {
604− return .usage(try await zaiChecker.fetchUsage(apiKey: key))
605− } catch ZaiUsageError.noAPIKey {
606− return .notConfigured
607− } catch {
608− return .failure(error.localizedDescription)
609− }
610− }
611−
612− private func applyZai(_ outcome: ZaiOutcome) {
613− switch outcome {
614− case .notConfigured:
615− zaiUsage = nil
616− zaiError = nil
617− zaiHistory.removeAll()
618−
619− case .usage(let report):
620− // Capture the previous 5-hour % for threshold-crossing notification.
621− let previousPercent = zaiFiveHourPercent
622− zaiUsage = report
623− zaiError = nil
624−
625− // Record history and fire high-usage notification based on the 5-hour %.
626− if let percent = zaiFiveHourPercent {
627− let now = Date()
628− recordMetric(percent, into: &zaiHistory, at: now)
629−
630− // High-usage notification: on threshold crossing (<90 → ≥90)
631− // OR on first successful fetch if already above threshold.
632− if let previous = previousPercent, previous < Self.highUsageThreshold,
633− percent >= Self.highUsageThreshold {
634− sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635− } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636− sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637− }
638− }
639−
640− case .failure(let message):
641− // As with the balance and Claude: keep the last good numbers on screen
642− // and say what went wrong, rather than blanking the section on a
643− // transient failure.
644− zaiError = message
645− }
646− }
647−
648− /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
649− var zaiFiveHourPercent: Int? {
650− zaiUsage?.fiveHourPercent
651− }
652−
653− /// The weekly window percentage for the popover, or `nil` when absent.
654− var zaiWeeklyPercent: Int? {
655− zaiUsage?.weeklyPercent
656− }
657−
658− /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
659− /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
660− var zaiPlanLabel: String? {
661− guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
662− !level.isEmpty else { return nil }
663−
664− // The subscription endpoint returns a product name like "GLM Coding Max";
665− // extract the tier from it. The quota endpoint returns just the tier.
666− let lowered = level.lowercased()
667− if lowered.contains("max") { return "Max" }
668− if lowered.contains("pro") { return "Pro" }
669− if lowered.contains("lite") { return "Lite" }
670− return level.capitalized
671− }
672−
673− // MARK: - Clipboard
674−
675− /// Copies the current balance to the clipboard as plain digits (no grouping
676− /// separator), and shows a brief "✓ Copied" confirmation.
677− func copyBalanceToClipboard() {
678− guard let balance = balance else { return }
679−
680− let pasteboard = NSPasteboard.general
681− pasteboard.clearContents()
682− pasteboard.setString(String(balance), forType: .string)
683−
684− copiedConfirmation = true
685− copiedResetTask?.cancel()
686− copiedResetTask = Task { @MainActor in
687− try? await Task.sleep(nanoseconds: 1_500_000_000)
688− guard !Task.isCancelled else { return }
689− copiedConfirmation = false
690− }
691− }
692−
693− /// Copies the current Claude 5-hour percentage to the clipboard.
694− func copyClaudePercentToClipboard() {
695− guard let percent = claudeFiveHourPercent else { return }
696−
697− let pasteboard = NSPasteboard.general
698− pasteboard.clearContents()
699− pasteboard.setString("\(percent)%", forType: .string)
700−
701− copiedClaudeConfirmation = true
702− copiedClaudeResetTask?.cancel()
703− copiedClaudeResetTask = Task { @MainActor in
704− try? await Task.sleep(nanoseconds: 1_500_000_000)
705− guard !Task.isCancelled else { return }
706− copiedClaudeConfirmation = false
707− }
708− }
709−
710− /// Copies the current z.ai 5-hour percentage to the clipboard.
711− func copyZaiPercentToClipboard() {
712− guard let percent = zaiFiveHourPercent else { return }
713−
714− let pasteboard = NSPasteboard.general
715− pasteboard.clearContents()
716− pasteboard.setString("\(percent)%", forType: .string)
717−
718− copiedZaiConfirmation = true
719− copiedZaiResetTask?.cancel()
720− copiedZaiResetTask = Task { @MainActor in
721− try? await Task.sleep(nanoseconds: 1_500_000_000)
722− guard !Task.isCancelled else { return }
723− copiedZaiConfirmation = false
724− }
725− }
726−
727− // MARK: - Notifications
728−
729− private func sendLowBalanceNotification(balance: Int) {
730− // Build the body here, on the main actor. `getNotificationSettings` calls back on
731− // a background queue, and the shared `balanceFormatter` is main-actor state — the
732− // status bar title formats against the very same instance.
733− let body = "Low Hyper credits: \(Self.formatBalance(balance)) remaining"
734−
735− let center = UNUserNotificationCenter.current()
736− center.getNotificationSettings { settings in
737− guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
738− return
739− }
740− let content = UNMutableNotificationContent()
741− content.title = "Low Usage Menubar"
742− content.body = body
743− content.sound = .default
744−
745− // A fixed identifier makes a later alert replace the delivered one in place,
746− // which updates the text without ever notifying the user again.
747− let request = UNNotificationRequest(
748− identifier: "low-balance-alert-\(UUID().uuidString)",
749− content: content,
750− trigger: nil
751− )
752− center.add(request)
753− }
754− }
755−
756− /// Sends a notification when a usage percentage crosses the high-usage threshold.
757− private func sendHighUsageNotification(provider: String, percent: Int) {
758− let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
759−
760− let center = UNUserNotificationCenter.current()
761− center.getNotificationSettings { settings in
762− guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
763− return
764− }
765− let content = UNMutableNotificationContent()
766− content.title = "High Usage Menubar"
767− content.body = body
768− content.sound = .default
769−
770− let request = UNNotificationRequest(
771− identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
772− content: content,
773− trigger: nil
774− )
775− center.add(request)
776− }
777− }
778−
779− // MARK: - API Key
780−
781− /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
782− /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
783− func saveAPIKey() {
784− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
785− if key.isEmpty {
786− let deleted = KeychainHelper.delete()
787− guard deleted else {
788− errorMessage = "Could not delete API key from Keychain. It may be locked."
789− return
790− }
791− apiKeyInput = ""
792− activeAPIKey = ""
793− hyperConfigured = false
794− // refresh() with an empty key clears balance, error, history, and
795− // cancels any in-flight task from the previous key.
796− refresh()
797− } else {
798− let saved = KeychainHelper.save(key)
799− guard saved else {
800− errorMessage = "Could not save API key to Keychain. It may be locked."
801− return
802− }
803− // A different key is a different account, so everything derived from the old
804− // one has to go — not just the history behind the sparkline and trend. A
805− // carried-over balance would be displayed as the new account's until the
806− // fetch lands, and it would stand in as `previousBalance` for the low-balance
807− // threshold check, which would then compare two unrelated accounts.
808− if key != activeAPIKey {
809− balance = nil
810− lastUpdated = nil
811− errorMessage = nil
812− history.removeAll()
813− }
814− // Show the trimmed key that was actually stored.
815− apiKeyInput = key
816− activeAPIKey = key
817− hyperConfigured = true
818− refresh()
819− }
820−
821− // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
822− // Cancel any previous reset so rapid Save clicks don't hide the
823− // confirmation prematurely.
824− savedConfirmation = true
825− savedResetTask?.cancel()
826− savedResetTask = Task { @MainActor in
827− try? await Task.sleep(nanoseconds: 2_000_000_000)
828− guard !Task.isCancelled else { return }
829− savedConfirmation = false
830− }
831− }
832−
833− // MARK: - z.ai API Key
834−
835− /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
836− /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
837− func saveZaiAPIKey() {
838− let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
839− if key.isEmpty {
840− let deleted = ZaiKeychainHelper.delete()
841− guard deleted else {
842− zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
843− return
844− }
845− zaiAPIKeyInput = ""
846− activeZaiAPIKey = ""
847− // refresh() with an empty key clears zai usage and error, and
848− // cancels any in-flight task from the previous key.
849− refresh()
850− } else {
851− let saved = ZaiKeychainHelper.save(key)
852− guard saved else {
853− zaiError = "Could not save z.ai API key to Keychain. It may be locked."
854− return
855− }
856− // A different key is a different account, so everything derived from the
857− // old one has to go.
858− if key != activeZaiAPIKey {
859− zaiUsage = nil
860− zaiError = nil
861− zaiHistory.removeAll()
862− }
863− // Show the trimmed key that was actually stored.
864− zaiAPIKeyInput = key
865− activeZaiAPIKey = key
866− refresh()
867− }
868−
869− // Reuse the same "✓ Saved" confirmation — the user sees one per save action,
870− // regardless of which key it was for.
871− savedConfirmation = true
872− savedResetTask?.cancel()
873− savedResetTask = Task { @MainActor in
874− try? await Task.sleep(nanoseconds: 2_000_000_000)
875− guard !Task.isCancelled else { return }
876− savedConfirmation = false
877− }
878− }
879−
880− // MARK: - Launch at Login
881−
882− func updateLaunchAtLogin(_ enabled: Bool) {
883− do {
884− if enabled {
885− try SMAppService.mainApp.register()
886− } else {
887− try SMAppService.mainApp.unregister()
888− }
889− } catch {
890− errorMessage = "Could not \(enabled ? "enable" : "disable") launch at login: "
891− + error.localizedDescription
892− // Snap the switch back to what the system actually did, suppressing the
893− // didSet so this doesn't recurse into another register/unregister attempt.
894− isInitializingLaunchAtLogin = true
895− launchAtLogin = SMAppService.mainApp.status == .enabled
896− isInitializingLaunchAtLogin = false
897− }
898− }
899−}
UsageMenubar/ZaiUsageClient.swiftdeleted−388
@@ -1,388 +0,0 @@
1−import Foundation
2−
3−// MARK: - Usage
4−
5−/// Usage data from z.ai's coding plan API — two endpoints that together give the
6−/// plan level, the 5-hour token quota, and the weekly token quota.
7−///
8−/// Every field in the report is optional. The quota endpoint may return fewer limits
9−/// than expected, and the subscription endpoint may return an empty list, so an
10−/// unrecognized shape degrades to "nothing to show" rather than failing the decode.
11−struct ZaiUsageReport: Equatable {
12− /// Percent of the 5-hour token window consumed, 0–100.
13− let fiveHourPercent: Int?
14− /// Percent of the weekly token window consumed, 0–100.
15− let weeklyPercent: Int?
16− /// When the 5-hour window resets, or `nil` when not provided.
17− let fiveHourResetsAt: Date?
18− /// When the weekly window resets, or `nil` when not provided.
19− let weeklyResetsAt: Date?
20− /// The plan tier from the quota response: `"lite"`, `"pro"`, `"max"`.
21− let planLevel: String?
22−
23− /// Whether there is anything at all to render. A report with all-nil fields reads
24− /// as a section header over nothing, which looks like a bug.
25− var isEmpty: Bool {
26− fiveHourPercent == nil && weeklyPercent == nil && planLevel == nil
27− }
28−}
29−
30−// MARK: - API Response Models
31−
32−/// Response from `GET https://api.z.ai/api/monitor/usage/quota/limit`.
33−struct ZaiQuotaResponse: Decodable, Equatable {
34− let code: Int
35− let msg: String?
36− let data: ZaiQuotaData?
37− let success: Bool
38−
39− private enum CodingKeys: String, CodingKey {
40− case code, msg, data, success
41− }
42−
43− init(from decoder: Decoder) throws {
44− let container = try decoder.container(keyedBy: CodingKeys.self)
45− code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
46− msg = try container.decodeIfPresent(String.self, forKey: .msg)
47− data = try container.decodeIfPresent(ZaiQuotaData.self, forKey: .data)
48− success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
49− }
50−}
51−
52−struct ZaiQuotaData: Decodable, Equatable {
53− let limits: [ZaiLimit]?
54− let level: String?
55−
56− private enum CodingKeys: String, CodingKey {
57− case limits, level
58− }
59−
60− init(from decoder: Decoder) throws {
61− let container = try decoder.container(keyedBy: CodingKeys.self)
62− limits = try container.decodeIfPresent([ZaiLimit].self, forKey: .limits)
63− level = try container.decodeIfPresent(String.self, forKey: .level)
64− }
65−}
66−
67−/// One limit entry from the quota response.
68−///
69−/// `unit` identifies the window: 3 = 5-hour, 6 = weekly, 5 = monthly tools.
70−/// `type` is `"TOKENS_LIMIT"` for token quotas or `"TIME_LIMIT"` for tool-call quotas.
71−struct ZaiLimit: Decodable, Equatable {
72− let type: String
73− let unit: Int
74− let percentage: Int
75− /// Epoch milliseconds, or `nil` when nothing has been used.
76− let nextResetTime: Int64?
77−
78− private enum CodingKeys: String, CodingKey {
79− case type, unit, percentage, nextResetTime
80− }
81−
82− init(from decoder: Decoder) throws {
83− let container = try decoder.container(keyedBy: CodingKeys.self)
84− type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
85− unit = try container.decodeIfPresent(Int.self, forKey: .unit) ?? 0
86− // Decoded as Double and rounded: the field is documented as an integer, but
87− // a fractional value would throw and take the whole payload down with it.
88− let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percentage) ?? 0
89− percentage = Int(rawPercent.rounded())
90− nextResetTime = try container.decodeIfPresent(Int64.self, forKey: .nextResetTime)
91− }
92−
93− init(type: String, unit: Int, percentage: Int, nextResetTime: Int64? = nil) {
94− self.type = type
95− self.unit = unit
96− self.percentage = percentage
97− self.nextResetTime = nextResetTime
98− }
99−}
100−
101−/// Response from `GET https://api.z.ai/api/biz/subscription/list`.
102−struct ZaiSubscriptionResponse: Decodable, Equatable {
103− let code: Int
104− let data: [ZaiSubscription]?
105− let success: Bool
106−
107− private enum CodingKeys: String, CodingKey {
108− case code, data, success
109− }
110−
111− init(from decoder: Decoder) throws {
112− let container = try decoder.container(keyedBy: CodingKeys.self)
113− code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
114− data = try container.decodeIfPresent([ZaiSubscription].self, forKey: .data)
115− success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
116− }
117−}
118−
119−struct ZaiSubscription: Decodable, Equatable {
120− let productName: String?
121− let status: String?
122−
123− private enum CodingKeys: String, CodingKey {
124− case productName, status
125− }
126−
127− init(from decoder: Decoder) throws {
128− let container = try decoder.container(keyedBy: CodingKeys.self)
129− productName = try container.decodeIfPresent(String.self, forKey: .productName)
130− status = try container.decodeIfPresent(String.self, forKey: .status)
131− }
132−
133− init(productName: String? = nil, status: String? = nil) {
134− self.productName = productName
135− self.status = status
136− }
137−}
138−
139−// MARK: - Errors
140−
141−/// Errors that can occur while fetching z.ai usage.
142−enum ZaiUsageError: LocalizedError, Equatable {
143− /// No API key has been entered. Not an error the user needs to see — the popover
144− /// just leaves the section out, same as missing Claude credentials.
145− case noAPIKey
146−
147− /// The API key was rejected by z.ai.
148− case invalidAPIKey
149− case requestFailed(String)
150− case decodingFailed(String)
151−
152− var errorDescription: String? {
153− switch self {
154− case .noAPIKey:
155− return nil
156− case .invalidAPIKey:
157− return "z.ai API key is invalid. Check your key at z.ai/manage-apikey/coding-plan/personal/my-plan"
158− case .requestFailed(let message):
159− return "z.ai request failed: \(message)"
160− case .decodingFailed(let message):
161− return "Failed to decode z.ai usage: \(message)"
162− }
163− }
164−}
165−
166−// MARK: - Protocol
167−
168−/// Protocol abstraction for fetching z.ai usage, enabling test injection.
169−protocol ZaiUsageChecking {
170− func fetchUsage(apiKey: String) async throws -> ZaiUsageReport
171−}
172−
173−// MARK: - Client
174−
175−/// Client for z.ai's coding plan usage API, authenticating with an API key the user
176−/// enters in settings (stored in Keychain, separate from the Hyper key).
177−///
178−/// Fetches the quota/limit and subscription endpoints concurrently to get both usage
179−/// percentages and the plan tier in one round-trip. Retries transient failures with
180−/// exponential backoff, same pattern as `CreditsChecker` and `ClaudeUsageClient`.
181−actor ZaiUsageClient: ZaiUsageChecking {
182− static let baseURL = URL(string: "https://api.z.ai/api")!
183− static let quotaEndpoint = URL(string: "https://api.z.ai/api/monitor/usage/quota/limit")!
184− static let subscriptionEndpoint = URL(string: "https://api.z.ai/api/biz/subscription/list")!
185−
186− /// Retries *after* the initial attempt, so at most 3 requests per call.
187− static let maxRetries = 2
188−
189− /// URL error codes worth retrying: the request never reached a server that had an
190− /// opinion about it, so the same request may well succeed a moment later.
191− private static let retryableURLErrorCodes: Set<URLError.Code> = [
192− .timedOut,
193− .cannotConnectToHost,
194− .cannotFindHost,
195− .dnsLookupFailed,
196− .networkConnectionLost,
197− .notConnectedToInternet,
198− .resourceUnavailable,
199− .badServerResponse,
200− ]
201−
202− /// Client-error status codes that are nonetheless worth retrying: both are the
203− /// server telling us to come back later, not that the request itself is wrong.
204− private static let retryableStatusCodes: Set<Int> = [
205− 408, // Request Timeout
206− 429, // Too Many Requests
207− ]
208−
209− private let session: URLSession
210− private let retryBaseDelay: Duration
211−
212− /// - Parameters:
213− /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
214− /// one (2s, then 4s). Injectable so tests need not wait in real time.
215− init(
216− session: URLSession? = nil,
217− retryBaseDelay: Duration = .seconds(2)
218− ) {
219− self.retryBaseDelay = retryBaseDelay
220− if let session {
221− self.session = session
222− } else {
223− let config = URLSessionConfiguration.default
224− config.timeoutIntervalForRequest = 10
225− config.timeoutIntervalForResource = 15
226− self.session = URLSession(configuration: config)
227− }
228− }
229−
230− /// Fetches current usage with the given API key.
231− ///
232− /// The quota and subscription endpoints are fetched concurrently; neither waits on
233− /// the other. The subscription endpoint is best-effort — if it fails, the plan
234−/// level from the quota response (`data.level`) is used instead.
235− ///
236− /// - Throws: `ZaiUsageError`. `.noAPIKey` means no key was provided, which callers
237− /// should treat as "nothing to show" rather than as a failure.
238− func fetchUsage(apiKey: String) async throws -> ZaiUsageReport {
239− guard !apiKey.isEmpty else { throw ZaiUsageError.noAPIKey }
240−
241− async let quotaOutcome = fetchQuota(apiKey: apiKey)
242− async let subscriptionOutcome = fetchSubscription(apiKey: apiKey)
243−
244− let quota = try await quotaOutcome
245− let subscription = try? await subscriptionOutcome
246−
247− // Extract percentages from the limits array.
248− var fiveHourPercent: Int?
249− var weeklyPercent: Int?
250− var fiveHourResetsAt: Date?
251− var weeklyResetsAt: Date?
252−
253− for limit in quota?.limits ?? [] {
254− switch limit.unit {
255− case 3: // 5-hour window
256− fiveHourPercent = limit.percentage
257− fiveHourResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
258− case 6: // weekly window
259− weeklyPercent = limit.percentage
260− weeklyResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
261− default:
262− break
263− }
264− }
265−
266− // Plan level: prefer the product name from the subscription, fall back to the
267− // quota response's `level` field.
268− var planLevel: String? = quota?.level
269− if let sub = subscription?.data?.first(where: { $0.status == "VALID" }),
270− let name = sub.productName {
271− planLevel = name
272− }
273−
274− return ZaiUsageReport(
275− fiveHourPercent: fiveHourPercent,
276− weeklyPercent: weeklyPercent,
277− fiveHourResetsAt: fiveHourResetsAt,
278− weeklyResetsAt: weeklyResetsAt,
279− planLevel: planLevel
280− )
281− }
282−
283− // MARK: - Quota Request
284−
285− private func fetchQuota(apiKey: String) async throws -> ZaiQuotaData? {
286− var request = URLRequest(url: Self.quotaEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
287− request.httpMethod = "GET"
288− request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
289− request.setValue("application/json", forHTTPHeaderField: "Accept")
290−
291− let data = try await send(request)
292−
293− do {
294− let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: data)
295− guard response.success else {
296− throw ZaiUsageError.requestFailed(response.msg ?? "Unknown error")
297− }
298− return response.data
299− } catch let error as ZaiUsageError {
300− throw error
301− } catch {
302− throw ZaiUsageError.decodingFailed(error.localizedDescription)
303− }
304− }
305−
306− // MARK: - Subscription Request
307−
308− private func fetchSubscription(apiKey: String) async throws -> ZaiSubscriptionResponse {
309− var request = URLRequest(url: Self.subscriptionEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
310− request.httpMethod = "GET"
311− request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
312− request.setValue("application/json", forHTTPHeaderField: "Accept")
313−
314− let data = try await send(request)
315−
316− do {
317− return try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: data)
318− } catch {
319− throw ZaiUsageError.decodingFailed(error.localizedDescription)
320− }
321− }
322−
323− // MARK: - Transport
324−
325− /// Sends a request, retrying transient failures with exponential backoff.
326− private func send(_ request: URLRequest) async throws -> Data {
327− var attempt = 0
328−
329− while true {
330− do {
331− return try await performAttempt(request)
332− } catch let failure as AttemptFailure {
333− guard failure.isRetryable, attempt < Self.maxRetries else {
334− throw failure.underlying
335− }
336− try await Task.sleep(for: retryBaseDelay * (1 << attempt))
337− attempt += 1
338− }
339− }
340− }
341−
342− /// Performs a single request, tagging the failure with whether a retry could
343− /// plausibly help.
344− private func performAttempt(_ request: URLRequest) async throws -> Data {
345− let data: Data
346− let response: URLResponse
347− do {
348− (data, response) = try await session.data(for: request)
349− } catch let urlError as URLError {
350− throw AttemptFailure(
351− underlying: .requestFailed(urlError.localizedDescription),
352− isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
353− )
354− } catch {
355− throw AttemptFailure(
356− underlying: .requestFailed(error.localizedDescription),
357− isRetryable: false
358− )
359− }
360−
361− guard let httpResponse = response as? HTTPURLResponse else {
362− throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
363− }
364−
365− switch httpResponse.statusCode {
366− case 200...299:
367− return data
368− case 401:
369− throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
370− case 500...599:
371− throw AttemptFailure(
372− underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
373− isRetryable: true
374− )
375− default:
376− throw AttemptFailure(
377− underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
378− isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
379− )
380− }
381− }
382−
383− /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
384− private struct AttemptFailure: Error {
385− let underlying: ZaiUsageError
386− let isRetryable: Bool
387− }
388−}
UsageMenubarTests/ClaudeUsageClientTests.swiftdeleted−557
@@ -1,557 +0,0 @@
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.swiftdeleted−646
@@ -1,646 +0,0 @@
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.swiftdeleted−378
@@ -1,378 +0,0 @@
1−import XCTest
2−@testable import UsageMenubar
3−
4−/// The menu bar title and the Claude numbers it is built from.
5−///
6−/// Balances are kept under 1,000 so the assertions don't depend on the grouping
7−/// separator of whatever locale the test happens to run in.
8−@MainActor
9−final class StatusBarTextTests: XCTestCase {
10−
11− // MARK: - Title
12−
13− func testBothServices() {
14− let title = ViewModel.statusBarText(
15− balance: 42, isLoading: false,
16− hyperConfigured: true,
17− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19− zaiInPeakHours: false
20− )
21− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
22− }
23−
24− func testHyperOnly() {
25− let title = ViewModel.statusBarText(
26− balance: 42, isLoading: false,
27− hyperConfigured: true,
28− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30− zaiInPeakHours: false
31− )
32− XCTAssertEqual(title, "⚡42")
33− }
34−
35− func testClaudeOnly() {
36− let title = ViewModel.statusBarText(
37− balance: nil, isLoading: false,
38− hyperConfigured: false,
39− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41− zaiInPeakHours: false
42− )
43− XCTAssertEqual(title, "\u{2055}62% 8%")
44− }
45−
46− func testLoadingWithNothingToShow() {
47− let title = ViewModel.statusBarText(
48− balance: nil, isLoading: true,
49− hyperConfigured: false,
50− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52− zaiInPeakHours: false
53− )
54− XCTAssertEqual(title, "…")
55− }
56−
57− func testNothingToShow() {
58− let title = ViewModel.statusBarText(
59− balance: nil, isLoading: false,
60− hyperConfigured: false,
61− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63− zaiInPeakHours: false
64− )
65− XCTAssertEqual(title, "")
66− }
67−
68− /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
69− /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
70− /// each time would blink their percentage away and back.
71− func testLoadingKeepsAClaudePercentOnScreen() {
72− let title = ViewModel.statusBarText(
73− balance: nil, isLoading: true,
74− hyperConfigured: false,
75− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77− zaiInPeakHours: false
78− )
79− XCTAssertEqual(title, "\u{2055}62%")
80− }
81−
82− func testLoadingKeepsAStaleBalanceOnScreen() {
83− let title = ViewModel.statusBarText(
84− balance: 42, isLoading: true,
85− hyperConfigured: true,
86− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88− zaiInPeakHours: false
89− )
90− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
91− }
92−
93− func testZeroPercentIsShown() {
94− let title = ViewModel.statusBarText(
95− balance: 42, isLoading: false,
96− hyperConfigured: true,
97− claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99− zaiInPeakHours: false
100− )
101− XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
102− }
103−
104− func testBothClaudeWindowsZero() {
105− let title = ViewModel.statusBarText(
106− balance: 42, isLoading: false,
107− hyperConfigured: true,
108− claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110− zaiInPeakHours: false
111− )
112− XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
113− }
114−
115− func testFiveHourOnly() {
116− let title = ViewModel.statusBarText(
117− balance: nil, isLoading: false,
118− hyperConfigured: false,
119− claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121− zaiInPeakHours: false
122− )
123− XCTAssertEqual(title, "\u{2055}62%")
124− }
125−
126− func testSevenDayOnly() {
127− let title = ViewModel.statusBarText(
128− balance: nil, isLoading: false,
129− hyperConfigured: false,
130− claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
131− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132− zaiInPeakHours: false
133− )
134− XCTAssertEqual(title, "\u{2055}8%")
135− }
136−
137− // MARK: - Hyper not configured
138−
139− func testHyperNotConfiguredHidesBoltEvenWithBalance() {
140− // balance is non-nil but Hyper is not configured — should not appear.
141− // This can happen briefly during the transition when a key is deleted.
142− let title = ViewModel.statusBarText(
143− balance: 42, isLoading: false,
144− hyperConfigured: false,
145− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
146− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147− zaiInPeakHours: false
148− )
149− XCTAssertEqual(title, "")
150− }
151−
152− func testHyperNotConfiguredLoadingShowsJustDots() {
153− let title = ViewModel.statusBarText(
154− balance: nil, isLoading: true,
155− hyperConfigured: false,
156− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158− zaiInPeakHours: false
159− )
160− XCTAssertEqual(title, "…")
161− }
162−
163− func testHyperNotConfiguredButClaudeStillShows() {
164− let title = ViewModel.statusBarText(
165− balance: nil, isLoading: false,
166− hyperConfigured: false,
167− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
168− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169− zaiInPeakHours: false
170− )
171− XCTAssertEqual(title, "\u{2055}62% 8%")
172− }
173−
174− // MARK: - z.ai in Title
175−
176− func testZaiOnly() {
177− let title = ViewModel.statusBarText(
178− balance: nil, isLoading: false,
179− hyperConfigured: false,
180− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
181− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182− zaiInPeakHours: false
183− )
184− XCTAssertEqual(title, "\u{005A}12%")
185− }
186−
187− func testZaiWithHyper() {
188− let title = ViewModel.statusBarText(
189− balance: 42, isLoading: false,
190− hyperConfigured: true,
191− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
192− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193− zaiInPeakHours: false
194− )
195− XCTAssertEqual(title, "⚡42 · \u{005A}12%")
196− }
197−
198− func testZaiWithClaude() {
199− let title = ViewModel.statusBarText(
200− balance: nil, isLoading: false,
201− hyperConfigured: false,
202− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
203− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204− zaiInPeakHours: false
205− )
206− XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
207− }
208−
209− func testAllThreeServices() {
210− let title = ViewModel.statusBarText(
211− balance: 42, isLoading: false,
212− hyperConfigured: true,
213− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
214− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215− zaiInPeakHours: false
216− )
217− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
218− }
219−
220− func testZaiZeroIsShown() {
221− let title = ViewModel.statusBarText(
222− balance: 42, isLoading: false,
223− hyperConfigured: true,
224− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
225− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226− zaiInPeakHours: false
227− )
228− XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
229− }
230−
231− func testLoadingKeepsZaiPercentOnScreen() {
232− let title = ViewModel.statusBarText(
233− balance: nil, isLoading: true,
234− hyperConfigured: false,
235− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
236− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237− zaiInPeakHours: false
238− )
239− XCTAssertEqual(title, "\u{005A}12%")
240− }
241−
242− // MARK: - z.ai Weekly
243−
244− func testZaiWeeklyOnly() {
245− let title = ViewModel.statusBarText(
246− balance: nil, isLoading: false,
247− hyperConfigured: false,
248− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250− zaiInPeakHours: false
251− )
252− XCTAssertEqual(title, "\u{005A}3%")
253− }
254−
255− func testZaiWeeklyWithFiveHour() {
256− let title = ViewModel.statusBarText(
257− balance: nil, isLoading: false,
258− hyperConfigured: false,
259− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
260− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261− zaiInPeakHours: false
262− )
263− XCTAssertEqual(title, "\u{005A}12% 3%")
264− }
265−
266− func testZaiWeeklyZeroIsShown() {
267− let title = ViewModel.statusBarText(
268− balance: nil, isLoading: false,
269− hyperConfigured: false,
270− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
271− zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272− zaiInPeakHours: false
273− )
274− XCTAssertEqual(title, "\u{005A}12% 0%")
275− }
276−
277− func testLoadingKeepsZaiWeeklyOnScreen() {
278− let title = ViewModel.statusBarText(
279− balance: nil, isLoading: true,
280− hyperConfigured: false,
281− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
282− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283− zaiInPeakHours: false
284− )
285− XCTAssertEqual(title, "\u{005A}3%")
286− }
287−
288− // MARK: - z.ai Peak Hours
289−
290− func testPeakHoursAddsDiamondToZaiSegment() {
291− let title = ViewModel.statusBarText(
292− balance: 42, isLoading: false,
293− hyperConfigured: true,
294− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
295− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296− zaiInPeakHours: true
297− )
298− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%\u{1F536}")
299− }
300−
301− func testPeakHoursWithZaiOnly() {
302− let title = ViewModel.statusBarText(
303− balance: nil, isLoading: false,
304− hyperConfigured: false,
305− claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
306− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307− zaiInPeakHours: true
308− )
309− XCTAssertEqual(title, "\u{005A}12%\u{1F536}")
310− }
311−
312− func testPeakHoursIgnoredWhenZaiNotConfigured() {
313− let title = ViewModel.statusBarText(
314− balance: 42, isLoading: false,
315− hyperConfigured: true,
316− claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
317− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
318− zaiInPeakHours: true
319− )
320− // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
322− }
323−
324− // MARK: - Claude Window Percentages
325−
326− func testFiveHourPercentRounds() {
327− let usage = ClaudeUsage(
328− fiveHour: UsageWindow(utilization: 61.6),
329− sevenDay: nil, sevenDayOpus: nil, limits: nil
330− )
331− let vm = ViewModel()
332− vm.claudeUsage = usage
333− XCTAssertEqual(vm.claudeFiveHourPercent, 62)
334− XCTAssertNil(vm.claudeSevenDayPercent)
335− }
336−
337− func testSevenDayPercentRounds() {
338− let usage = ClaudeUsage(
339− fiveHour: nil,
340− sevenDay: UsageWindow(utilization: 4.4),
341− sevenDayOpus: nil, limits: nil
342− )
343− let vm = ViewModel()
344− vm.claudeUsage = usage
345− XCTAssertNil(vm.claudeFiveHourPercent)
346− XCTAssertEqual(vm.claudeSevenDayPercent, 4)
347− }
348−
349− func testSevenDayFallsBackToOpus() {
350− let usage = ClaudeUsage(
351− fiveHour: nil,
352− sevenDay: nil,
353− sevenDayOpus: UsageWindow(utilization: 33.5),
354− limits: nil
355− )
356− let vm = ViewModel()
357− vm.claudeUsage = usage
358− XCTAssertEqual(vm.claudeSevenDayPercent, 34)
359− }
360−
361− func testSevenDayDoesNotFallBackToZeroOpus() {
362− let usage = ClaudeUsage(
363− fiveHour: nil,
364− sevenDay: nil,
365− sevenDayOpus: UsageWindow(utilization: 0),
366− limits: nil
367− )
368− let vm = ViewModel()
369− vm.claudeUsage = usage
370− XCTAssertNil(vm.claudeSevenDayPercent)
371− }
372−
373− func testNilUsageYieldsNilPercents() {
374− let vm = ViewModel()
375− XCTAssertNil(vm.claudeFiveHourPercent)
376− XCTAssertNil(vm.claudeSevenDayPercent)
377− }
378−}
UsageMenubarTests/ZaiUsageClientTests.swiftdeleted−237
@@ -1,237 +0,0 @@
1−import XCTest
2−@testable import UsageMenubar
3−
4−@MainActor
5−final class ZaiUsageClientTests: XCTestCase {
6−
7− override func setUp() {
8− super.setUp()
9− StubURLProtocol.reset()
10− }
11−
12− override func tearDown() {
13− StubURLProtocol.reset()
14− super.tearDown()
15− }
16−
17− /// A client whose requests are served by `StubURLProtocol`, with a backoff short
18− /// enough that the retry tests run in milliseconds rather than seconds.
19− private func makeStubbedClient(retryBaseDelay: Duration = .milliseconds(1)) -> ZaiUsageClient {
20− let config = URLSessionConfiguration.ephemeral
21− config.protocolClasses = [StubURLProtocol.self]
22− return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
23− }
24−
25− /// Enqueues quota and subscription responses matched to their URL paths, so the
26− /// order of arrival doesn't matter — each endpoint gets its own response.
27− private func enqueueQuotaAndSubscription(
28− quotaStatus: Int = 200,
29− quotaJSON: Data,
30− subscriptionStatus: Int = 200,
31− subscriptionJSON: Data
32− ) {
33− StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
34− StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
35− }
36−
37− // MARK: - Decoding
38−
39− func testDecodeQuotaResponse() throws {
40− let json = """
41− {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
42− {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
43− {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
44− ],"level":"max"}}
45− """.data(using: .utf8)!
46− let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
47− XCTAssertTrue(response.success)
48− XCTAssertEqual(response.code, 200)
49− XCTAssertEqual(response.data?.level, "max")
50− XCTAssertEqual(response.data?.limits?.count, 2)
51− XCTAssertEqual(response.data?.limits?.first?.percentage, 12)
52− XCTAssertEqual(response.data?.limits?.first?.unit, 3)
53− }
54−
55− func testDecodeQuotaResponseWithFractionalPercentage() throws {
56− let json = """
57− {"code":200,"success":true,"data":{"limits":[
58− {"type":"TOKENS_LIMIT","unit":3,"percentage":12.6,"nextResetTime":1784278018952}
59− ],"level":"pro"}}
60− """.data(using: .utf8)!
61− let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
62− XCTAssertEqual(response.data?.limits?.first?.percentage, 13)
63− }
64−
65− func testDecodeQuotaResponseWithMissingFields() throws {
66− let json = """
67− {"code":200,"success":true,"data":{"limits":[],"level":null}}
68− """.data(using: .utf8)!
69− let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
70− XCTAssertEqual(response.data?.limits?.count, 0)
71− XCTAssertNil(response.data?.level)
72− }
73−
74− func testDecodeSubscriptionResponse() throws {
75− let json = """
76− {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
77− """.data(using: .utf8)!
78− let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
79− XCTAssertEqual(response.data?.first?.productName, "GLM Coding Max")
80− XCTAssertEqual(response.data?.first?.status, "VALID")
81− }
82−
83− func testDecodeEmptySubscriptionResponse() throws {
84− let json = """
85− {"code":200,"data":[],"success":true}
86− """.data(using: .utf8)!
87− let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
88− XCTAssertEqual(response.data?.count, 0)
89− }
90−
91− // MARK: - Fetch
92−
93− func testFetchUsageSuccess() async throws {
94− let quotaJSON = """
95− {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
96− {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
97− {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
98− ],"level":"max"}}
99− """.data(using: .utf8)!
100− let subscriptionJSON = """
101− {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
102− """.data(using: .utf8)!
103−
104− enqueueQuotaAndSubscription(quotaJSON: quotaJSON, subscriptionJSON: subscriptionJSON)
105−
106− let client = makeStubbedClient()
107− let report = try await client.fetchUsage(apiKey: "test-key")
108−
109− XCTAssertEqual(report.fiveHourPercent, 12)
110− XCTAssertEqual(report.weeklyPercent, 3)
111− XCTAssertNotNil(report.fiveHourResetsAt)
112− XCTAssertNotNil(report.weeklyResetsAt)
113− }
114−
115− func testFetchUsageNoKey() async {
116− let client = makeStubbedClient()
117− do {
118− _ = try await client.fetchUsage(apiKey: "")
119− XCTFail("Expected noAPIKey error")
120− } catch ZaiUsageError.noAPIKey {
121− // Expected
122− } catch {
123− XCTFail("Unexpected error: \(error)")
124− }
125−
126− XCTAssertEqual(StubURLProtocol.requestCount, 0)
127− }
128−
129− func testFetchUsageInvalidKey() async {
130− let errorJSON = """
131− {"code":401,"msg":"token expired or incorrect","success":false}
132− """.data(using: .utf8)!
133− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
134− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
135−
136− let client = makeStubbedClient()
137− do {
138− _ = try await client.fetchUsage(apiKey: "bad-key")
139− XCTFail("Expected invalidAPIKey error")
140− } catch ZaiUsageError.invalidAPIKey {
141− // Expected
142− } catch {
143− XCTFail("Unexpected error: \(error)")
144− }
145− }
146−
147− // MARK: - Retry
148−
149− func testRetriesOnServerErrorThenSucceeds() async throws {
150− let quotaJSON = """
151− {"code":200,"success":true,"data":{"limits":[
152− {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
153− ],"level":"max"}}
154− """.data(using: .utf8)!
155− let subscriptionJSON = """
156− {"code":200,"data":[],"success":true}
157− """.data(using: .utf8)!
158−
159− // Subscription succeeds on first try. Quota fails with 500, then succeeds.
160− StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
161− StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
162− StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
163−
164− let client = makeStubbedClient()
165− let report = try await client.fetchUsage(apiKey: "test-key")
166− XCTAssertEqual(report.fiveHourPercent, 12)
167− }
168−
169− func testRetriesOnRateLimitThenSucceeds() async throws {
170− let quotaJSON = """
171− {"code":200,"success":true,"data":{"limits":[
172− {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
173− ],"level":"max"}}
174− """.data(using: .utf8)!
175− let subscriptionJSON = """
176− {"code":200,"data":[],"success":true}
177− """.data(using: .utf8)!
178−
179− StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
180− StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
181− StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
182−
183− let client = makeStubbedClient()
184− let report = try await client.fetchUsage(apiKey: "test-key")
185− XCTAssertEqual(report.fiveHourPercent, 12)
186− }
187−
188− func testDoesNotRetryOn401() async {
189− let errorJSON = """
190− {"code":401,"msg":"token expired or incorrect","success":false}
191− """.data(using: .utf8)!
192− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
193− StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
194−
195− let client = makeStubbedClient()
196− do {
197− _ = try await client.fetchUsage(apiKey: "bad-key")
198− XCTFail("Expected invalidAPIKey error")
199− } catch ZaiUsageError.invalidAPIKey {
200− // Expected — should not retry
201− } catch {
202− XCTFail("Unexpected error: \(error)")
203− }
204− }
205−
206− // MARK: - ZaiUsageReport
207−
208− func testEmptyReportIsEmpty() {
209− let report = ZaiUsageReport(
210− fiveHourPercent: nil,
211− weeklyPercent: nil,
212− fiveHourResetsAt: nil,
213− weeklyResetsAt: nil,
214− planLevel: nil
215− )
216− XCTAssertTrue(report.isEmpty)
217− }
218−
219− func testNonEmptyReportIsNotEmpty() {
220− let report = ZaiUsageReport(
221− fiveHourPercent: 12,
222− weeklyPercent: nil,
223− fiveHourResetsAt: nil,
224− weeklyResetsAt: nil,
225− planLevel: nil
226− )
227− XCTAssertFalse(report.isEmpty)
228− }
229−
230− // MARK: - Protocol conformance
231−
232− func testZaiUsageClientConformsToProtocol() {
233− let checker: ZaiUsageChecking = ZaiUsageClient()
234− // If this compiles, ZaiUsageClient conforms to ZaiUsageChecking.
235− XCTAssertNotNil(checker)
236− }
237−}
project.yml+24−49
@@ -1,14 +1,6 @@
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−
1+name: HyperCreditsMenubar
62options:
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.
3+ bundleIdPrefix: com.ilyakooo0
124 deploymentTarget:
135 macOS: "14.0"
146 createIntermediateGroups: true
@@ -17,90 +9,73 @@ options:
179settings:
1810 base:
1911 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).
2212 DEVELOPMENT_TEAM: ""
2313 CODE_SIGN_IDENTITY: ""
2414 CODE_SIGNING_REQUIRED: "NO"
2515 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.
3016 MARKETING_VERSION: "1.0.0"
3117 CURRENT_PROJECT_VERSION: "1"
3218
3319targets:
34− # ── The menu bar app ───────────────────────────────────────────────────
35− UsageMenubar:
20+ HyperCreditsMenubar:
3621 type: application
3722 platform: macOS
3823 sources:
39− - path: UsageMenubar
24+ - path: HyperCreditsMenubar
4025 excludes:
41− # Referenced explicitly via INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS,
42− # so they must not also be compiled in as resources.
4326 - "Info.plist"
4427 - "*.entitlements"
28+ resources:
29+ - HyperCreditsMenubar/Assets.xcassets
4530 info:
46− path: UsageMenubar/Info.plist
31+ path: HyperCreditsMenubar/Info.plist
4732 properties:
48− CFBundleName: UsageMenubar
49− CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: soy.iko.usage-menubar
51− # Overwritten by CI — see the note on MARKETING_VERSION above.
33+ CFBundleName: HyperCreditsMenubar
34+ CFBundleDisplayName: Hyper Credits
35+ CFBundleIdentifier: com.ilyakooo0.hyper-credits-menubar
5236 CFBundleVersion: "1"
5337 CFBundleShortVersionString: "1.0.0"
54− # Agent app: no dock icon and no main window; it lives in the menu bar.
5538 LSUIElement: true
5639 LSApplicationCategoryType: public.app-category.utilities
5740 NSHighResolutionCapable: true
5841 settings:
5942 base:
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
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
6847 ENABLE_HARDENED_RUNTIME: YES
69− ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
7048 MACOSX_DEPLOYMENT_TARGET: "14.0"
7149 LD_RUNPATH_SEARCH_PATHS:
7250 - "$(inherited)"
7351 - "@executable_path/../Frameworks"
7452
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:
53+ HyperCreditsMenubarTests:
7954 type: bundle.unit-test
8055 platform: macOS
8156 sources:
82− - path: UsageMenubarTests
57+ - path: HyperCreditsMenubarTests
8358 dependencies:
84− - target: UsageMenubar
59+ - target: HyperCreditsMenubar
8560 settings:
8661 base:
87− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
88− PRODUCT_NAME: UsageMenubarTests
62+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar.tests
63+ PRODUCT_NAME: HyperCreditsMenubarTests
8964 BUNDLE_LOADER: "$(TEST_HOST)"
90− TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"
65+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/HyperCreditsMenubar.app/Contents/MacOS/HyperCreditsMenubar"
9166 MACOSX_DEPLOYMENT_TARGET: "14.0"
9267
9368schemes:
94− UsageMenubar:
69+ HyperCreditsMenubar:
9570 build:
9671 targets:
97− UsageMenubar: all
98− UsageMenubarTests: [test]
72+ HyperCreditsMenubar: all
73+ HyperCreditsMenubarTests: [test]
9974 run:
10075 config: Debug
10176 test:
10277 config: Debug
10378 targets:
104− - UsageMenubarTests
79+ - HyperCreditsMenubarTests
10580 archive:
10681 config: Release