on master help sign in
fix: comprehensive bug fixes and UX improvements
d3ea866iko
.github/workflows/release.yml+12−5
@@ -34,13 +34,22 @@ jobs:
34 - name: Install xcodegen
34 - name: Install xcodegen
35 run: brew install xcodegen
35 run: brew install xcodegen
36
36
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+
37 # ── Generate Xcode project ────────────────────────────────────────
47 # ── Generate Xcode project ────────────────────────────────────────
38 - name: Generate Xcode project
48 - name: Generate Xcode project
39 run: xcodegen generate
49 run: xcodegen generate
40
50
41 # ── Run tests ─────────────────────────────────────────────────────
51 # ── Run tests ─────────────────────────────────────────────────────
42 - name: Run tests
52 - name: Run tests
43− continue-on-error: true
44 run: |
53 run: |
45 xcodebuild test \
54 xcodebuild test \
46 -scheme HyperCreditsMenubar \
55 -scheme HyperCreditsMenubar \
@@ -49,8 +58,7 @@ jobs:
49 CODE_SIGNING_ALLOWED=NO \
58 CODE_SIGNING_ALLOWED=NO \
50 CODE_SIGNING_REQUIRED=NO \
59 CODE_SIGNING_REQUIRED=NO \
51 CODE_SIGN_IDENTITY="" \
60 CODE_SIGN_IDENTITY="" \
52− DEVELOPMENT_TEAM="" \
61+ DEVELOPMENT_TEAM=""
53− || true
54
62
55 # ── Archive ───────────────────────────────────────────────────────
63 # ── Archive ───────────────────────────────────────────────────────
56 - name: Archive
64 - name: Archive
@@ -64,8 +72,7 @@ jobs:
64 CODE_SIGNING_REQUIRED=NO \
72 CODE_SIGNING_REQUIRED=NO \
65 CODE_SIGN_IDENTITY="" \
73 CODE_SIGN_IDENTITY="" \
66 DEVELOPMENT_TEAM="" \
74 DEVELOPMENT_TEAM="" \
67− CODE_SIGN_ENTITLEMENTS="" \
75+ CODE_SIGN_ENTITLEMENTS=""
68− ENABLE_HARDENED_RUNTIME=NO
69
76
70 # ── Export .app from archive ──────────────────────────────────────
77 # ── Export .app from archive ──────────────────────────────────────
71 - name: Export app from archive
78 - name: Export app from archive
HyperCreditsMenubar/CreditsChecker.swift+22−4
@@ -8,6 +8,7 @@ struct BalanceResponse: Decodable, Equatable {
8/// Errors that can occur while fetching the balance.
8/// Errors that can occur while fetching the balance.
9enum CreditsError: LocalizedError, Equatable {
9enum CreditsError: LocalizedError, Equatable {
10 case noAPIKey
10 case noAPIKey
11+ case invalidAPIKey
11 case invalidURL
12 case invalidURL
12 case requestFailed(String)
13 case requestFailed(String)
13 case decodingFailed(String)
14 case decodingFailed(String)
@@ -16,6 +17,8 @@ enum CreditsError: LocalizedError, Equatable {
16 switch self {
17 switch self {
17 case .noAPIKey:
18 case .noAPIKey:
18 return "No API key set. Enter your Hyper API key in settings."
19 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"
19 case .invalidURL:
22 case .invalidURL:
20 return "The server URL is invalid."
23 return "The server URL is invalid."
21 case .requestFailed(let message):
24 case .requestFailed(let message):
@@ -26,18 +29,30 @@ enum CreditsError: LocalizedError, Equatable {
26 }
29 }
27}
30}
28
31
32+/// Protocol abstraction for fetching credit balance, enabling test injection.
33+protocol CreditsChecking {
34+ func fetchBalance(apiKey: String) async throws -> Int
35+}
36+
29/// Client for the Hyper (Charm) credits API.
37/// Client for the Hyper (Charm) credits API.
30−final class CreditsChecker {
38+final class CreditsChecker: CreditsChecking {
31 static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
39 static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
32
40
33 private let session: URLSession
41 private let session: URLSession
34
42
35− init(session: URLSession = .shared) {
43+ init(session: URLSession? = nil) {
44+ if let session {
36− self.session = 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+ }
37 }
52 }
38
53
39 /// Fetches the current credit balance for the given API key.
54 /// 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-"`).
41 /// - Returns: The balance as an integer.
56 /// - Returns: The balance as an integer.
42 /// - Throws: `CreditsError` on failure.
57 /// - Throws: `CreditsError` on failure.
43 func fetchBalance(apiKey: String) async throws -> Int {
58 func fetchBalance(apiKey: String) async throws -> Int {
@@ -54,6 +69,9 @@ final class CreditsChecker {
54 throw CreditsError.requestFailed("Invalid response type")
69 throw CreditsError.requestFailed("Invalid response type")
55 }
70 }
56 guard (200...299).contains(httpResponse.statusCode) else {
71 guard (200...299).contains(httpResponse.statusCode) else {
72+ if httpResponse.statusCode == 401 {
73+ throw CreditsError.invalidAPIKey
74+ }
57 throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
75 throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
58 }
76 }
59 do {
77 do {
HyperCreditsMenubar/HyperCreditsApp.swift+46−97
@@ -1,9 +1,9 @@
1import SwiftUI
1import SwiftUI
2import Combine
2import Combine
3−import AppKit
3+import UserNotifications
4
4
5/// Main app delegate that manages the menu bar item, the refresh timer,
5/// 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.
7final class AppDelegate: NSObject, NSApplicationDelegate {
7final class AppDelegate: NSObject, NSApplicationDelegate {
8 private var statusItem: NSStatusItem!
8 private var statusItem: NSStatusItem!
9 private var popover: NSPopover!
9 private var popover: NSPopover!
@@ -12,26 +12,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
12 /// The shared view model. Shared between the menu bar and the popover.
12 /// The shared view model. Shared between the menu bar and the popover.
13 let viewModel = ViewModel()
13 let viewModel = ViewModel()
14
14
15− /// Combine cancellables for observing balance / loading / colour changes.
15+ /// Combine cancellables for observing published changes.
16 private var statusTextCancellable: AnyCancellable?
16 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
22
17
23 private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
18 private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
24
19
25 // MARK: - Lifecycle
20 // MARK: - Lifecycle
26
21
27 func applicationDidFinishLaunching(_ notification: Notification) {
22 func applicationDidFinishLaunching(_ notification: Notification) {
23+ // Request notification permission for low-balance alerts
24+ requestNotificationPermission()
25+
28 // Create status bar item
26 // Create status bar item
29 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
27 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
30
28
31 if let button = statusItem.button {
29 if let button = statusItem.button {
32− button.image = statusBarIcon(color: .secondary, opacity: 1.0)
30+ button.image = nil
33− button.image?.isTemplate = false
34− button.title = "?"
31+ button.title = "⚡?"
35 button.target = self
32 button.target = self
36 button.action = #selector(togglePopover(_:))
33 button.action = #selector(togglePopover(_:))
37 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
34 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -42,46 +39,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
42 popover.behavior = .transient
39 popover.behavior = .transient
43 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
40 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
44
41
45− // Observe balance changes to update the menu bar title.
42+ // Observe published changes (balance + isLoading) to update the menu bar title.
46− // Shows "⚡…" while loading (no balance yet), otherwise the number.
43+ // CombineLatest fires when either published property changes.
47− statusTextCancellable = viewModel.$isLoading
44+ statusTextCancellable = Publishers.CombineLatest(viewModel.$balance, viewModel.$isLoading)
48− .combineLatest(viewModel.$balance)
49− .map { [weak viewModel] isLoading, balance in
50− if isLoading && balance == nil { return "…" }
51− return viewModel?.statusBarItemText ?? "⚡?"
45+ .map { [weak viewModel] _, _ in viewModel?.statusBarItemText ?? "⚡?" }
52− }
53 .removeDuplicates()
46 .removeDuplicates()
54 .receive(on: RunLoop.main)
47 .receive(on: RunLoop.main)
55 .sink { [weak self] text in
48 .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
61 }
50 }
62
51
63− // Observe balance to tint the status bar icon by balance colour.
52+ // Register for sleep/wake notifications
64− statusColorCancellable = viewModel.$balance
53+ registerSleepWakeNotifications()
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− }
85
54
86 // Initial fetch
55 // Initial fetch
87 viewModel.refresh()
56 viewModel.refresh()
@@ -97,64 +66,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
97 func applicationWillTerminate(_ notification: Notification) {
66 func applicationWillTerminate(_ notification: Notification) {
98 refreshTimer?.invalidate()
67 refreshTimer?.invalidate()
99 statusTextCancellable?.cancel()
68 statusTextCancellable?.cancel()
100− statusColorCancellable?.cancel()
101− loadingCancellable?.cancel()
102− stopPulse()
103 }
69 }
104
70
105− // MARK: - Status bar icon
71+ // MARK: - Notification Permission
106−
72+
107− /// Renders `bolt.fill` as an NSImage tinted with the balance colour.
73+ private func requestNotificationPermission() {
108− /// The number is kept as the button `title`; this image is the icon only.
74+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in
109− private func statusBarIcon(color: Color, opacity: Double) -> NSImage? {
75+ // Permission result is handled silently; notifications are optional.
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
126 }
76 }
127− tinted.isTemplate = false
128− return tinted
129 }
77 }
130
78
131− // MARK: - Loading pulse
79+ // MARK: - Sleep / Wake
132−
80+
133− /// Subtle pulse: 0.3s interval, opacity oscillates gently between 0.6–1.0.
81+ private func registerSleepWakeNotifications() {
134− private func startPulse() {
82+ let workspace = NSWorkspace.shared
135− pulseStep = 0
83+ let notificationCenter = workspace.notificationCenter
136− pulseTimer?.invalidate()
84+
137− let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in
85+ notificationCenter.addObserver(
138− guard let self else { return }
139− self.pulseStep += 1
86+ self,
140− let wave = sin(Double(self.pulseStep) * 0.5)
87+ selector: #selector(systemWillSleep(_:)),
141− // 0.6 – 1.0 opacity range (subtle, not flashy)
88+ name: NSWorkspace.willSleepNotification,
142− let opacity = 0.8 + 0.2 * wave
89+ object: nil
143− self.statusItem.button?.image = self.statusBarIcon(
90+ )
144− color: self.viewModel.balanceColor,
91+
145− opacity: opacity
92+ notificationCenter.addObserver(
146− )
93+ self,
147− self.statusItem.button?.image?.isTemplate = false
94+ selector: #selector(systemDidWake(_:)),
148− }
95+ name: NSWorkspace.didWakeNotification,
149− RunLoop.main.add(timer, forMode: .common)
96+ object: nil
150− pulseTimer = timer
97+ )
151 }
98 }
152
99
153− private func stopPulse() {
100+ @objc private func systemWillSleep(_ notification: Notification) {
154− pulseTimer?.invalidate()
101+ // Nothing to do on sleep; timer will pause naturally.
155− pulseTimer = nil
102+ }
156− statusItem.button?.image = statusBarIcon(color: viewModel.balanceColor, opacity: 1.0)
103+
157− statusItem.button?.image?.isTemplate = false
104+ @objc private func systemDidWake(_ notification: Notification) {
105+ // Refresh after wake to get a fresh balance.
106+ viewModel.refresh()
158 }
107 }
159
108
160 // MARK: - Popover
109 // MARK: - Popover
HyperCreditsMenubar/HyperCreditsMenubar.entitlements+1−1
@@ -3,7 +3,7 @@
3<plist version="1.0">
3<plist version="1.0">
4<dict>
4<dict>
5 <key>com.apple.security.app-sandbox</key>
5 <key>com.apple.security.app-sandbox</key>
6− <false/>
6+ <true/>
7 <key>com.apple.security.network.client</key>
7 <key>com.apple.security.network.client</key>
8 <true/>
8 <true/>
9</dict>
9</dict>
HyperCreditsMenubar/MenuView.swift+153−177
@@ -2,10 +2,11 @@ import Foundation
2import SwiftUI
2import SwiftUI
3import Combine
3import Combine
4import ServiceManagement
4import ServiceManagement
5+import UserNotifications
5
6
6/// The view model that drives the menu bar display and the popover content.
7/// The view model that drives the menu bar display and the popover content.
7final class ViewModel: ObservableObject {
8final 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.
9 @Published var balance: Int?
10 @Published var balance: Int?
10
11
11 /// `true` while a network request is in flight.
12 /// `true` while a network request is in flight.
@@ -17,43 +18,58 @@ final class ViewModel: ObservableObject {
17 /// The API key entered by the user (loaded from Keychain on init).
18 /// The API key entered by the user (loaded from Keychain on init).
18 @Published var apiKeyInput: String = ""
19 @Published var apiKeyInput: String = ""
19
20
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+
20 /// Whether launch-at-login is enabled.
27 /// Whether launch-at-login is enabled.
21 @Published var launchAtLogin: Bool {
28 @Published var launchAtLogin: Bool {
22 didSet {
29 didSet {
30+ guard !isInitializingLaunchAtLogin else { return }
23 updateLaunchAtLogin(launchAtLogin)
31 updateLaunchAtLogin(launchAtLogin)
24 }
32 }
25 }
33 }
26
34
27− /// Timestamp of the last successful balance fetch, for relative "x ago" display.
35+ /// Flag to suppress the didSet during initial setup.
28− @Published var lastUpdated: Date?
36+ private var isInitializingLaunchAtLogin = true
29−
30− /// Whether an API key is currently stored in the Keychain.
31− @Published var hasAPIKey: Bool
32
37
33− private let checker = CreditsChecker()
38+ private let checker: CreditsChecking
34
39
35− init() {
40+ init(checker: CreditsChecking = CreditsChecker()) {
41+ self.checker = checker
36− let storedKey = KeychainHelper.load()
42+ apiKeyInput = KeychainHelper.load() ?? ""
37− apiKeyInput = storedKey ?? ""
38− hasAPIKey = (storedKey ?? "").isEmpty == false
39 launchAtLogin = SMAppService.mainApp.status == .enabled
43 launchAtLogin = SMAppService.mainApp.status == .enabled
44+ isInitializingLaunchAtLogin = false
40 }
45 }
41
46
42 // MARK: - Balance
47 // MARK: - Balance
43
48
44− /// `true` when there is no stored API key and no balance yet (onboarding state).
49+ /// Formats an integer with thousands grouping separator.
45− var needsOnboarding: Bool {
50+ private func formatBalance(_ value: Int) -> String {
46− !hasAPIKey && balance == nil && errorMessage == nil
51+ let formatter = NumberFormatter()
52+ formatter.numberStyle = .decimal
53+ return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
47 }
54 }
48
55
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.
50 var statusBarItemText: String {
57 var statusBarItemText: String {
58+ if isLoading && balance == nil {
59+ return "⚡…"
60+ }
51 if let balance = balance {
61 if let balance = balance {
52− return "⚡\(balance)"
62+ return "⚡\(formatBalance(balance))"
53 }
63 }
54 return "⚡?"
64 return "⚡?"
55 }
65 }
56
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+
57 /// The color for the balance display based on thresholds.
73 /// The color for the balance display based on thresholds.
58 var balanceColor: Color {
74 var balanceColor: Color {
59 guard let balance = balance else { return .secondary }
75 guard let balance = balance else { return .secondary }
@@ -62,12 +78,12 @@ final class ViewModel: ObservableObject {
62 return .red
78 return .red
63 }
79 }
64
80
65− /// A human-readable relative time for the last update, e.g. "2m ago".
81+ /// Relative time string for the last successful update, or nil if never updated.
66− var relativeUpdateText: String? {
82+ var lastUpdatedText: String? {
67− guard let lastUpdated else { return nil }
83+ guard let lastUpdated = lastUpdated else { return nil }
68 let formatter = RelativeDateTimeFormatter()
84 let formatter = RelativeDateTimeFormatter()
69− formatter.unitsStyle = .abbreviated
85+ formatter.unitsStyle = .short
70− return formatter.localizedString(for: lastUpdated, relativeTo: Date())
86+ return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
71 }
87 }
72
88
73 /// Refreshes the balance from the API. No-op if no API key is set.
89 /// Refreshes the balance from the API. No-op if no API key is set.
@@ -82,17 +98,53 @@ final class ViewModel: ObservableObject {
82
98
83 Task { @MainActor in
99 Task { @MainActor in
84 isLoading = true
100 isLoading = true
85− errorMessage = nil
101+ // Don't clear errorMessage here — keep showing the last error
102+ // until we have a successful result.
86 do {
103 do {
87 let result = try await checker.fetchBalance(apiKey: key)
104 let result = try await checker.fetchBalance(apiKey: key)
105+ // Check for low-balance threshold crossing before updating
106+ let previousBalance = balance
88 balance = result
107 balance = result
108+ errorMessage = nil
89 lastUpdated = Date()
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)
115+ }
90 } catch {
116 } 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).
92 errorMessage = error.localizedDescription
120 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
94 }
133 }
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)
96 }
148 }
97 }
149 }
98
150
@@ -103,15 +155,20 @@ final class ViewModel: ObservableObject {
103 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
155 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
104 if key.isEmpty {
156 if key.isEmpty {
105 KeychainHelper.delete()
157 KeychainHelper.delete()
106− hasAPIKey = false
107 balance = nil
158 balance = nil
108 errorMessage = nil
159 errorMessage = nil
109 lastUpdated = nil
160 lastUpdated = nil
110 } else {
161 } else {
111 KeychainHelper.save(key)
162 KeychainHelper.save(key)
112− hasAPIKey = true
113 refresh()
163 refresh()
114 }
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
171+ }
115 }
172 }
116
173
117 // MARK: - Launch at Login
174 // MARK: - Launch at Login
@@ -128,183 +185,102 @@ final class ViewModel: ObservableObject {
128// MARK: - MenuView
185// MARK: - MenuView
129
186
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
187/// 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.
135struct MenuView: View {
188struct MenuView: View {
136 @ObservedObject var viewModel: ViewModel
189 @ObservedObject var viewModel: ViewModel
137
190
138 var body: some View {
191 var body: some View {
139− VStack(spacing: 0) {
192+ VStack(spacing: 16) {
140− heroSection
193+ // Balance display
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) {
194+ VStack(spacing: 4) {
173− Group {
174 if viewModel.isLoading && viewModel.balance == nil {
195 if viewModel.isLoading && viewModel.balance == nil {
175 ProgressView()
196 ProgressView()
176− .controlSize(.small)
197+ .scaleEffect(0.8)
198+ Text("Loading…")
199+ .font(.caption)
200+ .foregroundColor(.secondary)
177− } else if let balance = viewModel.balance {
201+ } else if viewModel.balance != nil {
178− Text("\(balance)")
202+ Text(viewModel.formattedBalance)
179− .font(.system(size: 52, weight: .heavy, design: .rounded))
203+ .font(.system(size: 42, weight: .bold, design: .rounded))
180− .monospacedDigit()
181 .foregroundColor(viewModel.balanceColor)
204 .foregroundColor(viewModel.balanceColor)
182− .contentTransition(.opacity)
205+ Text("credits")
183− } else if viewModel.needsOnboarding {
206+ .font(.caption)
184− Image(systemName: "bolt.fill")
207+ .foregroundColor(.secondary)
185− .font(.system(size: 40, weight: .regular))
186− .symbolRenderingMode(.hierarchical)
187− .foregroundStyle(.secondary)
188 } else {
208 } else {
189− Text("—")
209+ Text("?")
190− .font(.system(size: 52, weight: .heavy, design: .rounded))
210+ .font(.system(size: 42, weight: .bold, design: .rounded))
211+ .foregroundColor(.secondary)
212+ Text("credits")
213+ .font(.caption)
191 .foregroundColor(.secondary)
214 .foregroundColor(.secondary)
192 }
215 }
193 }
216 }
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
207
217
208− /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
218+ // Last updated time
209− private var statusRow: some View {
219+ if let lastUpdatedText = viewModel.lastUpdatedText {
210− Group {
220+ Text(lastUpdatedText)
211− if let relative = viewModel.relativeUpdateText {
221+ .font(.caption2)
212− Text("Updated \(relative)")
222+ .foregroundColor(.secondary)
213− } else {
214− Text("Not connected")
215 }
223 }
216− }
217− .font(.system(size: 11, weight: .regular, design: .rounded))
218− .foregroundStyle(.tertiary)
219− .padding(.top, 2)
220− }
221−
222− // MARK: - Refresh
223
224
224− /// Minimal text button — no icon rotation, just a text change.
225+ if let error = viewModel.errorMessage {
225− private var refreshButton: some View {
226+ Text(error)
226− Button(action: { viewModel.refresh() }) {
227+ .font(.caption)
227− Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
228− .font(.system(size: 12, weight: .medium, design: .rounded))
229− .foregroundColor(.secondary)
228+ .foregroundColor(.red)
230− }
229+ .multilineTextAlignment(.center)
231− .buttonStyle(.plain)
232− .disabled(viewModel.isLoading)
233− .padding(.top, 6)
230+ .padding(.horizontal)
234− }
231+ }
235
232
236− // MARK: - API Key
233+ Divider()
237
234
238− /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
235+ // Refresh button
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() }) {
236+ Button(action: { viewModel.refresh() }) {
248− Text("Save")
237+ Label("Refresh now", systemImage: "arrow.clockwise")
249− .font(.system(size: 13, weight: .medium, design: .rounded))
250− }
251− .buttonStyle(.borderedProminent)
252− .controlSize(.small)
253 }
238 }
254−
239+ .disabled(viewModel.isLoading)
255− if viewModel.hasAPIKey {
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 {
256− Text("✓ Saved")
253+ Text("✓ Saved")
257− .font(.system(size: 11, weight: .medium, design: .rounded))
254+ .font(.caption)
258− .foregroundColor(.green)
255+ .foregroundColor(.green)
259− } else if let url = URL(string: "https://hyper.charm.land") {
256+ .transition(.opacity)
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)
266 }
257 }
267− .buttonStyle(.plain)
268 }
258 }
269− }
270− }
271
259
272− // MARK: - Footer
260+ // Launch at login toggle
273−
274− /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275− private var footerRow: some View {
276− HStack {
277 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
261 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278 .toggleStyle(.switch)
262 .toggleStyle(.switch)
279− .controlSize(.small)
280− .font(.system(size: 12, weight: .regular, design: .rounded))
281
263
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+ }
282− Spacer()
271+ Spacer()
272+ }
273+
274+ Divider()
283
275
276+ // Quit
284− Button {
277+ Button("Quit HyperCredits") {
285 NSApplication.shared.terminate(nil)
278 NSApplication.shared.terminate(nil)
286− } label: {
287− Text("Quit")
288− .font(.system(size: 12, weight: .regular, design: .rounded))
289− .foregroundColor(.secondary)
290 }
279 }
291− .buttonStyle(.plain)
292 .keyboardShortcut("q")
280 .keyboardShortcut("q")
293 }
281 }
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)
282+ .padding(16)
304− }
283+ .frame(width: 280)
305−
284+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
306− /// Reads the marketing version from the main bundle.
307− private var appVersion: String {
308− Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
309 }
285 }
310}
286}
HyperCreditsMenubarTests/CreditsCheckerTests.swift+62
@@ -48,4 +48,66 @@ final class CreditsCheckerTests: XCTestCase {
48 XCTFail("Unexpected error: \(error)")
48 XCTFail("Unexpected error: \(error)")
49 }
49 }
50 }
50 }
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+ }
51}
113}