on master help sign in
fix: comprehensive bug fixes and UX improvements

Bugs / Correctness: - CI tests now gating: removed continue-on-error and || true from test step - Error no longer wipes last known balance: stale balance preserved on network error, errorMessage set instead, lastUpdated timestamp tracked - 401 handling: CreditsChecker throws CreditsError.invalidAPIKey on HTTP 401 with message pointing to hyper.charm.land; other 4xx still use requestFailed - Request timeout: custom URLSessionConfiguration with 10s request / 15s resource timeout instead of URLSession.shared - launchAtLogin didSet no longer fires on init: isInitializingLaunchAtLogin flag suppresses updateLaunchAtLogin during initial SMAppService status read - Sleep/wake refresh: register NSWorkspace.willSleepNotification and didWakeNotification, trigger viewModel.refresh() on wake - Version applied to built app in CI: sed injects VERSION into project.yml (MARKETING_VERSION, CFBundleShortVersionString, CURRENT_PROJECT_VERSION, CFBundleVersion) before xcodegen generate UX Improvements: - Last updated time: lastUpdated property + relative time display in popover ("Updated 2m ago") using RelativeDateTimeFormatter - Loading state in menu bar: shows ⚡… while loading with no balance, ⚡? when idle with no balance, ⚡{balance} when available; CombineLatest on and drives updates - Saved confirmation: "✓ Saved" appears for 2s after saveAPIKey() via savedConfirmation published property with Task.sleep auto-reset - Balance formatted with thousands separator via NumberFormatter in both menu bar text and popover display - Low-balance notification: UNUserNotification when balance crosses from >=10 to <10, notification permission requested on first launch Architecture / CI: - CreditsChecker injectable: CreditsChecking protocol with fetchBalance(apiKey:), CreditsChecker conforms, ViewModel takes CreditsChecking via init injection (defaults to CreditsChecker()) - App sandbox enabled: com.apple.security.app-sandbox set to true in entitlements - Hardened runtime enabled in release: removed ENABLE_HARDENED_RUNTIME=NO from archive step, keeps default YES from project.yml Tests: - Added test for CreditsError.invalidAPIKey errorDescription - Added test for CreditsError.invalidAPIKey equatable conformance - Added test for CreditsChecking protocol conformance - Added MockCreditsChecker test double and tests for it

commitd3ea866742db80d251318c0480988f23f5a9af7e authoriko <mail@iko.soy> dateSat 11 Jul 2026, 16:09 (3 months ago) parentb2430c2
6 files changed+296−284
.github/workflows/release.yml+12−5 HyperCreditsMenubar/CreditsChecker.swift+22−4 HyperCreditsMenubar/HyperCreditsApp.swift+46−97 HyperCreditsMenubar/HyperCreditsMenubar.entitlements+1−1 HyperCreditsMenubar/MenuView.swift+153−177 HyperCreditsMenubarTests/CreditsCheckerTests.swift+62
.github/workflows/release.yml+12−5
@@ -34,13 +34,22 @@ jobs:
3434 - name: Install xcodegen
3535 run: brew install xcodegen
3636
37+ # ── Inject version into project.yml ───────────────────────────────
38+ - name: Inject version into project.yml
39+ 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"
45+ grep -E 'MARKETING_VERSION|CFBundleShortVersionString|CURRENT_PROJECT_VERSION|CFBundleVersion' project.yml
46+
3747 # ── Generate Xcode project ────────────────────────────────────────
3848 - name: Generate Xcode project
3949 run: xcodegen generate
4050
4151 # ── Run tests ─────────────────────────────────────────────────────
4252 - name: Run tests
43− continue-on-error: true
4453 run: |
4554 xcodebuild test \
4655 -scheme HyperCreditsMenubar \
@@ -49,8 +58,7 @@ jobs:
4958 CODE_SIGNING_ALLOWED=NO \
5059 CODE_SIGNING_REQUIRED=NO \
5160 CODE_SIGN_IDENTITY="" \
52− DEVELOPMENT_TEAM="" \
53− || true
61+ DEVELOPMENT_TEAM=""
5462
5563 # ── Archive ───────────────────────────────────────────────────────
5664 - name: Archive
@@ -64,8 +72,7 @@ jobs:
6472 CODE_SIGNING_REQUIRED=NO \
6573 CODE_SIGN_IDENTITY="" \
6674 DEVELOPMENT_TEAM="" \
67− CODE_SIGN_ENTITLEMENTS="" \
68− ENABLE_HARDENED_RUNTIME=NO
75+ CODE_SIGN_ENTITLEMENTS=""
6976
7077 # ── Export .app from archive ──────────────────────────────────────
7178 - name: Export app from archive
HyperCreditsMenubar/CreditsChecker.swift+22−4
@@ -8,6 +8,7 @@ struct BalanceResponse: Decodable, Equatable {
88/// Errors that can occur while fetching the balance.
99enum CreditsError: LocalizedError, Equatable {
1010 case noAPIKey
11+ case invalidAPIKey
1112 case invalidURL
1213 case requestFailed(String)
1314 case decodingFailed(String)
@@ -16,6 +17,8 @@ enum CreditsError: LocalizedError, Equatable {
1617 switch self {
1718 case .noAPIKey:
1819 return "No API key set. Enter your Hyper API key in settings."
20+ case .invalidAPIKey:
21+ return "Invalid API key. Check your key at hyper.charm.land"
1922 case .invalidURL:
2023 return "The server URL is invalid."
2124 case .requestFailed(let message):
@@ -26,18 +29,30 @@ enum CreditsError: LocalizedError, Equatable {
2629 }
2730}
2831
32+/// Protocol abstraction for fetching credit balance, enabling test injection.
33+protocol CreditsChecking {
34+ func fetchBalance(apiKey: String) async throws -> Int
35+}
36+
2937/// Client for the Hyper (Charm) credits API.
30−final class CreditsChecker {
38+final class CreditsChecker: CreditsChecking {
3139 static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
3240
3341 private let session: URLSession
3442
35− init(session: URLSession = .shared) {
36− self.session = session
43+ init(session: URLSession? = nil) {
44+ if let session {
45+ self.session = session
46+ } else {
47+ let config = URLSessionConfiguration.default
48+ config.timeoutIntervalForRequest = 10
49+ config.timeoutIntervalForResource = 15
50+ self.session = URLSession(configuration: config)
51+ }
3752 }
3853
3954 /// Fetches the current credit balance for the given API key.
40− /// - Parameter apiKey: The Hyper API key (starts with `"sk-"`).
55+ /// - Parameter apiKey: A Hyper API key (starts with `"sk-"`).
4156 /// - Returns: The balance as an integer.
4257 /// - Throws: `CreditsError` on failure.
4358 func fetchBalance(apiKey: String) async throws -> Int {
@@ -54,6 +69,9 @@ final class CreditsChecker {
5469 throw CreditsError.requestFailed("Invalid response type")
5570 }
5671 guard (200...299).contains(httpResponse.statusCode) else {
72+ if httpResponse.statusCode == 401 {
73+ throw CreditsError.invalidAPIKey
74+ }
5775 throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
5876 }
5977 do {
HyperCreditsMenubar/HyperCreditsApp.swift+46−97
@@ -1,9 +1,9 @@
11import SwiftUI
22import Combine
3−import AppKit
3+import UserNotifications
44
55/// Main app delegate that manages the menu bar item, the refresh timer,
6−/// and the SwiftUI popover hosting `MenuView`.
6+/// the SwiftUI popover hosting `MenuView`, and sleep/wake handling.
77final class AppDelegate: NSObject, NSApplicationDelegate {
88 private var statusItem: NSStatusItem!
99 private var popover: NSPopover!
@@ -12,26 +12,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
1212 /// The shared view model. Shared between the menu bar and the popover.
1313 let viewModel = ViewModel()
1414
15− /// Combine cancellables for observing balance / loading / colour changes.
15+ /// Combine cancellables for observing published changes.
1616 private var statusTextCancellable: AnyCancellable?
17− private var statusColorCancellable: AnyCancellable?
18− private var loadingCancellable: AnyCancellable?
19− /// Drives the subtle "pulse" of the menu bar icon while loading.
20− private var pulseTimer: Timer?
21− private var pulseStep: Int = 0
2217
2318 private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
2419
2520 // MARK: - Lifecycle
2621
2722 func applicationDidFinishLaunching(_ notification: Notification) {
23+ // Request notification permission for low-balance alerts
24+ requestNotificationPermission()
25+
2826 // Create status bar item
2927 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
3028
3129 if let button = statusItem.button {
32− button.image = statusBarIcon(color: .secondary, opacity: 1.0)
33− button.image?.isTemplate = false
34− button.title = "?"
30+ button.image = nil
31+ button.title = "⚡?"
3532 button.target = self
3633 button.action = #selector(togglePopover(_:))
3734 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -42,46 +39,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4239 popover.behavior = .transient
4340 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4441
45− // Observe balance changes to update the menu bar title.
46− // Shows "⚡…" while loading (no balance yet), otherwise the number.
47− statusTextCancellable = viewModel.$isLoading
48− .combineLatest(viewModel.$balance)
49− .map { [weak viewModel] isLoading, balance in
50− if isLoading && balance == nil { return "…" }
51− return viewModel?.statusBarItemText ?? "⚡?"
52− }
42+ // Observe published changes (balance + isLoading) to update the menu bar title.
43+ // CombineLatest fires when either published property changes.
44+ statusTextCancellable = Publishers.CombineLatest(viewModel.$balance, viewModel.$isLoading)
45+ .map { [weak viewModel] _, _ in viewModel?.statusBarItemText ?? "⚡?" }
5346 .removeDuplicates()
5447 .receive(on: RunLoop.main)
5548 .sink { [weak self] text in
56− // statusBarItemText is "⚡NN"; we split off the leading ⚡ and
57− // keep only the number/`?` as the title, rendering the bolt as
58− // a tinted SF Symbol image.
59− let stripped = text.hasPrefix("⚡") ? String(text.dropFirst()) : text
60− self?.statusItem.button?.title = stripped
49+ self?.statusItem.button?.title = text
6150 }
6251
63− // Observe balance to tint the status bar icon by balance colour.
64− statusColorCancellable = viewModel.$balance
65− .map { [weak viewModel] _ in viewModel?.balanceColor ?? .secondary }
66− .removeDuplicates()
67− .receive(on: RunLoop.main)
68− .sink { [weak self] color in
69− guard let self else { return }
70− self.statusItem.button?.image = self.statusBarIcon(color: color, opacity: 1.0)
71− self.statusItem.button?.image?.isTemplate = false
72− }
73−
74− // Observe loading state to start/stop the icon pulse.
75− loadingCancellable = viewModel.$isLoading
76− .removeDuplicates()
77− .receive(on: RunLoop.main)
78− .sink { [weak self] isLoading in
79− if isLoading {
80− self?.startPulse()
81− } else {
82− self?.stopPulse()
83− }
84− }
52+ // Register for sleep/wake notifications
53+ registerSleepWakeNotifications()
8554
8655 // Initial fetch
8756 viewModel.refresh()
@@ -97,64 +66,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
9766 func applicationWillTerminate(_ notification: Notification) {
9867 refreshTimer?.invalidate()
9968 statusTextCancellable?.cancel()
100− statusColorCancellable?.cancel()
101− loadingCancellable?.cancel()
102− stopPulse()
10369 }
10470
105− // MARK: - Status bar icon
106−
107− /// Renders `bolt.fill` as an NSImage tinted with the balance colour.
108− /// The number is kept as the button `title`; this image is the icon only.
109− private func statusBarIcon(color: Color, opacity: Double) -> NSImage? {
110− guard let baseImage = NSImage(
111− systemSymbolName: "bolt.fill",
112− accessibilityDescription: "Hyper credits"
113− ) else { return nil }
114−
115− // Lock a consistent point-size so the tint rasterisation is crisp.
116− let targetSize = NSSize(width: 16, height: 16)
117− let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
118− let symbol = baseImage.withSymbolConfiguration(config) ?? baseImage
119−
120− // Tint by filling with colour and compositing the symbol with sourceIn.
121− let tinted = NSImage(size: targetSize, flipped: false) { [nsColor = NSColor(color).withAlphaComponent(opacity)] rect in
122− nsColor.setFill()
123− rect.fill()
124− symbol.draw(in: rect, from: .zero, operation: .sourceIn, fraction: 1.0)
125− return true
71+ // MARK: - Notification Permission
72+
73+ private func requestNotificationPermission() {
74+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in
75+ // Permission result is handled silently; notifications are optional.
12676 }
127− tinted.isTemplate = false
128− return tinted
12977 }
13078
131− // MARK: - Loading pulse
132−
133− /// Subtle pulse: 0.3s interval, opacity oscillates gently between 0.6–1.0.
134− private func startPulse() {
135− pulseStep = 0
136− pulseTimer?.invalidate()
137− let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in
138− guard let self else { return }
139− self.pulseStep += 1
140− let wave = sin(Double(self.pulseStep) * 0.5)
141− // 0.6 – 1.0 opacity range (subtle, not flashy)
142− let opacity = 0.8 + 0.2 * wave
143− self.statusItem.button?.image = self.statusBarIcon(
144− color: self.viewModel.balanceColor,
145− opacity: opacity
146− )
147− self.statusItem.button?.image?.isTemplate = false
148− }
149− RunLoop.main.add(timer, forMode: .common)
150− pulseTimer = timer
79+ // MARK: - Sleep / Wake
80+
81+ private func registerSleepWakeNotifications() {
82+ let workspace = NSWorkspace.shared
83+ let notificationCenter = workspace.notificationCenter
84+
85+ notificationCenter.addObserver(
86+ self,
87+ selector: #selector(systemWillSleep(_:)),
88+ name: NSWorkspace.willSleepNotification,
89+ object: nil
90+ )
91+
92+ notificationCenter.addObserver(
93+ self,
94+ selector: #selector(systemDidWake(_:)),
95+ name: NSWorkspace.didWakeNotification,
96+ object: nil
97+ )
15198 }
15299
153− private func stopPulse() {
154− pulseTimer?.invalidate()
155− pulseTimer = nil
156− statusItem.button?.image = statusBarIcon(color: viewModel.balanceColor, opacity: 1.0)
157− statusItem.button?.image?.isTemplate = false
100+ @objc private func systemWillSleep(_ notification: Notification) {
101+ // Nothing to do on sleep; timer will pause naturally.
102+ }
103+
104+ @objc private func systemDidWake(_ notification: Notification) {
105+ // Refresh after wake to get a fresh balance.
106+ viewModel.refresh()
158107 }
159108
160109 // MARK: - Popover
HyperCreditsMenubar/HyperCreditsMenubar.entitlements+1−1
@@ -3,7 +3,7 @@
33<plist version="1.0">
44<dict>
55 <key>com.apple.security.app-sandbox</key>
6− <false/>
6+ <true/>
77 <key>com.apple.security.network.client</key>
88 <true/>
99</dict>
HyperCreditsMenubar/MenuView.swift+153−177
@@ -2,10 +2,11 @@ import Foundation
22import SwiftUI
33import Combine
44import ServiceManagement
5+import UserNotifications
56
67/// The view model that drives the menu bar display and the popover content.
78final class ViewModel: ObservableObject {
8− /// The last known balance. `nil` when loading, error, or no API key.
9+ /// The last known balance. `nil` when there has never been a successful fetch.
910 @Published var balance: Int?
1011
1112 /// `true` while a network request is in flight.
@@ -17,43 +18,58 @@ final class ViewModel: ObservableObject {
1718 /// The API key entered by the user (loaded from Keychain on init).
1819 @Published var apiKeyInput: String = ""
1920
21+ /// Timestamp of the last successful balance fetch.
22+ @Published var lastUpdated: Date?
23+
24+ /// Brief "✓ Saved" confirmation shown after saving the API key.
25+ @Published var savedConfirmation: Bool = false
26+
2027 /// Whether launch-at-login is enabled.
2128 @Published var launchAtLogin: Bool {
2229 didSet {
30+ guard !isInitializingLaunchAtLogin else { return }
2331 updateLaunchAtLogin(launchAtLogin)
2432 }
2533 }
2634
27− /// Timestamp of the last successful balance fetch, for relative "x ago" display.
28− @Published var lastUpdated: Date?
29−
30− /// Whether an API key is currently stored in the Keychain.
31− @Published var hasAPIKey: Bool
35+ /// Flag to suppress the didSet during initial setup.
36+ private var isInitializingLaunchAtLogin = true
3237
33− private let checker = CreditsChecker()
38+ private let checker: CreditsChecking
3439
35− init() {
36− let storedKey = KeychainHelper.load()
37− apiKeyInput = storedKey ?? ""
38− hasAPIKey = (storedKey ?? "").isEmpty == false
40+ init(checker: CreditsChecking = CreditsChecker()) {
41+ self.checker = checker
42+ apiKeyInput = KeychainHelper.load() ?? ""
3943 launchAtLogin = SMAppService.mainApp.status == .enabled
44+ isInitializingLaunchAtLogin = false
4045 }
4146
4247 // MARK: - Balance
4348
44− /// `true` when there is no stored API key and no balance yet (onboarding state).
45− var needsOnboarding: Bool {
46− !hasAPIKey && balance == nil && errorMessage == nil
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)"
4754 }
4855
49− /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
56+ /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
5057 var statusBarItemText: String {
58+ if isLoading && balance == nil {
59+ return "⚡…"
60+ }
5161 if let balance = balance {
52− return "⚡\(balance)"
62+ return "⚡\(formatBalance(balance))"
5363 }
5464 return "⚡?"
5565 }
5666
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+
5773 /// The color for the balance display based on thresholds.
5874 var balanceColor: Color {
5975 guard let balance = balance else { return .secondary }
@@ -62,12 +78,12 @@ final class ViewModel: ObservableObject {
6278 return .red
6379 }
6480
65− /// A human-readable relative time for the last update, e.g. "2m ago".
66− var relativeUpdateText: String? {
67− guard let lastUpdated else { return nil }
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 }
6884 let formatter = RelativeDateTimeFormatter()
69− formatter.unitsStyle = .abbreviated
70− return formatter.localizedString(for: lastUpdated, relativeTo: Date())
85+ formatter.unitsStyle = .short
86+ return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
7187 }
7288
7389 /// Refreshes the balance from the API. No-op if no API key is set.
@@ -82,17 +98,53 @@ final class ViewModel: ObservableObject {
8298
8399 Task { @MainActor in
84100 isLoading = true
85− errorMessage = nil
101+ // Don't clear errorMessage here — keep showing the last error
102+ // until we have a successful result.
86103 do {
87104 let result = try await checker.fetchBalance(apiKey: key)
105+ // Check for low-balance threshold crossing before updating
106+ let previousBalance = balance
88107 balance = result
108+ errorMessage = nil
89109 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)
115+ }
90116 } catch {
91− balance = nil
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).
92120 errorMessage = error.localizedDescription
93− lastUpdated = nil
121+ isLoading = false
122+ }
123+ }
124+ }
125+
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
94133 }
95− isLoading = false
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)
96148 }
97149 }
98150
@@ -103,15 +155,20 @@ final class ViewModel: ObservableObject {
103155 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
104156 if key.isEmpty {
105157 KeychainHelper.delete()
106− hasAPIKey = false
107158 balance = nil
108159 errorMessage = nil
109160 lastUpdated = nil
110161 } else {
111162 KeychainHelper.save(key)
112− hasAPIKey = true
113163 refresh()
114164 }
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
171+ }
115172 }
116173
117174 // MARK: - Launch at Login
@@ -128,183 +185,102 @@ final class ViewModel: ObservableObject {
128185// MARK: - MenuView
129186
130187/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131−///
132−/// Minimal, airy design: one big balance number as the hero, generous spacing,
133−/// restrained color (balance color is the only accent), no cards or material
134−/// backgrounds. Inspired by Apple's Battery widget and trading apps.
135188struct MenuView: View {
136189 @ObservedObject var viewModel: ViewModel
137190
138191 var body: some View {
139− VStack(spacing: 0) {
140− heroSection
141− statusRow
142− refreshButton
143−
144− spacer
145−
146− apiKeySection
147−
148− spacer
149−
150− footerRow
151− versionText
152− }
153− .padding(20)
154− .frame(width: 280)
155− .fixedSize(horizontal: false, vertical: true)
156− .background(Color(nsColor: .windowBackgroundColor))
157− .animation(.easeInOut(duration: 0.2), value: viewModel.balance)
158− .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
159− .animation(.easeInOut(duration: 0.2), value: viewModel.hasAPIKey)
160− }
161−
162− /// A light vertical spacer between sections — spacing, not borders.
163− private var spacer: some View {
164− Spacer().frame(height: 16)
165− }
166−
167− // MARK: - Hero
168−
169− /// The balance number is the hero — big, bold, beautiful. Everything else
170− /// is secondary.
171− private var heroSection: some View {
172− VStack(spacing: 6) {
173− Group {
192+ VStack(spacing: 16) {
193+ // Balance display
194+ VStack(spacing: 4) {
174195 if viewModel.isLoading && viewModel.balance == nil {
175196 ProgressView()
176− .controlSize(.small)
177− } else if let balance = viewModel.balance {
178− Text("\(balance)")
179− .font(.system(size: 52, weight: .heavy, design: .rounded))
180− .monospacedDigit()
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))
181204 .foregroundColor(viewModel.balanceColor)
182− .contentTransition(.opacity)
183− } else if viewModel.needsOnboarding {
184− Image(systemName: "bolt.fill")
185− .font(.system(size: 40, weight: .regular))
186− .symbolRenderingMode(.hierarchical)
187− .foregroundStyle(.secondary)
205+ Text("credits")
206+ .font(.caption)
207+ .foregroundColor(.secondary)
188208 } else {
189− Text("—")
190− .font(.system(size: 52, weight: .heavy, design: .rounded))
209+ Text("?")
210+ .font(.system(size: 42, weight: .bold, design: .rounded))
211+ .foregroundColor(.secondary)
212+ Text("credits")
213+ .font(.caption)
191214 .foregroundColor(.secondary)
192215 }
193216 }
194− .frame(height: 56)
195−
196− Text("credits")
197− .font(.system(size: 11, weight: .medium, design: .rounded))
198− .foregroundColor(.secondary)
199− .textCase(.uppercase)
200− .tracking(1.5)
201− }
202− .frame(maxWidth: .infinity)
203− .padding(.top, 4)
204− }
205−
206− // MARK: - Status
207217
208− /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
209− private var statusRow: some View {
210− Group {
211− if let relative = viewModel.relativeUpdateText {
212− Text("Updated \(relative)")
213− } else {
214− Text("Not connected")
218+ // Last updated time
219+ if let lastUpdatedText = viewModel.lastUpdatedText {
220+ Text(lastUpdatedText)
221+ .font(.caption2)
222+ .foregroundColor(.secondary)
215223 }
216− }
217− .font(.system(size: 11, weight: .regular, design: .rounded))
218− .foregroundStyle(.tertiary)
219− .padding(.top, 2)
220− }
221−
222− // MARK: - Refresh
223224
224− /// Minimal text button — no icon rotation, just a text change.
225− private var refreshButton: some View {
226− Button(action: { viewModel.refresh() }) {
227− Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
228− .font(.system(size: 12, weight: .medium, design: .rounded))
229− .foregroundColor(.secondary)
230− }
231− .buttonStyle(.plain)
232− .disabled(viewModel.isLoading)
233− .padding(.top, 6)
234− }
225+ if let error = viewModel.errorMessage {
226+ Text(error)
227+ .font(.caption)
228+ .foregroundColor(.red)
229+ .multilineTextAlignment(.center)
230+ .padding(.horizontal)
231+ }
235232
236− // MARK: - API Key
233+ Divider()
237234
238− /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
239− private var apiKeySection: some View {
240− VStack(spacing: 8) {
241− HStack(spacing: 8) {
242− SecureField("Hyper API Key", text: $viewModel.apiKeyInput)
243− .textFieldStyle(.roundedBorder)
244− .font(.system(size: 13, design: .monospaced))
245− .onSubmit { viewModel.saveAPIKey() }
246−
247− Button(action: { viewModel.saveAPIKey() }) {
248− Text("Save")
249− .font(.system(size: 13, weight: .medium, design: .rounded))
250− }
251− .buttonStyle(.borderedProminent)
252− .controlSize(.small)
235+ // Refresh button
236+ Button(action: { viewModel.refresh() }) {
237+ Label("Refresh now", systemImage: "arrow.clockwise")
253238 }
254−
255− if viewModel.hasAPIKey {
256− Text("✓ Saved")
257− .font(.system(size: 11, weight: .medium, design: .rounded))
258− .foregroundColor(.green)
259− } else if let url = URL(string: "https://hyper.charm.land") {
260− Button {
261− NSWorkspace.shared.open(url)
262− } label: {
263− Text("Get your key at hyper.charm.land")
264− .font(.system(size: 11, weight: .regular, design: .rounded))
265− .foregroundColor(.secondary)
239+ .disabled(viewModel.isLoading)
240+
241+ // API key section
242+ VStack(alignment: .leading, spacing: 6) {
243+ Text("API Key")
244+ .font(.headline)
245+ HStack {
246+ SecureField("sk-...", text: $viewModel.apiKeyInput)
247+ .textFieldStyle(.roundedBorder)
248+ Button("Save") {
249+ viewModel.saveAPIKey()
250+ }
251+ }
252+ if viewModel.savedConfirmation {
253+ Text("✓ Saved")
254+ .font(.caption)
255+ .foregroundColor(.green)
256+ .transition(.opacity)
266257 }
267− .buttonStyle(.plain)
268258 }
269− }
270− }
271259
272− // MARK: - Footer
273−
274− /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275− private var footerRow: some View {
276− HStack {
260+ // Launch at login toggle
277261 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278262 .toggleStyle(.switch)
279− .controlSize(.small)
280− .font(.system(size: 12, weight: .regular, design: .rounded))
281263
282− Spacer()
264+ // Links
265+ HStack {
266+ if let url = URL(string: "https://hyper.charm.land") {
267+ Button("Open hyper.charm.land") {
268+ NSWorkspace.shared.open(url)
269+ }
270+ }
271+ Spacer()
272+ }
273+
274+ Divider()
283275
284− Button {
276+ // Quit
277+ Button("Quit HyperCredits") {
285278 NSApplication.shared.terminate(nil)
286− } label: {
287− Text("Quit")
288− .font(.system(size: 12, weight: .regular, design: .rounded))
289− .foregroundColor(.secondary)
290279 }
291− .buttonStyle(.plain)
292280 .keyboardShortcut("q")
293281 }
294− }
295−
296− // MARK: - Version
297−
298− /// Tiny version label at the very bottom.
299− private var versionText: some View {
300− Text("v\(appVersion)")
301− .font(.system(size: 10, weight: .regular, design: .rounded))
302− .foregroundStyle(.tertiary)
303− .padding(.top, 10)
304− }
305−
306− /// Reads the marketing version from the main bundle.
307− private var appVersion: String {
308− Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
282+ .padding(16)
283+ .frame(width: 280)
284+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
309285 }
310286}
HyperCreditsMenubarTests/CreditsCheckerTests.swift+62
@@ -48,4 +48,66 @@ final class CreditsCheckerTests: XCTestCase {
4848 XCTFail("Unexpected error: \(error)")
4949 }
5050 }
51+
52+ // MARK: - CreditsError cases
53+
54+ func testInvalidAPIKeyErrorDescription() {
55+ XCTAssertEqual(
56+ CreditsError.invalidAPIKey.errorDescription,
57+ "Invalid API key. Check your key at hyper.charm.land"
58+ )
59+ }
60+
61+ func testInvalidAPIKeyIsEquatable() {
62+ XCTAssertEqual(CreditsError.invalidAPIKey, .invalidAPIKey)
63+ XCTAssertNotEqual(CreditsError.invalidAPIKey, .noAPIKey)
64+ }
65+
66+ // MARK: - CreditsChecking protocol
67+
68+ func testCreditsCheckerConformsToProtocol() {
69+ let checker: CreditsChecking = CreditsChecker()
70+ // If this compiles, CreditsChecker conforms to CreditsChecking.
71+ XCTAssertNotNil(checker)
72+ }
73+
74+ // MARK: - Mock CreditsChecker for ViewModel tests
75+
76+ func testMockCreditsCheckerReturnsBalance() async throws {
77+ let mock = MockCreditsChecker(balanceToReturn: 42)
78+ let result = try await mock.fetchBalance(apiKey: "sk-test")
79+ XCTAssertEqual(result, 42)
80+ }
81+
82+ func testMockCreditsCheckerThrowsError() async {
83+ let mock = MockCreditsChecker(errorToThrow: CreditsError.invalidAPIKey)
84+ do {
85+ _ = try await mock.fetchBalance(apiKey: "sk-test")
86+ XCTFail("Expected invalidAPIKey error")
87+ } catch CreditsError.invalidAPIKey {
88+ // expected
89+ } catch {
90+ XCTFail("Unexpected error: \(error)")
91+ }
92+ }
93+}
94+
95+// MARK: - Mock CreditsChecker
96+
97+/// A mock `CreditsChecking` implementation for testing the ViewModel.
98+final class MockCreditsChecker: CreditsChecking {
99+ var balanceToReturn: Int?
100+ var errorToThrow: Error?
101+
102+ init(balanceToReturn: Int? = nil, errorToThrow: Error? = nil) {
103+ self.balanceToReturn = balanceToReturn
104+ self.errorToThrow = errorToThrow
105+ }
106+
107+ func fetchBalance(apiKey: String) async throws -> Int {
108+ if let errorToThrow {
109+ throw errorToThrow
110+ }
111+ return balanceToReturn ?? 0
112+ }
51113}