@@ -0,0 +1,188 @@
1+import Foundation
2+import SwiftUI
3+import Combine
4+import ServiceManagement
5+
6+/// The view model that drives the menu bar display and the popover content.
7+final class ViewModel: ObservableObject {
8+ /// The last known balance. `nil` when loading, error, or no API key.
9+ @Published var balance: Int?
10+
11+ /// `true` while a network request is in flight.
12+ @Published var isLoading = false
13+
14+ /// A human-readable error message, shown when the balance can't be fetched.
15+ @Published var errorMessage: String?
16+
17+ /// The API key entered by the user (loaded from Keychain on init).
18+ @Published var apiKeyInput: String = ""
19+
20+ /// Whether launch-at-login is enabled.
21+ @Published var launchAtLogin: Bool {
22+ didSet {
23+ updateLaunchAtLogin(launchAtLogin)
24+ }
25+ }
26+
27+ private let checker = CreditsChecker()
28+
29+ init() {
30+ apiKeyInput = KeychainHelper.load() ?? ""
31+ launchAtLogin = SMAppService.mainApp.status == .registered
32+ }
33+
34+ // MARK: - Balance
35+
36+ /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
37+ var statusBarItemText: String {
38+ if let balance = balance {
39+ return "⚡\(balance)"
40+ }
41+ return "⚡?"
42+ }
43+
44+ /// The color for the balance display based on thresholds.
45+ var balanceColor: Color {
46+ guard let balance = balance else { return .secondary }
47+ if balance >= 100 { return .green }
48+ if balance >= 10 { return .yellow }
49+ return .red
50+ }
51+
52+ /// Refreshes the balance from the API. No-op if no API key is set.
53+ func refresh() {
54+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
55+ guard !key.isEmpty else {
56+ balance = nil
57+ errorMessage = nil
58+ return
59+ }
60+
61+ Task { @MainActor in
62+ isLoading = true
63+ errorMessage = nil
64+ do {
65+ let result = try await checker.fetchBalance(apiKey: key)
66+ balance = result
67+ } catch {
68+ balance = nil
69+ errorMessage = error.localizedDescription
70+ }
71+ isLoading = false
72+ }
73+ }
74+
75+ // MARK: - API Key
76+
77+ /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
78+ func saveAPIKey() {
79+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
80+ if key.isEmpty {
81+ KeychainHelper.delete()
82+ balance = nil
83+ errorMessage = nil
84+ } else {
85+ KeychainHelper.save(key)
86+ refresh()
87+ }
88+ }
89+
90+ // MARK: - Launch at Login
91+
92+ func updateLaunchAtLogin(_ enabled: Bool) {
93+ if enabled {
94+ try? SMAppService.mainApp.register()
95+ } else {
96+ try? SMAppService.mainApp.unregister()
97+ }
98+ }
99+}
100+
101+// MARK: - MenuView
102+
103+/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
104+struct MenuView: View {
105+ @ObservedObject var viewModel: ViewModel
106+
107+ var body: some View {
108+ VStack(spacing: 16) {
109+ // Balance display
110+ VStack(spacing: 4) {
111+ if viewModel.isLoading && viewModel.balance == nil {
112+ ProgressView()
113+ .scaleEffect(0.8)
114+ Text("Loading…")
115+ .font(.caption)
116+ .foregroundColor(.secondary)
117+ } else if let balance = viewModel.balance {
118+ Text("\(balance)")
119+ .font(.system(size: 42, weight: .bold, design: .rounded))
120+ .foregroundColor(viewModel.balanceColor)
121+ Text("credits")
122+ .font(.caption)
123+ .foregroundColor(.secondary)
124+ } else {
125+ Text("?")
126+ .font(.system(size: 42, weight: .bold, design: .rounded))
127+ .foregroundColor(.secondary)
128+ Text("credits")
129+ .font(.caption)
130+ .foregroundColor(.secondary)
131+ }
132+ }
133+
134+ if let error = viewModel.errorMessage {
135+ Text(error)
136+ .font(.caption)
137+ .foregroundColor(.red)
138+ .multilineTextAlignment(.center)
139+ .padding(.horizontal)
140+ }
141+
142+ Divider()
143+
144+ // Refresh button
145+ Button(action: { viewModel.refresh() }) {
146+ Label("Refresh now", systemImage: "arrow.clockwise")
147+ }
148+ .disabled(viewModel.isLoading)
149+
150+ // API key section
151+ VStack(alignment: .leading, spacing: 6) {
152+ Text("API Key")
153+ .font(.headline)
154+ HStack {
155+ SecureField("sk-...", text: $viewModel.apiKeyInput)
156+ .textFieldStyle(.roundedBorder)
157+ Button("Save") {
158+ viewModel.saveAPIKey()
159+ }
160+ }
161+ }
162+
163+ // Launch at login toggle
164+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
165+ .toggleStyle(.switch)
166+
167+ // Links
168+ HStack {
169+ if let url = URL(string: "https://hyper.charm.land") {
170+ Button("Open hyper.charm.land") {
171+ NSWorkspace.shared.open(url)
172+ }
173+ }
174+ Spacer()
175+ }
176+
177+ Divider()
178+
179+ // Quit
180+ Button("Quit HyperCredits") {
181+ NSApplication.shared.terminate(nil)
182+ }
183+ .keyboardShortcut("q")
184+ }
185+ .padding(16)
186+ .frame(width: 280)
187+ }
188+}