on master help sign in
feat: comprehensive improvements across bugs, UX, features, and CI

Bugs fixed: - Right-click on menu bar item now shows a proper NSMenu (Refresh, Open, Quit) instead of just toggling the popover same as left-click - BalanceResponse handles fractional API responses (e.g. {"balance": 42.5}) by falling back to Double and rounding to nearest Int - CreditsChecker retries transient failures (5xx, timeouts) up to 2 times with exponential backoff (2s, 4s); no retry on 401 or 4xx - Refresh task cancellation prevents races when timer + user click + wake fire overlapping requests - KeychainHelper.save() now returns Bool instead of silently ignoring SecItemAdd failure; ViewModel surfaces error if keychain rejects the key - Notification permission only requested when status is .notDetermined, not on every launch UX improvements: - "Get key →" link next to API key field opens hyper.charm.land - Click balance number to copy to clipboard with "✓ Copied" indicator - Balance sparkline (mini line chart) shows trend from recent fetches - Balance trend arrow (↑↗→↘) beside the number - Configurable refresh interval (1m/5m/15m/30m) via picker in popover - Refresh interval persisted to UserDefaults, timer restarts on change - Right-click context menu for quick access without opening popover Code quality: - ViewModel extracted from MenuView.swift into its own ViewModel.swift - BalanceRingView.swift (dead code) deleted - @MainActor annotation on ViewModel prevents threading bugs - Static NumberFormatter cached instead of per-call allocation - KeychainStore struct with injectable SecItem* functions for testability - Comprehensive test coverage: retry logic, fractional balance, keychain failure paths, header verification (~30 new tests) CI improvements: - Ad-hoc code signing (codesign -s -) after archive export - Notarization step wired up and gated on secrets (ready for Developer ID) - Version injection uses Python/pyyaml instead of fragile sed, with fallback - Re-zip after stapling so released artifact includes notarization ticket - project.yml documented with inline comments README updated with all new features, settings table, and accurate docs

commit2f7a334f92e827cb6a0b4d439a880f4e7fb1bd2f authoriko <mail@iko.soy> dateSat 11 Jul 2026, 16:30 (3 months ago) parentd3ea866
10 files changed+1502−352
.github/workflows/release.yml+169−12 HyperCreditsMenubar/BalanceRingView.swift−40 HyperCreditsMenubar/CreditsChecker.swift+124−20 HyperCreditsMenubar/HyperCreditsApp.swift+103−12 HyperCreditsMenubar/KeychainHelper.swift+83−28 HyperCreditsMenubar/MenuView.swift+163−228 HyperCreditsMenubar/ViewModel.swift+344 HyperCreditsMenubarTests/CreditsCheckerTests.swift+458 README.md+33−12 project.yml+25
.github/workflows/release.yml+169−12
@@ -35,13 +35,77 @@ jobs:
3535 run: brew install xcodegen
3636
3737 # ── 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.
3845 - 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("HyperCreditsMenubar", {})
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}")
3988 run: |
40− sed -i '' "s/MARKETING_VERSION: \".*\"/MARKETING_VERSION: \"$VERSION\"/" project.yml
41− sed -i '' "s/CFBundleShortVersionString: \".*\"/CFBundleShortVersionString: \"$VERSION\"/" project.yml
42− sed -i '' "s/CURRENT_PROJECT_VERSION: \".*\"/CURRENT_PROJECT_VERSION: \"$VERSION\"/" project.yml
43− sed -i '' "s/CFBundleVersion: \".*\"/CFBundleVersion: \"$VERSION\"/" project.yml
44− echo "Updated project.yml with version $VERSION"
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:"
45109 grep -E 'MARKETING_VERSION|CFBundleShortVersionString|CURRENT_PROJECT_VERSION|CFBundleVersion' project.yml
46110
47111 # ── Generate Xcode project ────────────────────────────────────────
@@ -78,18 +142,110 @@ jobs:
78142 - name: Export app from archive
79143 run: |
80144 mkdir -p build/export
81− # Simply copy the .app from the archive (no signing in CI)
145+ # Simply copy the .app from the archive (no signing during the build)
82146 cp -R "build/HyperCreditsMenubar.xcarchive/Products/Applications/HyperCreditsMenubar.app" \
83147 "build/export/HyperCreditsMenubar.app"
84148
85− # ── Zip the app ───────────────────────────────────────────────────
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/HyperCreditsMenubar.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
164+
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.
86169 - name: Zip the app
87170 run: |
171+ set -eo pipefail
172+ cd build/export
173+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${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/HyperCreditsMenubar-${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/HyperCreditsMenubar.app"
235+ xcrun stapler validate "build/export/HyperCreditsMenubar.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
88243 cd build/export
89− ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${{ env.VERSION }}.zip"
244+ rm -f "HyperCreditsMenubar-${VERSION}.zip"
245+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${VERSION}.zip"
90246 # Also create a stable-named zip for the Homebrew Cask
91− cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" "HyperCreditsMenubar-latest.zip"
92− cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" ../../
247+ cp "HyperCreditsMenubar-${VERSION}.zip" "HyperCreditsMenubar-latest.zip"
248+ cp "HyperCreditsMenubar-${VERSION}.zip" ../../
93249 cp "HyperCreditsMenubar-latest.zip" ../../
94250
95251 # ── Upload build artifact ─────────────────────────────────────────
@@ -117,8 +273,9 @@ jobs:
117273 3. Launch the app — it will appear in your menu bar as ⚡
118274 4. Click the ⚡ icon, enter your Hyper API key, and click Save
119275
120− > **Note:** This build is unsigned. On first launch you may need to
121− > right-click → Open, or allow it in System Settings → Privacy & Security.
276+ > **Note:** This build is ad-hoc signed, not signed with an Apple
277+ > Developer ID. On first launch you will still need to right-click →
278+ > **Open**, or allow it in **System Settings → Privacy & Security**.
122279
123280 ### Changes
124281 Built from commit ${{ github.sha }}
HyperCreditsMenubar/BalanceRingView.swiftdeleted−40
@@ -1,40 +0,0 @@
1−import SwiftUI
2−
3−/// A very thin, very subtle circular progress ring that visualises the credit
4−/// balance relative to a reference maximum. Not currently used in the main
5−/// popover — the balance number is the hero, not a ring — but kept as a
6−/// clean, minimal implementation that could be layered behind the number
7−/// if a visual indicator is desired.
8−///
9−/// The track is nearly invisible (0.08 opacity) and the arc is a flat color
10−/// (no gradient, no shadow) to stay understated.
11−struct BalanceRingView: View {
12− let balance: Int?
13− let color: Color
14− /// The balance value that maps to a full ring.
15− var referenceMax: Int = 500
16−
17− /// Progress in the range 0...1.
18− private var progress: Double {
19− guard let balance, balance > 0 else { return 0 }
20− return min(Double(balance) / Double(referenceMax), 1.0)
21− }
22−
23− var body: some View {
24− ZStack {
25− // Track — barely visible
26− Circle()
27− .stroke(color.opacity(0.08), lineWidth: 3)
28−
29− // Progress arc — flat color, no gradient, no shadow
30− Circle()
31− .trim(from: 0, to: progress)
32− .stroke(
33− color,
34− style: StrokeStyle(lineWidth: 3, lineCap: .round)
35− )
36− .rotationEffect(.degrees(-90)) // start from top
37− }
38− .animation(.easeInOut(duration: 0.4), value: progress)
39− }
40−}
HyperCreditsMenubar/CreditsChecker.swift+124−20
@@ -1,8 +1,40 @@
11import Foundation
22
33/// 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.
47struct BalanceResponse: Decodable, Equatable {
58 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()) 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+ }
638}
739
840/// Errors that can occur while fetching the balance.
@@ -38,9 +70,29 @@ protocol CreditsChecking {
3870final class CreditsChecker: CreditsChecking {
3971 static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
4072
73+ /// Number of retries *after* the initial attempt, so at most 3 requests total.
74+ static let maxRetries = 2
75+
76+ /// URL error codes worth retrying: the request never reached a server that had an
77+ /// opinion about it, so the same request may well succeed a moment later.
78+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
79+ .timedOut,
80+ .cannotConnectToHost,
81+ .cannotFindHost,
82+ .dnsLookupFailed,
83+ .networkConnectionLost,
84+ .notConnectedToInternet,
85+ .resourceUnavailable,
86+ .badServerResponse,
87+ ]
88+
4189 private let session: URLSession
90+ private let retryBaseDelay: Duration
4291
43− init(session: URLSession? = nil) {
92+ /// - Parameter retryBaseDelay: Delay before the first retry; doubles on each
93+ /// subsequent one (2s, then 4s). Injectable so tests need not wait in real time.
94+ init(session: URLSession? = nil, retryBaseDelay: Duration = .seconds(2)) {
95+ self.retryBaseDelay = retryBaseDelay
4496 if let session {
4597 self.session = session
4698 } else {
@@ -52,38 +104,90 @@ final class CreditsChecker: CreditsChecking {
52104 }
53105
54106 /// Fetches the current credit balance for the given API key.
107+ ///
108+ /// Transient failures (network errors, 5xx) are retried up to `maxRetries` times with
109+ /// exponential backoff. Authentication and other client errors fail immediately.
110+ ///
55111 /// - Parameter apiKey: A Hyper API key (starts with `"sk-"`).
56112 /// - Returns: The balance as an integer.
57113 /// - Throws: `CreditsError` on failure.
58114 func fetchBalance(apiKey: String) async throws -> Int {
59115 guard !apiKey.isEmpty else { throw CreditsError.noAPIKey }
60116
117+ let request = makeRequest(apiKey: apiKey)
118+ var attempt = 0
119+
120+ while true {
121+ do {
122+ return try await performAttempt(request)
123+ } catch let failure as AttemptFailure {
124+ guard failure.isRetryable, attempt < Self.maxRetries else {
125+ throw failure.underlying
126+ }
127+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
128+ attempt += 1
129+ }
130+ }
131+ }
132+
133+ private func makeRequest(apiKey: String) -> URLRequest {
61134 var request = URLRequest(url: Self.endpoint)
62135 request.httpMethod = "GET"
63136 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
64137 request.setValue("application/json", forHTTPHeaderField: "Accept")
138+ request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
139+ return request
140+ }
65141
142+ /// Performs a single request, tagging the failure with whether a retry could plausibly help.
143+ private func performAttempt(_ request: URLRequest) async throws -> Int {
144+ let data: Data
145+ let response: URLResponse
66146 do {
67− let (data, response) = try await session.data(for: request)
68− guard let httpResponse = response as? HTTPURLResponse else {
69− throw CreditsError.requestFailed("Invalid response type")
70− }
71− guard (200...299).contains(httpResponse.statusCode) else {
72− if httpResponse.statusCode == 401 {
73− throw CreditsError.invalidAPIKey
74− }
75− throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
76− }
77− do {
78− let decoded = try JSONDecoder().decode(BalanceResponse.self, from: data)
79− return decoded.balance
80− } catch {
81− throw CreditsError.decodingFailed(error.localizedDescription)
82− }
83− } catch let error as CreditsError {
84− throw error
147+ (data, response) = try await session.data(for: request)
148+ } catch let urlError as URLError {
149+ throw AttemptFailure(
150+ underlying: .requestFailed(urlError.localizedDescription),
151+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
152+ )
85153 } catch {
86− throw CreditsError.requestFailed(error.localizedDescription)
154+ throw AttemptFailure(
155+ underlying: .requestFailed(error.localizedDescription),
156+ isRetryable: false
157+ )
158+ }
159+
160+ guard let httpResponse = response as? HTTPURLResponse else {
161+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
87162 }
163+
164+ switch httpResponse.statusCode {
165+ case 200...299:
166+ break
167+ case 401:
168+ throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
169+ case 500...599:
170+ throw AttemptFailure(
171+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
172+ isRetryable: true
173+ )
174+ default:
175+ throw AttemptFailure(
176+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
177+ isRetryable: false
178+ )
179+ }
180+
181+ do {
182+ return try JSONDecoder().decode(BalanceResponse.self, from: data).balance
183+ } catch {
184+ throw AttemptFailure(underlying: .decodingFailed(error.localizedDescription), isRetryable: false)
185+ }
186+ }
187+
188+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
189+ private struct AttemptFailure: Error {
190+ let underlying: CreditsError
191+ let isRetryable: Bool
88192 }
89193}
HyperCreditsMenubar/HyperCreditsApp.swift+103−12
@@ -15,13 +15,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
1515 /// Combine cancellables for observing published changes.
1616 private var statusTextCancellable: AnyCancellable?
1717
18− private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
18+ /// The site the "Open" menu item points at.
19+ private static let websiteURL = URL(string: "https://hyper.charm.land")!
1920
2021 // MARK: - Lifecycle
2122
2223 func applicationDidFinishLaunching(_ notification: Notification) {
23− // Request notification permission for low-balance alerts
24− requestNotificationPermission()
24+ // Only prompt if the user has never answered; re-asking after a denial
25+ // does nothing, and after an approval it's pointless.
26+ requestNotificationPermissionIfNeeded()
2527
2628 // Create status bar item
2729 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -30,7 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3032 button.image = nil
3133 button.title = "⚡?"
3234 button.target = self
33− button.action = #selector(togglePopover(_:))
35+ button.action = #selector(statusItemClicked(_:))
3436 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
3537 }
3638
@@ -52,27 +54,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5254 // Register for sleep/wake notifications
5355 registerSleepWakeNotifications()
5456
57+ // Restart the timer whenever the user picks a new interval in the popover.
58+ NotificationCenter.default.addObserver(
59+ self,
60+ selector: #selector(refreshIntervalDidChange(_:)),
61+ name: .refreshIntervalDidChange,
62+ object: nil
63+ )
64+
5565 // Initial fetch
5666 viewModel.refresh()
5767
5868 // Start periodic refresh
59− let timer = Timer(timeInterval: refreshInterval, repeats: true) { [weak self] _ in
69+ startRefreshTimer()
70+ }
71+
72+ func applicationWillTerminate(_ notification: Notification) {
73+ refreshTimer?.invalidate()
74+ statusTextCancellable?.cancel()
75+ NotificationCenter.default.removeObserver(self)
76+ NSWorkspace.shared.notificationCenter.removeObserver(self)
77+ }
78+
79+ // MARK: - Refresh Timer
80+
81+ /// (Re)starts the periodic refresh using the interval stored in the view model.
82+ private func startRefreshTimer() {
83+ refreshTimer?.invalidate()
84+
85+ let minutes = max(1, viewModel.refreshIntervalMinutes)
86+ let interval = TimeInterval(minutes * 60)
87+
88+ let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
6089 self?.viewModel.refresh()
6190 }
6291 RunLoop.main.add(timer, forMode: .common)
6392 refreshTimer = timer
6493 }
6594
66− func applicationWillTerminate(_ notification: Notification) {
67− refreshTimer?.invalidate()
68− statusTextCancellable?.cancel()
95+ @objc private func refreshIntervalDidChange(_ notification: Notification) {
96+ startRefreshTimer()
6997 }
7098
7199 // MARK: - Notification Permission
72100
73− private func requestNotificationPermission() {
74− UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in
75− // Permission result is handled silently; notifications are optional.
101+ private func requestNotificationPermissionIfNeeded() {
102+ let center = UNUserNotificationCenter.current()
103+ center.getNotificationSettings { settings in
104+ guard settings.authorizationStatus == .notDetermined else { return }
105+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in
106+ // Permission result is handled silently; notifications are optional.
107+ }
76108 }
77109 }
78110
@@ -106,9 +138,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
106138 viewModel.refresh()
107139 }
108140
141+ // MARK: - Status Item Click
142+
143+ /// Left-click toggles the popover; right-click (or control-click) opens the context menu.
144+ @objc private func statusItemClicked(_ sender: Any?) {
145+ let event = NSApp.currentEvent
146+ let isRightClick = event?.type == .rightMouseUp
147+ || event?.type == .rightMouseDown
148+ || event?.modifierFlags.contains(.control) == true
149+
150+ if isRightClick {
151+ showContextMenu()
152+ } else {
153+ togglePopover()
154+ }
155+ }
156+
157+ // MARK: - Context Menu
158+
159+ private func showContextMenu() {
160+ if popover.isShown {
161+ closePopover()
162+ }
163+
164+ let menu = NSMenu()
165+
166+ let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu(_:)), keyEquivalent: "r")
167+ refreshItem.target = self
168+ menu.addItem(refreshItem)
169+
170+ let openItem = NSMenuItem(title: "Open hyper.charm.land", action: #selector(openWebsite(_:)), keyEquivalent: "")
171+ openItem.target = self
172+ menu.addItem(openItem)
173+
174+ menu.addItem(.separator())
175+
176+ let quitItem = NSMenuItem(title: "Quit HyperCredits", action: #selector(quit(_:)), keyEquivalent: "q")
177+ quitItem.target = self
178+ menu.addItem(quitItem)
179+
180+ // Attaching the menu makes the next click present it (with the correct
181+ // highlight + placement), then we detach so left-clicks keep firing our
182+ // action instead of reopening the menu.
183+ statusItem.menu = menu
184+ statusItem.button?.performClick(nil)
185+ statusItem.menu = nil
186+ }
187+
188+ @objc private func refreshFromMenu(_ sender: Any?) {
189+ viewModel.refresh()
190+ }
191+
192+ @objc private func openWebsite(_ sender: Any?) {
193+ NSWorkspace.shared.open(Self.websiteURL)
194+ }
195+
196+ @objc private func quit(_ sender: Any?) {
197+ NSApplication.shared.terminate(nil)
198+ }
199+
109200 // MARK: - Popover
110201
111− @objc private func togglePopover(_ sender: Any?) {
202+ private func togglePopover() {
112203 guard let button = statusItem.button else { return }
113204 if popover.isShown {
114205 closePopover()
HyperCreditsMenubar/KeychainHelper.swift+83−28
@@ -1,47 +1,102 @@
11import Foundation
22import Security
33
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"
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
813
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+ 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+ [
1436 kSecClass as String: kSecClassGenericPassword,
1537 kSecAttrService as String: service,
1638 kSecAttrAccount as String: account,
17− kSecValueData as String: data,
18− kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
1939 ]
20− SecItemAdd(query as CFDictionary, nil)
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+ @discardableResult
45+ func save(_ key: String) -> Bool {
46+ delete()
47+ guard let data = key.data(using: .utf8) else { return false }
48+
49+ var query = baseQuery
50+ query[kSecValueData as String] = data
51+ query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
52+
53+ return addItem(query as CFDictionary, nil) == errSecSuccess
2154 }
2255
2356 /// 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− ]
57+ func load() -> String? {
58+ var query = baseQuery
59+ query[kSecReturnData as String] = true
60+ query[kSecMatchLimit as String] = kSecMatchLimitOne
61+
3262 var item: CFTypeRef?
33− let status = SecItemCopyMatching(query as CFDictionary, &item)
63+ let status = copyMatching(query as CFDictionary, &item)
3464 guard status == errSecSuccess, let data = item as? Data else { return nil }
3565 return String(data: data, encoding: .utf8)
3666 }
3767
3868 /// 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)
69+ /// - Returns: `true` if the item is gone afterwards — including when it never existed.
70+ @discardableResult
71+ func delete() -> Bool {
72+ let status = deleteItem(baseQuery as CFDictionary)
73+ return status == errSecSuccess || status == errSecItemNotFound
74+ }
75+}
76+
77+/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
78+enum KeychainHelper {
79+ private static let service = "com.ilyakooo0.hyper-credits-menubar"
80+ private static let account = "hyper-api-key"
81+
82+ private static let store = KeychainStore(service: service, account: account)
83+
84+ /// Saves the given API key to the Keychain, overwriting any existing entry.
85+ /// - Returns: `true` on success; `false` if the item could not be stored, e.g. because
86+ /// the keychain is locked.
87+ @discardableResult
88+ static func save(_ key: String) -> Bool {
89+ store.save(key)
90+ }
91+
92+ /// Retrieves the stored API key, or `nil` if none exists.
93+ static func load() -> String? {
94+ store.load()
95+ }
96+
97+ /// Deletes the stored API key if it exists.
98+ @discardableResult
99+ static func delete() -> Bool {
100+ store.delete()
46101 }
47102}
HyperCreditsMenubar/MenuView.swift+163−228
@@ -1,247 +1,117 @@
1−import Foundation
21import SwiftUI
3−import Combine
4−import ServiceManagement
5−import UserNotifications
62
7−/// The view model that drives the menu bar display and the popover content.
8−final class ViewModel: ObservableObject {
9− /// The last known balance. `nil` when there has never been a successful fetch.
10− @Published var balance: Int?
11−
12− /// `true` while a network request is in flight.
13− @Published var isLoading = false
14−
15− /// A human-readable error message, shown when the balance can't be fetched.
16− @Published var errorMessage: String?
17−
18− /// The API key entered by the user (loaded from Keychain on init).
19− @Published var apiKeyInput: String = ""
20−
21− /// Timestamp of the last successful balance fetch.
22− @Published var lastUpdated: Date?
3+/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
4+struct MenuView: View {
5+ @ObservedObject var viewModel: ViewModel
236
24− /// Brief "✓ Saved" confirmation shown after saving the API key.
25− @Published var savedConfirmation: Bool = false
7+ private static let websiteURL = URL(string: "https://hyper.charm.land")!
268
27− /// Whether launch-at-login is enabled.
28− @Published var launchAtLogin: Bool {
29− didSet {
30− guard !isInitializingLaunchAtLogin else { return }
31− updateLaunchAtLogin(launchAtLogin)
9+ var body: some View {
10+ VStack(alignment: .leading, spacing: 18) {
11+ hero
12+ settings
13+ footer
3214 }
15+ .padding(16)
16+ .frame(width: 280)
17+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
18+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
3319 }
3420
35− /// Flag to suppress the didSet during initial setup.
36− private var isInitializingLaunchAtLogin = true
37−
38− private let checker: CreditsChecking
21+ // MARK: - Hero
3922
40− init(checker: CreditsChecking = CreditsChecker()) {
41− self.checker = checker
42− apiKeyInput = KeychainHelper.load() ?? ""
43− launchAtLogin = SMAppService.mainApp.status == .enabled
44− isInitializingLaunchAtLogin = false
45− }
46−
47− // MARK: - Balance
23+ private var hero: some View {
24+ VStack(spacing: 6) {
25+ if viewModel.isLoading && viewModel.balance == nil {
26+ ProgressView()
27+ .scaleEffect(0.8)
28+ .frame(height: 50)
29+ Text("Loading…")
30+ .font(.caption)
31+ .foregroundColor(.secondary)
32+ } else {
33+ balanceNumber
34+ caption
35+ sparkline
36+ }
4837
49− /// Formats an integer with thousands grouping separator.
50− private func formatBalance(_ value: Int) -> String {
51− let formatter = NumberFormatter()
52− formatter.numberStyle = .decimal
53− return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
54− }
38+ if let lastUpdatedText = viewModel.lastUpdatedText {
39+ Text(lastUpdatedText)
40+ .font(.caption2)
41+ .foregroundColor(.secondary)
42+ }
5543
56− /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
57− var statusBarItemText: String {
58− if isLoading && balance == nil {
59− return "⚡…"
60− }
61− if let balance = balance {
62− return "⚡\(formatBalance(balance))"
44+ if let error = viewModel.errorMessage {
45+ Text(error)
46+ .font(.caption)
47+ .foregroundColor(.red)
48+ .multilineTextAlignment(.center)
49+ .padding(.top, 2)
50+ }
6351 }
64− return "⚡?"
65− }
66−
67− /// The formatted balance string for display in the popover.
68− var formattedBalance: String {
69− guard let balance = balance else { return "?" }
70− return formatBalance(balance)
71− }
72−
73− /// The color for the balance display based on thresholds.
74− var balanceColor: Color {
75− guard let balance = balance else { return .secondary }
76− if balance >= 100 { return .green }
77− if balance >= 10 { return .yellow }
78− return .red
79− }
80−
81− /// Relative time string for the last successful update, or nil if never updated.
82− var lastUpdatedText: String? {
83− guard let lastUpdated = lastUpdated else { return nil }
84− let formatter = RelativeDateTimeFormatter()
85− formatter.unitsStyle = .short
86− return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
52+ .frame(maxWidth: .infinity)
8753 }
8854
89− /// Refreshes the balance from the API. No-op if no API key is set.
90− func refresh() {
91− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
92− guard !key.isEmpty else {
93− balance = nil
94− errorMessage = nil
95− lastUpdated = nil
96− return
97− }
98−
99− Task { @MainActor in
100− isLoading = true
101− // Don't clear errorMessage here — keep showing the last error
102− // until we have a successful result.
103− do {
104− let result = try await checker.fetchBalance(apiKey: key)
105− // Check for low-balance threshold crossing before updating
106− let previousBalance = balance
107− balance = result
108− errorMessage = nil
109− lastUpdated = Date()
110− isLoading = false
111−
112− // Low-balance notification: only on threshold crossing (≥10 → <10)
113− if let prev = previousBalance, prev >= 10, result < 10 {
114− sendLowBalanceNotification(balance: result)
55+ /// The balance itself. Clicking it copies the raw number to the clipboard.
56+ private var balanceNumber: some View {
57+ Button(action: { viewModel.copyBalanceToClipboard() }) {
58+ HStack(alignment: .firstTextBaseline, spacing: 6) {
59+ Text(viewModel.formattedBalance)
60+ .font(.system(size: 42, weight: .bold, design: .rounded))
61+ .foregroundColor(viewModel.balanceColor)
62+
63+ if let symbol = viewModel.balanceTrend.symbolName {
64+ Image(systemName: symbol)
65+ .font(.system(size: 12, weight: .semibold))
66+ .foregroundColor(.secondary)
11567 }
116− } catch {
117− // Keep the stale balance value — don't wipe it on error.
118− // Only set balance = nil if there was never a successful fetch
119− // (it's already nil in that case, so nothing to do).
120− errorMessage = error.localizedDescription
121− isLoading = false
12268 }
12369 }
70+ .buttonStyle(.plain)
71+ .disabled(viewModel.balance == nil)
72+ .help("Click to copy")
12473 }
12574
126− // MARK: - Low Balance Notification
127−
128− private func sendLowBalanceNotification(balance: Int) {
129− let center = UNUserNotificationCenter.current()
130− center.getNotificationSettings { settings in
131− guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
132− return
75+ /// Doubles as the copy confirmation so the layout doesn't shift.
76+ private var caption: some View {
77+ Group {
78+ if viewModel.copiedConfirmation {
79+ Text("✓ Copied")
80+ .foregroundColor(.green)
81+ } else {
82+ Text("credits")
83+ .foregroundColor(.secondary)
13384 }
134− let content = UNMutableNotificationContent()
135− content.title = "Low Hyper Credits"
136− let formatter = NumberFormatter()
137− formatter.numberStyle = .decimal
138− let balanceStr = formatter.string(from: NSNumber(value: balance)) ?? "\(balance)"
139− content.body = "Low Hyper credits: \(balanceStr) remaining"
140− content.sound = .default
141−
142− let request = UNNotificationRequest(
143− identifier: "low-balance-alert",
144− content: content,
145− trigger: nil
146− )
147− center.add(request)
148− }
149− }
150−
151− // MARK: - API Key
152−
153− /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
154− func saveAPIKey() {
155− let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
156− if key.isEmpty {
157− KeychainHelper.delete()
158− balance = nil
159− errorMessage = nil
160− lastUpdated = nil
161− } else {
162− KeychainHelper.save(key)
163− refresh()
164− }
165−
166− // Show "✓ Saved" confirmation, auto-reset after 2 seconds
167− savedConfirmation = true
168− Task { @MainActor in
169− try? await Task.sleep(nanoseconds: 2_000_000_000)
170− savedConfirmation = false
17185 }
86+ .font(.caption)
17287 }
17388
174− // MARK: - Launch at Login
175−
176− func updateLaunchAtLogin(_ enabled: Bool) {
177− if enabled {
178− try? SMAppService.mainApp.register()
179− } else {
180− try? SMAppService.mainApp.unregister()
89+ @ViewBuilder
90+ private var sparkline: some View {
91+ // Key paths can't index tuple elements, so this stays a closure.
92+ let values = viewModel.balanceHistory.map { $0.balance }
93+ if values.count >= 2 {
94+ Sparkline(values: values)
95+ .frame(height: 24)
96+ .padding(.top, 2)
18197 }
18298 }
183−}
18499
185−// MARK: - MenuView
100+ // MARK: - Settings
186101
187−/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
188−struct MenuView: View {
189− @ObservedObject var viewModel: ViewModel
102+ private var settings: some View {
103+ VStack(alignment: .leading, spacing: 10) {
104+ Divider()
190105
191− var body: some View {
192− VStack(spacing: 16) {
193− // Balance display
194− VStack(spacing: 4) {
195− if viewModel.isLoading && viewModel.balance == nil {
196− ProgressView()
197− .scaleEffect(0.8)
198− Text("Loading…")
199− .font(.caption)
200− .foregroundColor(.secondary)
201− } else if viewModel.balance != nil {
202− Text(viewModel.formattedBalance)
203− .font(.system(size: 42, weight: .bold, design: .rounded))
204− .foregroundColor(viewModel.balanceColor)
205− Text("credits")
206− .font(.caption)
207− .foregroundColor(.secondary)
208− } else {
209− Text("?")
210− .font(.system(size: 42, weight: .bold, design: .rounded))
211− .foregroundColor(.secondary)
212− Text("credits")
106+ VStack(alignment: .leading, spacing: 6) {
107+ HStack {
108+ Text("API Key")
109+ .font(.headline)
110+ Spacer()
111+ Link("Get key →", destination: Self.websiteURL)
213112 .font(.caption)
214− .foregroundColor(.secondary)
215113 }
216− }
217−
218− // Last updated time
219− if let lastUpdatedText = viewModel.lastUpdatedText {
220− Text(lastUpdatedText)
221− .font(.caption2)
222− .foregroundColor(.secondary)
223− }
224114
225− if let error = viewModel.errorMessage {
226− Text(error)
227− .font(.caption)
228− .foregroundColor(.red)
229− .multilineTextAlignment(.center)
230− .padding(.horizontal)
231− }
232−
233− Divider()
234−
235− // Refresh button
236− Button(action: { viewModel.refresh() }) {
237− Label("Refresh now", systemImage: "arrow.clockwise")
238− }
239− .disabled(viewModel.isLoading)
240−
241− // API key section
242− VStack(alignment: .leading, spacing: 6) {
243− Text("API Key")
244− .font(.headline)
245115 HStack {
246116 SecureField("sk-...", text: $viewModel.apiKeyInput)
247117 .textFieldStyle(.roundedBorder)
@@ -249,6 +119,7 @@ struct MenuView: View {
249119 viewModel.saveAPIKey()
250120 }
251121 }
122+
252123 if viewModel.savedConfirmation {
253124 Text("✓ Saved")
254125 .font(.caption)
@@ -257,30 +128,94 @@ struct MenuView: View {
257128 }
258129 }
259130
260− // Launch at login toggle
131+ HStack {
132+ Text("Refresh every")
133+ Spacer()
134+ Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
135+ ForEach(RefreshInterval.options, id: \.self) { minutes in
136+ Text("\(minutes)m").tag(minutes)
137+ }
138+ }
139+ .pickerStyle(.menu)
140+ .labelsHidden()
141+ .fixedSize()
142+ }
143+
261144 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
262145 .toggleStyle(.switch)
146+ }
147+ }
148+
149+ // MARK: - Footer
150+
151+ private var footer: some View {
152+ VStack(alignment: .leading, spacing: 10) {
153+ Divider()
263154
264− // Links
265155 HStack {
266− if let url = URL(string: "https://hyper.charm.land") {
267− Button("Open hyper.charm.land") {
268− NSWorkspace.shared.open(url)
269− }
156+ Button(action: { viewModel.refresh() }) {
157+ Label("Refresh", systemImage: "arrow.clockwise")
270158 }
159+ .disabled(viewModel.isLoading)
160+
271161 Spacer()
162+
163+ Button("Quit") {
164+ NSApplication.shared.terminate(nil)
165+ }
166+ .keyboardShortcut("q")
272167 }
273168
274− Divider()
169+ Link("hyper.charm.land", destination: Self.websiteURL)
170+ .font(.caption2)
171+ .foregroundColor(.secondary)
172+ .frame(maxWidth: .infinity, alignment: .center)
173+ }
174+ }
175+}
176+
177+// MARK: - Sparkline
178+
179+/// A minimal line chart of recent balances: no axes, no fill, no labels.
180+/// Points are spaced evenly by index rather than by timestamp, which keeps the
181+/// shape readable when fetches are irregular (wake-ups, manual refreshes).
182+struct Sparkline: View {
183+ let values: [Int]
184+
185+ /// Keeps the stroke from being clipped at the top and bottom edges.
186+ private let verticalInset: CGFloat = 2
275187
276− // Quit
277− Button("Quit HyperCredits") {
278− NSApplication.shared.terminate(nil)
188+ var body: some View {
189+ GeometryReader { geometry in
190+ Path { path in
191+ guard values.count >= 2 else { return }
192+
193+ let size = geometry.size
194+ let minValue = values.min() ?? 0
195+ let maxValue = values.max() ?? 0
196+ let range = Double(maxValue - minValue)
197+
198+ let stepX = size.width / CGFloat(values.count - 1)
199+ let usableHeight = max(size.height - verticalInset * 2, 1)
200+
201+ for (index, value) in values.enumerated() {
202+ // A flat series has no range to normalize against; center it.
203+ let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
204+ let point = CGPoint(
205+ x: CGFloat(index) * stepX,
206+ y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
207+ )
208+ if index == 0 {
209+ path.move(to: point)
210+ } else {
211+ path.addLine(to: point)
212+ }
213+ }
279214 }
280− .keyboardShortcut("q")
215+ .stroke(
216+ Color.secondary.opacity(0.5),
217+ style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
218+ )
281219 }
282− .padding(16)
283− .frame(width: 280)
284− .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
285220 }
286221}
HyperCreditsMenubar/ViewModel.swiftnew file+344
@@ -0,0 +1,344 @@
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("com.ilyakooo0.hyper-credits-menubar.refreshIntervalDidChange")
11+}
12+
13+/// The direction the balance has moved over the most recent history points.
14+enum BalanceTrend {
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 balance observation.
33+struct BalancePoint: Equatable {
34+ let date: Date
35+ let balance: Int
36+}
37+
38+/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
39+/// the app delegate can read the options without hopping onto the main actor.
40+enum RefreshInterval {
41+ /// `UserDefaults` key for the refresh period, in minutes.
42+ static let defaultsKey = "refreshIntervalMinutes"
43+
44+ /// The intervals offered in the settings picker.
45+ static let options = [1, 5, 15, 30]
46+
47+ /// Used when nothing valid is stored.
48+ static let defaultMinutes = 5
49+
50+ /// Reads the stored interval, falling back to the default when unset or when
51+ /// the stored value isn't one we offer.
52+ static func stored(in defaults: UserDefaults = .standard) -> Int {
53+ let minutes = defaults.integer(forKey: defaultsKey)
54+ guard options.contains(minutes) else { return defaultMinutes }
55+ return minutes
56+ }
57+}
58+
59+/// The view model that drives the menu bar display and the popover content.
60+@MainActor
61+final class ViewModel: ObservableObject {
62+ /// The last known balance. `nil` when there has never been a successful fetch.
63+ @Published var balance: Int?
64+
65+ /// `true` while a network request is in flight.
66+ @Published var isLoading = false
67+
68+ /// A human-readable error message, shown when the balance can't be fetched.
69+ @Published var errorMessage: String?
70+
71+ /// The API key entered by the user (loaded from Keychain on init).
72+ @Published var apiKeyInput: String = ""
73+
74+ /// Timestamp of the last successful balance fetch.
75+ @Published var lastUpdated: Date?
76+
77+ /// Brief "✓ Saved" confirmation shown after saving the API key.
78+ @Published var savedConfirmation: Bool = false
79+
80+ /// Brief "✓ Copied" confirmation shown after copying the balance.
81+ @Published var copiedConfirmation: Bool = false
82+
83+ /// Successful balance observations from the last 24 hours, oldest first.
84+ @Published private(set) var history: [BalancePoint] = []
85+
86+ /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
87+ /// changing it posts `.refreshIntervalDidChange` so the app delegate can
88+ /// restart its timer.
89+ @Published var refreshIntervalMinutes: Int {
90+ didSet {
91+ guard refreshIntervalMinutes != oldValue else { return }
92+ defaults.set(refreshIntervalMinutes, forKey: RefreshInterval.defaultsKey)
93+ NotificationCenter.default.post(name: .refreshIntervalDidChange, object: nil)
94+ }
95+ }
96+
97+ /// Whether launch-at-login is enabled.
98+ @Published var launchAtLogin: Bool {
99+ didSet {
100+ guard !isInitializingLaunchAtLogin else { return }
101+ updateLaunchAtLogin(launchAtLogin)
102+ }
103+ }
104+
105+ /// Flag to suppress the didSet during initial setup.
106+ private var isInitializingLaunchAtLogin = true
107+
108+ private let checker: CreditsChecking
109+ private let defaults: UserDefaults
110+
111+ /// The currently in-flight refresh task, if any. Used to cancel
112+ /// overlapping refreshes (timer + user click + wake).
113+ private var refreshTask: Task<Void, Never>?
114+
115+ /// Resets `copiedConfirmation`; held so rapid clicks re-arm the delay
116+ /// instead of the first click's reset firing early.
117+ private var copiedResetTask: Task<Void, Never>?
118+
119+ // MARK: - History Configuration
120+
121+ /// 24 hours at the shortest (1 minute) interval would overflow this, so the
122+ /// count cap and the age cutoff both apply; whichever bites first wins.
123+ private static let maxHistoryEntries = 300
124+ private static let historyWindow: TimeInterval = 24 * 60 * 60
125+
126+ init(checker: CreditsChecking = CreditsChecker(), defaults: UserDefaults = .standard) {
127+ self.checker = checker
128+ self.defaults = defaults
129+ apiKeyInput = KeychainHelper.load() ?? ""
130+ // Assigning in init doesn't fire didSet, so this load doesn't re-post
131+ // the change notification or write back to defaults.
132+ refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
133+ launchAtLogin = SMAppService.mainApp.status == .enabled
134+ isInitializingLaunchAtLogin = false
135+ }
136+
137+ // MARK: - Balance
138+
139+ /// Cached number formatter for balance display.
140+ private static let balanceFormatter: NumberFormatter = {
141+ let formatter = NumberFormatter()
142+ formatter.numberStyle = .decimal
143+ return formatter
144+ }()
145+
146+ /// Formats an integer with thousands grouping separator.
147+ private func formatBalance(_ value: Int) -> String {
148+ Self.balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
149+ }
150+
151+ /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
152+ var statusBarItemText: String {
153+ if isLoading && balance == nil {
154+ return "⚡…"
155+ }
156+ if let balance = balance {
157+ return "⚡\(formatBalance(balance))"
158+ }
159+ return "⚡?"
160+ }
161+
162+ /// The formatted balance string for display in the popover.
163+ var formattedBalance: String {
164+ guard let balance = balance else { return "?" }
165+ return formatBalance(balance)
166+ }
167+
168+ /// The color for the balance display based on thresholds.
169+ var balanceColor: Color {
170+ guard let balance = balance else { return .secondary }
171+ if balance >= 100 { return .green }
172+ if balance >= 10 { return .yellow }
173+ return .red
174+ }
175+
176+ /// Relative time string for the last successful update, or nil if never updated.
177+ var lastUpdatedText: String? {
178+ guard let lastUpdated = lastUpdated else { return nil }
179+ let formatter = RelativeDateTimeFormatter()
180+ formatter.unitsStyle = .short
181+ return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
182+ }
183+
184+ // MARK: - History
185+
186+ /// The last 24 hours of successful fetches, oldest first.
187+ var balanceHistory: [(date: Date, balance: Int)] {
188+ history.map { (date: $0.date, balance: $0.balance) }
189+ }
190+
191+ /// The direction of travel across the most recent handful of observations.
192+ /// `.unknown` until there are at least two points to compare.
193+ var balanceTrend: BalanceTrend {
194+ guard history.count >= 2 else { return .unknown }
195+ let recent = history.suffix(5)
196+ guard let first = recent.first, let last = recent.last else { return .unknown }
197+ if last.balance > first.balance { return .increasing }
198+ if last.balance < first.balance { return .decreasing }
199+ return .stable
200+ }
201+
202+ /// Appends an observation and drops anything older than 24 hours or beyond the cap.
203+ private func recordHistory(balance: Int, at date: Date) {
204+ history.append(BalancePoint(date: date, balance: balance))
205+
206+ let cutoff = date.addingTimeInterval(-Self.historyWindow)
207+ history.removeAll { $0.date < cutoff }
208+
209+ if history.count > Self.maxHistoryEntries {
210+ history.removeFirst(history.count - Self.maxHistoryEntries)
211+ }
212+ }
213+
214+ // MARK: - Refresh
215+
216+ /// Refreshes the balance from the API. No-op if no API key is set.
217+ /// Cancels any previously in-flight refresh to prevent races.
218+ func refresh() {
219+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
220+ guard !key.isEmpty else {
221+ balance = nil
222+ errorMessage = nil
223+ lastUpdated = nil
224+ return
225+ }
226+
227+ refreshTask?.cancel()
228+
229+ refreshTask = Task { @MainActor in
230+ isLoading = true
231+ // Don't clear errorMessage here — keep showing the last error
232+ // until we have a successful result.
233+ do {
234+ let result = try await checker.fetchBalance(apiKey: key)
235+ // Check for cancellation after the await
236+ guard !Task.isCancelled else { return }
237+ // Check for low-balance threshold crossing before updating
238+ let previousBalance = balance
239+ balance = result
240+ errorMessage = nil
241+ let now = Date()
242+ lastUpdated = now
243+ recordHistory(balance: result, at: now)
244+ isLoading = false
245+
246+ // Low-balance notification: on threshold crossing (≥10 → <10)
247+ // OR on first successful fetch if already below threshold.
248+ if let prev = previousBalance, prev >= 10, result < 10 {
249+ sendLowBalanceNotification(balance: result)
250+ } else if previousBalance == nil && result < 10 {
251+ sendLowBalanceNotification(balance: result)
252+ }
253+ } catch {
254+ // Keep the stale balance value — don't wipe it on error.
255+ // Only set balance = nil if there was never a successful fetch
256+ // (it's already nil in that case, so nothing to do).
257+ guard !Task.isCancelled else { return }
258+ errorMessage = error.localizedDescription
259+ isLoading = false
260+ }
261+ }
262+ }
263+
264+ // MARK: - Clipboard
265+
266+ /// Copies the current balance to the clipboard as plain digits (no grouping
267+ /// separator), and shows a brief "✓ Copied" confirmation.
268+ func copyBalanceToClipboard() {
269+ guard let balance = balance else { return }
270+
271+ let pasteboard = NSPasteboard.general
272+ pasteboard.clearContents()
273+ pasteboard.setString(String(balance), forType: .string)
274+
275+ copiedConfirmation = true
276+ copiedResetTask?.cancel()
277+ copiedResetTask = Task { @MainActor in
278+ try? await Task.sleep(nanoseconds: 1_500_000_000)
279+ guard !Task.isCancelled else { return }
280+ copiedConfirmation = false
281+ }
282+ }
283+
284+ // MARK: - Low Balance Notification
285+
286+ private func sendLowBalanceNotification(balance: Int) {
287+ let center = UNUserNotificationCenter.current()
288+ center.getNotificationSettings { settings in
289+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
290+ return
291+ }
292+ let content = UNMutableNotificationContent()
293+ content.title = "Low Hyper Credits"
294+ let balanceStr = Self.balanceFormatter.string(from: NSNumber(value: balance)) ?? "\(balance)"
295+ content.body = "Low Hyper credits: \(balanceStr) remaining"
296+ content.sound = .default
297+
298+ let request = UNNotificationRequest(
299+ identifier: "low-balance-alert",
300+ content: content,
301+ trigger: nil
302+ )
303+ center.add(request)
304+ }
305+ }
306+
307+ // MARK: - API Key
308+
309+ /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
310+ /// Shows "✓ Saved" on success, or an error message if the keychain rejected the key.
311+ func saveAPIKey() {
312+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
313+ if key.isEmpty {
314+ KeychainHelper.delete()
315+ balance = nil
316+ errorMessage = nil
317+ lastUpdated = nil
318+ } else {
319+ let saved = KeychainHelper.save(key)
320+ guard saved else {
321+ errorMessage = "Could not save API key to Keychain. It may be locked."
322+ return
323+ }
324+ refresh()
325+ }
326+
327+ // Show "✓ Saved" confirmation, auto-reset after 2 seconds
328+ savedConfirmation = true
329+ Task { @MainActor in
330+ try? await Task.sleep(nanoseconds: 2_000_000_000)
331+ savedConfirmation = false
332+ }
333+ }
334+
335+ // MARK: - Launch at Login
336+
337+ func updateLaunchAtLogin(_ enabled: Bool) {
338+ if enabled {
339+ try? SMAppService.mainApp.register()
340+ } else {
341+ try? SMAppService.mainApp.unregister()
342+ }
343+ }
344+}
HyperCreditsMenubarTests/CreditsCheckerTests.swift+458
@@ -1,8 +1,28 @@
11import XCTest
2+import Foundation
3+import Security
24@testable import HyperCreditsMenubar
35
46final class CreditsCheckerTests: XCTestCase {
57
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+
626 // MARK: - BalanceResponse Decoding
727
828 func testDecodeValidBalance() throws {
@@ -35,6 +55,196 @@ final class CreditsCheckerTests: XCTestCase {
3555 XCTAssertEqual(response.balance, -5)
3656 }
3757
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 testDoesNotRetryOnUnauthorized() async {
182+ StubURLProtocol.enqueue(.status(401, Data()))
183+
184+ do {
185+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-bad")
186+ XCTFail("Expected invalidAPIKey error")
187+ } catch CreditsError.invalidAPIKey {
188+ // expected
189+ } catch {
190+ XCTFail("Unexpected error: \(error)")
191+ }
192+
193+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
194+ }
195+
196+ func testDoesNotRetryOnClientError() async {
197+ StubURLProtocol.enqueue(.status(404, Data()))
198+
199+ do {
200+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
201+ XCTFail("Expected requestFailed error")
202+ } catch CreditsError.requestFailed(let message) {
203+ XCTAssertEqual(message, "HTTP 404")
204+ } catch {
205+ XCTFail("Unexpected error: \(error)")
206+ }
207+
208+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
209+ }
210+
211+ func testDoesNotRetryOnDecodingFailure() async {
212+ StubURLProtocol.enqueue(.status(200, #"{"nope": true}"#.data(using: .utf8)!))
213+
214+ do {
215+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
216+ XCTFail("Expected decodingFailed error")
217+ } catch CreditsError.decodingFailed {
218+ // expected
219+ } catch {
220+ XCTFail("Unexpected error: \(error)")
221+ }
222+
223+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
224+ }
225+
226+ func testSucceedsWithoutRetryOnFirstAttempt() async throws {
227+ StubURLProtocol.enqueue(.status(200, #"{"balance": 99}"#.data(using: .utf8)!))
228+
229+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
230+
231+ XCTAssertEqual(balance, 99)
232+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
233+ }
234+
235+ func testBackoffGrowsExponentially() async {
236+ StubURLProtocol.enqueue(.status(500, Data()))
237+
238+ let clock = ContinuousClock()
239+ let start = clock.now
240+ _ = try? await makeStubbedChecker(retryBaseDelay: .milliseconds(20)).fetchBalance(apiKey: "sk-test")
241+ let elapsed = clock.now - start
242+
243+ // Two retries wait 20ms then 40ms. Only a lower bound is asserted: sleeps can
244+ // overshoot under load, but they never return early.
245+ XCTAssertGreaterThanOrEqual(elapsed, .milliseconds(60))
246+ }
247+
38248 // MARK: - CreditsChecker Errors
39249
40250 func testEmptyAPIKeyThrowsNoAPIKey() async {
@@ -49,6 +259,11 @@ final class CreditsCheckerTests: XCTestCase {
49259 }
50260 }
51261
262+ func testEmptyAPIKeyDoesNotHitTheNetwork() async {
263+ _ = try? await makeStubbedChecker().fetchBalance(apiKey: "")
264+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
265+ }
266+
52267 // MARK: - CreditsError cases
53268
54269 func testInvalidAPIKeyErrorDescription() {
@@ -92,6 +307,249 @@ final class CreditsCheckerTests: XCTestCase {
92307 }
93308}
94309
310+// MARK: - KeychainStore
311+
312+/// These tests drive `KeychainStore` with stubbed `SecItem*` calls rather than
313+/// `KeychainHelper`, which would read and write the user's real login keychain entry —
314+/// and `save()` deletes before adding, so a test run would destroy a real API key.
315+final class KeychainStoreTests: XCTestCase {
316+
317+ private func makeStore(backing: FakeKeychain) -> KeychainStore {
318+ KeychainStore(
319+ service: "com.example.tests",
320+ account: "test-account",
321+ addItem: backing.add,
322+ copyMatching: backing.copyMatching,
323+ deleteItem: backing.delete
324+ )
325+ }
326+
327+ func testSaveReturnsTrueOnSuccess() {
328+ let store = makeStore(backing: FakeKeychain())
329+ XCTAssertTrue(store.save("sk-test"))
330+ }
331+
332+ func testSaveReturnsFalseWhenItemCannotBeAdded() {
333+ let backing = FakeKeychain()
334+ backing.addStatus = errSecInteractionNotAllowed // e.g. the keychain is locked
335+ let store = makeStore(backing: backing)
336+
337+ XCTAssertFalse(store.save("sk-test"))
338+ XCTAssertNil(store.load(), "A rejected save must not leave a value behind")
339+ }
340+
341+ func testSaveReturnsFalseWhenKeychainIsUnavailable() {
342+ let backing = FakeKeychain()
343+ backing.addStatus = errSecNotAvailable
344+ XCTAssertFalse(makeStore(backing: backing).save("sk-test"))
345+ }
346+
347+ func testSaveThenLoadRoundTrips() {
348+ let store = makeStore(backing: FakeKeychain())
349+
350+ XCTAssertTrue(store.save("sk-round-trip"))
351+ XCTAssertEqual(store.load(), "sk-round-trip")
352+ }
353+
354+ func testSaveOverwritesExistingKey() {
355+ let backing = FakeKeychain()
356+ let store = makeStore(backing: backing)
357+
358+ XCTAssertTrue(store.save("sk-first"))
359+ XCTAssertTrue(store.save("sk-second"))
360+
361+ XCTAssertEqual(store.load(), "sk-second")
362+ // Overwriting means deleting first; otherwise the add would hit errSecDuplicateItem.
363+ XCTAssertEqual(backing.deleteCallCount, 2)
364+ }
365+
366+ func testSaveEmptyStringIsStored() {
367+ // An empty string is still valid UTF-8, so this is a successful save of an empty
368+ // value — not a failure. The failure path is the keychain rejecting the add.
369+ let store = makeStore(backing: FakeKeychain())
370+
371+ XCTAssertTrue(store.save(""))
372+ XCTAssertEqual(store.load(), "")
373+ }
374+
375+ func testLoadReturnsNilWhenNothingStored() {
376+ XCTAssertNil(makeStore(backing: FakeKeychain()).load())
377+ }
378+
379+ func testLoadReturnsNilWhenKeychainErrors() {
380+ let backing = FakeKeychain()
381+ backing.copyStatus = errSecInteractionNotAllowed
382+ XCTAssertNil(makeStore(backing: backing).load())
383+ }
384+
385+ func testDeleteRemovesStoredKey() {
386+ let backing = FakeKeychain()
387+ let store = makeStore(backing: backing)
388+
389+ XCTAssertTrue(store.save("sk-test"))
390+ XCTAssertTrue(store.delete())
391+ XCTAssertNil(store.load())
392+ }
393+
394+ func testDeleteOfMissingItemSucceeds() {
395+ // Deleting something that was never there is not an error.
396+ XCTAssertTrue(makeStore(backing: FakeKeychain()).delete())
397+ }
398+
399+ func testDeleteReturnsFalseOnRealFailure() {
400+ let backing = FakeKeychain()
401+ backing.deleteStatus = errSecInteractionNotAllowed
402+ XCTAssertFalse(makeStore(backing: backing).delete())
403+ }
404+}
405+
406+// MARK: - Fake keychain
407+
408+/// An in-memory stand-in for the `SecItem*` API, with overridable statuses so the failure
409+/// paths can be exercised. Never touches the real keychain.
410+final class FakeKeychain {
411+ /// When set, `add` fails with this status instead of storing.
412+ var addStatus: OSStatus?
413+ /// When set, `copyMatching` fails with this status instead of reading.
414+ var copyStatus: OSStatus?
415+ /// When set, `delete` fails with this status instead of removing.
416+ var deleteStatus: OSStatus?
417+
418+ private(set) var addCallCount = 0
419+ private(set) var deleteCallCount = 0
420+
421+ private var storage: [String: Data] = [:]
422+
423+ func add(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
424+ addCallCount += 1
425+ if let addStatus { return addStatus }
426+
427+ let attributes = Self.attributes(from: query)
428+ guard let account = attributes[kSecAttrAccount as String] as? String,
429+ let data = attributes[kSecValueData as String] as? Data
430+ else { return errSecParam }
431+
432+ guard storage[account] == nil else { return errSecDuplicateItem }
433+ storage[account] = data
434+ return errSecSuccess
435+ }
436+
437+ func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
438+ if let copyStatus { return copyStatus }
439+
440+ let attributes = Self.attributes(from: query)
441+ guard let account = attributes[kSecAttrAccount as String] as? String,
442+ let data = storage[account]
443+ else { return errSecItemNotFound }
444+
445+ result?.pointee = data as CFData
446+ return errSecSuccess
447+ }
448+
449+ func delete(_ query: CFDictionary) -> OSStatus {
450+ deleteCallCount += 1
451+ if let deleteStatus { return deleteStatus }
452+
453+ let attributes = Self.attributes(from: query)
454+ guard let account = attributes[kSecAttrAccount as String] as? String,
455+ storage.removeValue(forKey: account) != nil
456+ else { return errSecItemNotFound }
457+
458+ return errSecSuccess
459+ }
460+
461+ private static func attributes(from query: CFDictionary) -> [String: Any] {
462+ (query as NSDictionary) as? [String: Any] ?? [:]
463+ }
464+}
465+
466+// MARK: - Stub URLProtocol
467+
468+/// Serves canned responses to `URLSession` so the retry logic can be driven without a network.
469+final class StubURLProtocol: URLProtocol {
470+
471+ enum Outcome {
472+ case status(Int, Data)
473+ case failure(Error)
474+ }
475+
476+ private enum StubError: Error {
477+ case noOutcomeConfigured
478+ case missingURL
479+ }
480+
481+ private static let lock = NSLock()
482+ private static var outcomes: [Outcome] = []
483+ private static var lastOutcome: Outcome?
484+ private static var requests: [URLRequest] = []
485+
486+ /// Queues the outcomes for successive requests. Once the queue is drained the final
487+ /// outcome repeats, so a persistent failure needs only one entry.
488+ static func enqueue(_ outcomes: Outcome...) {
489+ lock.lock()
490+ defer { lock.unlock() }
491+ Self.outcomes.append(contentsOf: outcomes)
492+ }
493+
494+ static func reset() {
495+ lock.lock()
496+ defer { lock.unlock() }
497+ outcomes = []
498+ lastOutcome = nil
499+ requests = []
500+ }
501+
502+ static var recordedRequests: [URLRequest] {
503+ lock.lock()
504+ defer { lock.unlock() }
505+ return requests
506+ }
507+
508+ static var requestCount: Int {
509+ recordedRequests.count
510+ }
511+
512+ /// Records the request and pops the outcome that should answer it.
513+ private static func nextOutcome(for request: URLRequest) -> Outcome {
514+ lock.lock()
515+ defer { lock.unlock() }
516+ requests.append(request)
517+
518+ if !outcomes.isEmpty {
519+ lastOutcome = outcomes.removeFirst()
520+ }
521+ return lastOutcome ?? .failure(StubError.noOutcomeConfigured)
522+ }
523+
524+ override class func canInit(with request: URLRequest) -> Bool { true }
525+
526+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
527+
528+ override func startLoading() {
529+ switch Self.nextOutcome(for: request) {
530+ case .failure(let error):
531+ client?.urlProtocol(self, didFailWithError: error)
532+
533+ case .status(let statusCode, let body):
534+ guard let url = request.url else {
535+ client?.urlProtocol(self, didFailWithError: StubError.missingURL)
536+ return
537+ }
538+ let response = HTTPURLResponse(
539+ url: url,
540+ statusCode: statusCode,
541+ httpVersion: "HTTP/1.1",
542+ headerFields: ["Content-Type": "application/json"]
543+ )!
544+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
545+ client?.urlProtocol(self, didLoad: body)
546+ client?.urlProtocolDidFinishLoading(self)
547+ }
548+ }
549+
550+ override func stopLoading() {}
551+}
552+
95553// MARK: - Mock CreditsChecker
96554
97555/// A mock `CreditsChecking` implementation for testing the ViewModel.
README.md+33−12
@@ -5,12 +5,16 @@ A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.ch
55## Features
66
77- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
8−- 🔄 **Auto-refresh** — fetches your balance every 5 minutes
8+- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
9+- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
10+- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
11+- 🖱️ **Right-click menu** — right-click the menu bar icon for quick access to Refresh, Open hyper.charm.land, and Quit
12+- 🔔 **Low-balance notifications** — alerts you when your balance drops below 10 credits, including on the very first fetch if you are already below the threshold
913- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
1014- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
1115- 🔐 **Secure input** — API key field is masked
1216- 🚀 **Launch at login** — optional, using `SMAppService`
13−- 📋 **Quick link** — opens [hyper.charm.land](https://hyper.charm.land) from the menu
17+- 🔑 **Get key link** — quick link to [hyper.charm.land](https://hyper.charm.land) right next to the API key field
1418- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
1519
1620## Installation
@@ -35,7 +39,7 @@ brew install --cask ilyakooo0/tap/hyper-credits-menubar
35393. Unzip and move `HyperCreditsMenubar.app` to `/Applications`
36404. Launch the app — it will appear in your menu bar as ⚡
3741
38−> **Note:** Releases are currently unsigned. On first launch, right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**.
42+> **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.
3943
4044### Set your API key
4145
@@ -46,6 +50,20 @@ brew install --cask ilyakooo0/tap/hyper-credits-menubar
4650
4751You can get your API key from [hyper.charm.land](https://hyper.charm.land).
4852
53+## Settings
54+
55+Everything is configured from the popover — click the ⚡ icon in the menu bar.
56+
57+| Setting | Where | Notes |
58+| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ |
59+| **API key** | **API Key** field → **Save** | Stored in the macOS Keychain. Clearing the field and saving deletes the stored key. |
60+| **Launch at login** | **Launch at Login** toggle | Registers the app with `SMAppService`. |
61+| **Notifications** | System permission prompt | macOS asks on first launch (only if not already decided). Required for low-balance alerts. |
62+| **Refresh now** | **Refresh** button | Forces an immediate fetch, on top of the automatic refresh. |
63+| **Refresh interval**| **Refresh every** picker | Choose 1m, 5m, 15m, or 30m. Defaults to 5m. Stored in `UserDefaults` and persisted across launches. |
64+
65+The app also refreshes automatically when your Mac wakes from sleep.
66+
4967## Build from source
5068
5169### Prerequisites
@@ -103,16 +121,17 @@ The version is derived automatically from the commit timestamp in the GitHub Act
103121hyper-credits-menubar/
104122├── project.yml # xcodegen project definition
105123├── HyperCreditsMenubar/
106−│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer
124+│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer, sleep/wake
125+│ ├── ViewModel.swift # Observable state: balance, refresh, notifications
126+│ ├── MenuView.swift # SwiftUI popover view
107127│ ├── CreditsChecker.swift # API client (GET /v1/credits)
108−│ ├── MenuView.swift # SwiftUI popover view + ViewModel
109128│ ├── KeychainHelper.swift # Keychain wrapper for API key
110129│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
111130│ ├── Info.plist # LSUIElement=true (agent app)
112131│ ├── HyperCreditsMenubar.entitlements # Network client entitlements
113132│ └── Assets.xcassets/ # App icon, accent color
114133├── HyperCreditsMenubarTests/
115−│ ├── CreditsCheckerTests.swift # JSON decoding tests
134+│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
116135│ └── VersionFormatterTests.swift # Version formatting tests
117136├── .github/workflows/
118137│ └── release.yml # Build + release on push to master
@@ -123,12 +142,14 @@ hyper-credits-menubar/
123142
124143## How it works
125144
126−1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`.
127−2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView`.
128−3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer {apiKey}` and decodes `{"balance": <int>}`.
129−4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework.
130−5. **Timer**: A `Timer` fires every 5 minutes to refresh the balance.
131−6. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
145+1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`. Left-click toggles the popover; right-click opens a context menu (Refresh, Open hyper.charm.land, Quit).
146+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView` with the balance, sparkline, API key field, and settings.
147+3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer <key>`, retries transient failures (5xx, timeouts) with exponential backoff, and decodes `{"balance": <int>}` (tolerating fractional values by rounding).
148+4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework. `save()` returns a `Bool` indicating success.
149+5. **Timer**: A `Timer` fires at the user-selected interval (1m / 5m / 15m / 30m, default 5m) to refresh the balance; the app also refreshes on wake from sleep. Changing the interval in the popover restarts the timer immediately.
150+6. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
151+7. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
152+8. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
132153
133154## Credits
134155
project.yml+25
@@ -1,6 +1,14 @@
1+# xcodegen project definition — run `xcodegen generate` to (re)create
2+# HyperCreditsMenubar.xcodeproj. The .xcodeproj is not committed; this file is
3+# the source of truth for project structure and build settings.
14name: HyperCreditsMenubar
5+
26options:
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.
39 bundleIdPrefix: com.ilyakooo0
10+ # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
11+ # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
412 deploymentTarget:
513 macOS: "14.0"
614 createIntermediateGroups: true
@@ -9,20 +17,29 @@ options:
917settings:
1018 base:
1119 SWIFT_VERSION: "5.9"
20+ # Local/CI builds are unsigned at compile time. Release builds get an
21+ # ad-hoc signature applied after archiving (see .github/workflows/release.yml).
1222 DEVELOPMENT_TEAM: ""
1323 CODE_SIGN_IDENTITY: ""
1424 CODE_SIGNING_REQUIRED: "NO"
1525 CODE_SIGNING_ALLOWED: "NO"
26+ # Version defaults for local builds only. The release workflow overwrites
27+ # these (and the CFBundle* keys below) with a version derived from the
28+ # commit timestamp, formatted YYYY.MM.DD.HHHH — so editing them by hand
29+ # has no effect on what ships.
1630 MARKETING_VERSION: "1.0.0"
1731 CURRENT_PROJECT_VERSION: "1"
1832
1933targets:
34+ # ── The menu bar app ───────────────────────────────────────────────────
2035 HyperCreditsMenubar:
2136 type: application
2237 platform: macOS
2338 sources:
2439 - path: HyperCreditsMenubar
2540 excludes:
41+ # Referenced explicitly via INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS,
42+ # so they must not also be compiled in as resources.
2643 - "Info.plist"
2744 - "*.entitlements"
2845 resources:
@@ -33,8 +50,10 @@ targets:
3350 CFBundleName: HyperCreditsMenubar
3451 CFBundleDisplayName: Hyper Credits
3552 CFBundleIdentifier: com.ilyakooo0.hyper-credits-menubar
53+ # Overwritten by CI — see the note on MARKETING_VERSION above.
3654 CFBundleVersion: "1"
3755 CFBundleShortVersionString: "1.0.0"
56+ # Agent app: no dock icon and no main window; it lives in the menu bar.
3857 LSUIElement: true
3958 LSApplicationCategoryType: public.app-category.utilities
4059 NSHighResolutionCapable: true
@@ -43,6 +62,9 @@ targets:
4362 PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar
4463 PRODUCT_NAME: HyperCreditsMenubar
4564 INFOPLIST_FILE: HyperCreditsMenubar/Info.plist
65+ # Grants the outgoing-network-connection entitlement needed to reach
66+ # the Hyper API. Stripped in CI's archive step (unsigned builds can't
67+ # carry entitlements).
4668 CODE_SIGN_ENTITLEMENTS: HyperCreditsMenubar/HyperCreditsMenubar.entitlements
4769 ENABLE_HARDENED_RUNTIME: YES
4870 MACOSX_DEPLOYMENT_TARGET: "14.0"
@@ -50,6 +72,9 @@ targets:
5072 - "$(inherited)"
5173 - "@executable_path/../Frameworks"
5274
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.
5378 HyperCreditsMenubarTests:
5479 type: bundle.unit-test
5580 platform: macOS