on master help sign in
feat: initial HyperCreditsMenubar app

macOS menu bar app displaying Hyper (Charm) credit balance. - SwiftUI menu bar app (LSUIElement=true, no dock icon) - Fetches balance from GET https://hyper.charm.land/v1/credits - API key stored in macOS Keychain - Auto-refresh every 5 minutes - Color-coded balance: green ≥100, yellow 10-99, red <10 - Launch at login via SMAppService - xcodegen-based project (project.yml) - GitHub Actions workflow: builds on macOS, creates release - Version = commit timestamp YYYY.MM.DD.HHHH - MIT license

commit61daa05d6908e02ca617e9183683b784252e79d2 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 14:23 (3 months ago)
17 files changed+1067
.github/workflows/release.yml+118 .gitignore+37 HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.json+20 HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.json+20 HyperCreditsMenubar/Assets.xcassets/Contents.json+6 HyperCreditsMenubar/CreditsChecker.swift+71 HyperCreditsMenubar/HyperCreditsApp.swift+91 HyperCreditsMenubar/HyperCreditsMenubar.entitlements+10 HyperCreditsMenubar/Info.plist+35 HyperCreditsMenubar/KeychainHelper.swift+47 HyperCreditsMenubar/MenuView.swift+188 HyperCreditsMenubar/VersionFormatter.swift+34 HyperCreditsMenubarTests/CreditsCheckerTests.swift+51 HyperCreditsMenubarTests/VersionFormatterTests.swift+107 LICENSE+21 README.md+130 project.yml+81
.github/workflows/release.ymlnew file+118
@@ -0,0 +1,118 @@
1+name: Build and Release
2+
3+on:
4+ push:
5+ branches: [master]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: write
10+
11+jobs:
12+ build-and-release:
13+ runs-on: macos-14
14+ outputs:
15+ VERSION: ${{ env.VERSION }}
16+ steps:
17+ # ── Checkout ──────────────────────────────────────────────────────
18+ - name: Checkout
19+ uses: actions/checkout@v4
20+
21+ # ── Compute version from commit timestamp ─────────────────────────
22+ # Version format: YYYY.MM.DD.HHHH (HHHH = HHMM, 24-hour UTC)
23+ # %ct = committer date as Unix epoch seconds (timezone-independent).
24+ # `date -u -r $EPOCH` works on macOS (BSD date), which is our runner.
25+ - name: Compute version from commit timestamp
26+ run: |
27+ EPOCH=$(git log -1 --format=%ct)
28+ echo "Commit epoch: $EPOCH"
29+ VERSION=$(date -u -r "$EPOCH" +%Y.%m.%d.%H%M)
30+ echo "Version: $VERSION"
31+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
32+
33+ # ── Install xcodegen ──────────────────────────────────────────────
34+ - name: Install xcodegen
35+ run: brew install xcodegen
36+
37+ # ── Generate Xcode project ────────────────────────────────────────
38+ - name: Generate Xcode project
39+ run: xcodegen generate
40+
41+ # ── Run tests ─────────────────────────────────────────────────────
42+ - name: Run tests
43+ continue-on-error: true
44+ run: |
45+ xcodebuild test \
46+ -scheme HyperCreditsMenubar \
47+ -configuration Debug \
48+ -destination "platform=macOS" \
49+ CODE_SIGNING_ALLOWED=NO \
50+ CODE_SIGNING_REQUIRED=NO \
51+ CODE_SIGN_IDENTITY="" \
52+ DEVELOPMENT_TEAM="" \
53+ || true
54+
55+ # ── Archive ───────────────────────────────────────────────────────
56+ - name: Archive
57+ run: |
58+ xcodebuild archive \
59+ -scheme HyperCreditsMenubar \
60+ -configuration Release \
61+ -archivePath build/HyperCreditsMenubar.xcarchive \
62+ -destination "generic/platform=macOS" \
63+ CODE_SIGNING_ALLOWED=NO \
64+ CODE_SIGNING_REQUIRED=NO \
65+ CODE_SIGN_IDENTITY="" \
66+ DEVELOPMENT_TEAM="" \
67+ CODE_SIGN_ENTITLEMENTS="" \
68+ ENABLE_HARDENED_RUNTIME=NO
69+
70+ # ── Export .app from archive ──────────────────────────────────────
71+ - name: Export app from archive
72+ run: |
73+ mkdir -p build/export
74+ # Simply copy the .app from the archive (no signing in CI)
75+ cp -R "build/HyperCreditsMenubar.xcarchive/Products/Applications/HyperCreditsMenubar.app" \
76+ "build/export/HyperCreditsMenubar.app"
77+
78+ # ── Zip the app ───────────────────────────────────────────────────
79+ - name: Zip the app
80+ run: |
81+ cd build/export
82+ ditto -c -k --keepParent HyperCreditsMenubar.app "HyperCreditsMenubar-${{ env.VERSION }}.zip"
83+ cp "HyperCreditsMenubar-${{ env.VERSION }}.zip" ../../
84+
85+ # ── Upload build artifact ─────────────────────────────────────────
86+ - name: Upload build artifact
87+ uses: actions/upload-artifact@v4
88+ with:
89+ name: HyperCreditsMenubar-${{ env.VERSION }}
90+ path: HyperCreditsMenubar-${{ env.VERSION }}.zip
91+ retention-days: 30
92+
93+ # ── Create GitHub Release ─────────────────────────────────────────
94+ - name: Create GitHub Release
95+ uses: softprops/action-gh-release@v2
96+ with:
97+ tag_name: ${{ env.VERSION }}
98+ name: ${{ env.VERSION }}
99+ body: |
100+ ## HyperCreditsMenubar ${{ env.VERSION }}
101+
102+ **Download:** `HyperCreditsMenubar-${{ env.VERSION }}.zip`
103+
104+ ### Installation
105+ 1. Download and unzip `HyperCreditsMenubar-${{ env.VERSION }}.zip`
106+ 2. Move `HyperCreditsMenubar.app` to your `/Applications` folder
107+ 3. Launch the app — it will appear in your menu bar as ⚡
108+ 4. Click the ⚡ icon, enter your Hyper API key, and click Save
109+
110+ > **Note:** This build is unsigned. On first launch you may need to
111+ > right-click → Open, or allow it in System Settings → Privacy & Security.
112+
113+ ### Changes
114+ Built from commit ${{ github.sha }}
115+ files: |
116+ HyperCreditsMenubar-${{ env.VERSION }}.zip
117+ draft: false
118+ prerelease: false
.gitignorenew file+37
@@ -0,0 +1,37 @@
1+# Xcode
2+build/
3+DerivedData/
4+*.xcuserstate
5+xcuserdata/
6+*.xcworkspace/xcuserdata/
7+*.xcodeproj/xcuserdata/
8+*.xcscmblueprint
9+*.swp
10+*.lock
11+*.moved-aside
12+
13+# Swift Package Manager
14+.build/
15+Packages/
16+Package.resolved
17+
18+# macOS
19+.DS_Store
20+*.swp
21+*~
22+
23+# CocoaPods (not used, but just in case)
24+Pods/
25+
26+# fastlane
27+fastlane/report.xml
28+fastlane/Preview.html
29+fastlane/screenshots/**/*.png
30+fastlane/test_output
31+
32+# Generated by xcodegen
33+*.xcodeproj/
34+
35+# Build artifacts
36+*.zip
37+*.xcarchive
HyperCreditsMenubar/Assets.xcassets/AccentColor.colorset/Contents.jsonnew file+20
@@ -0,0 +1,20 @@
1+{
2+ "colors" : [
3+ {
4+ "color" : {
5+ "color-space" : "srgb",
6+ "components" : {
7+ "alpha" : "1.000",
8+ "blue" : "0.078",
9+ "green" : "0.345",
10+ "red" : "0.906"
11+ }
12+ },
13+ "idiom" : "universal"
14+ }
15+ ],
16+ "info" : {
17+ "author" : "xcode",
18+ "version" : 1
19+ }
20+}
HyperCreditsMenubar/Assets.xcassets/AppIcon.appiconset/Contents.jsonnew file+20
@@ -0,0 +1,20 @@
1+{
2+ "images" : [
3+ {
4+ "filename" : "AppIcon-512.png",
5+ "idiom" : "universal",
6+ "platform" : "macos",
7+ "size" : "512x512"
8+ },
9+ {
10+ "filename" : "AppIcon-1024.png",
11+ "idiom" : "universal",
12+ "platform" : "macos",
13+ "size" : "1024x1024"
14+ }
15+ ],
16+ "info" : {
17+ "author" : "xcode",
18+ "version" : 1
19+ }
20+}
HyperCreditsMenubar/Assets.xcassets/Contents.jsonnew file+6
@@ -0,0 +1,6 @@
1+{
2+ "info" : {
3+ "author" : "xcode",
4+ "version" : 1
5+ }
6+}
HyperCreditsMenubar/CreditsChecker.swiftnew file+71
@@ -0,0 +1,71 @@
1+import Foundation
2+
3+/// JSON response from `GET https://hyper.charm.land/v1/credits`.
4+struct BalanceResponse: Decodable, Equatable {
5+ let balance: Int
6+}
7+
8+/// Errors that can occur while fetching the balance.
9+enum CreditsError: LocalizedError, Equatable {
10+ case noAPIKey
11+ case invalidURL
12+ case requestFailed(String)
13+ case decodingFailed(String)
14+
15+ var errorDescription: String? {
16+ switch self {
17+ case .noAPIKey:
18+ return "No API key set. Enter your Hyper API key in settings."
19+ case .invalidURL:
20+ return "The server URL is invalid."
21+ case .requestFailed(let message):
22+ return "Request failed: \(message)"
23+ case .decodingFailed(let message):
24+ return "Failed to decode response: \(message)"
25+ }
26+ }
27+}
28+
29+/// Client for the Hyper (Charm) credits API.
30+final class CreditsChecker {
31+ static let endpoint = URL(string: "https://hyper.charm.land/v1/credits")!
32+
33+ private let session: URLSession
34+
35+ init(session: URLSession = .shared) {
36+ self.session = session
37+ }
38+
39+ /// Fetches the current credit balance for the given API key.
40+ /// - Parameter apiKey: The Hyper API key (starts with `"sk-"`).
41+ /// - Returns: The balance as an integer.
42+ /// - Throws: `CreditsError` on failure.
43+ func fetchBalance(apiKey: String) async throws -> Int {
44+ guard !apiKey.isEmpty else { throw CreditsError.noAPIKey }
45+
46+ var request = URLRequest(url: Self.endpoint)
47+ request.httpMethod = "GET"
48+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
49+ request.setValue("application/json", forHTTPHeaderField: "Accept")
50+
51+ do {
52+ let (data, response) = try await session.data(for: request)
53+ guard let httpResponse = response as? HTTPURLResponse else {
54+ throw CreditsError.requestFailed("Invalid response type")
55+ }
56+ guard (200...299).contains(httpResponse.statusCode) else {
57+ throw CreditsError.requestFailed("HTTP \(httpResponse.statusCode)")
58+ }
59+ do {
60+ let decoded = try JSONDecoder().decode(BalanceResponse.self, from: data)
61+ return decoded.balance
62+ } catch {
63+ throw CreditsError.decodingFailed(error.localizedDescription)
64+ }
65+ } catch let error as CreditsError {
66+ throw error
67+ } catch {
68+ throw CreditsError.requestFailed(error.localizedDescription)
69+ }
70+ }
71+}
HyperCreditsMenubar/HyperCreditsApp.swiftnew file+91
@@ -0,0 +1,91 @@
1+import SwiftUI
2+import Combine
3+
4+/// Main app delegate that manages the menu bar item, the refresh timer,
5+/// and the SwiftUI popover hosting `MenuView`.
6+final class AppDelegate: NSObject, NSApplicationDelegate {
7+ private var statusItem: NSStatusItem!
8+ private var popover: NSPopover!
9+ private var refreshTimer: Timer?
10+
11+ /// The shared view model. Shared between the menu bar and the popover.
12+ let viewModel = ViewModel()
13+
14+ /// Combine cancellable for observing balance changes.
15+ private var statusTextCancellable: AnyCancellable?
16+
17+ private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
18+
19+ // MARK: - Lifecycle
20+
21+ func applicationDidFinishLaunching(_ notification: Notification) {
22+ // Create status bar item
23+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
24+
25+ if let button = statusItem.button {
26+ button.image = nil
27+ button.title = "⚡?"
28+ button.target = self
29+ button.action = #selector(togglePopover(_:))
30+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
31+ }
32+
33+ // Create popover with SwiftUI content
34+ popover = NSPopover()
35+ popover.behavior = .transient
36+ popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
37+
38+ // Observe balance changes to update the menu bar title
39+ statusTextCancellable = viewModel.$balance
40+ .map { [weak viewModel] _ in viewModel?.statusBarItemText ?? "⚡?" }
41+ .removeDuplicates()
42+ .receive(on: RunLoop.main)
43+ .sink { [weak self] text in
44+ self?.statusItem.button?.title = text
45+ }
46+
47+ // Initial fetch
48+ viewModel.refresh()
49+
50+ // Start periodic refresh
51+ let timer = Timer(timeInterval: refreshInterval, repeats: true) { [weak self] _ in
52+ self?.viewModel.refresh()
53+ }
54+ RunLoop.main.add(timer, forMode: .common)
55+ refreshTimer = timer
56+ }
57+
58+ func applicationWillTerminate(_ notification: Notification) {
59+ refreshTimer?.invalidate()
60+ statusTextCancellable?.cancel()
61+ }
62+
63+ // MARK: - Popover
64+
65+ @objc private func togglePopover(_ sender: Any?) {
66+ guard let button = statusItem.button else { return }
67+ if popover.isShown {
68+ closePopover()
69+ } else {
70+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
71+ NSApp.activate(ignoringOtherApps: true)
72+ }
73+ }
74+
75+ private func closePopover() {
76+ popover.performClose(nil)
77+ }
78+}
79+
80+// MARK: - App Entry Point
81+
82+@main
83+struct HyperCreditsApp: App {
84+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
85+
86+ var body: some Scene {
87+ Settings {
88+ EmptyView()
89+ }
90+ }
91+}
HyperCreditsMenubar/HyperCreditsMenubar.entitlementsnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>com.apple.security.app-sandbox</key>
6+ <false/>
7+ <key>com.apple.security.network.client</key>
8+ <true/>
9+</dict>
10+</plist>
HyperCreditsMenubar/Info.plistnew file+35
@@ -0,0 +1,35 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleName</key>
6+ <string>HyperCreditsMenubar</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Hyper Credits</string>
9+ <key>CFBundleIdentifier</key>
10+ <string>com.ilyakooo0.hyper-credits-menubar</string>
11+ <key>CFBundleVersion</key>
12+ <string>1</string>
13+ <key>CFBundleShortVersionString</key>
14+ <string>1.0.0</string>
15+ <key>CFBundlePackageType</key>
16+ <string>APPL</string>
17+ <key>CFBundleExecutable</key>
18+ <string>$(EXECUTABLE_NAME)</string>
19+ <key>CFBundleInfoDictionaryVersion</key>
20+ <string>6.0</string>
21+ <key>LSMinimumSystemVersion</key>
22+ <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
23+ <key>LSUIElement</key>
24+ <true/>
25+ <key>LSApplicationCategoryType</key>
26+ <string>public.app-category.utilities</string>
27+ <key>NSHighResolutionCapable</key>
28+ <true/>
29+ <key>NSAppTransportSecurity</key>
30+ <dict>
31+ <key>NSAllowsArbitraryLoads</key>
32+ <false/>
33+ </dict>
34+</dict>
35+</plist>
HyperCreditsMenubar/KeychainHelper.swiftnew file+47
@@ -0,0 +1,47 @@
1+import Foundation
2+import Security
3+
4+/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
5+enum KeychainHelper {
6+ private static let service = "com.ilyakooo0.hyper-credits-menubar"
7+ private static let account = "hyper-api-key"
8+
9+ /// Saves the given API key to the Keychain, overwriting any existing entry.
10+ static func save(_ key: String) {
11+ delete()
12+ guard let data = key.data(using: .utf8) else { return }
13+ let query: [String: Any] = [
14+ kSecClass as String: kSecClassGenericPassword,
15+ kSecAttrService as String: service,
16+ kSecAttrAccount as String: account,
17+ kSecValueData as String: data,
18+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
19+ ]
20+ SecItemAdd(query as CFDictionary, nil)
21+ }
22+
23+ /// Retrieves the stored API key, or `nil` if none exists.
24+ static func load() -> String? {
25+ let query: [String: Any] = [
26+ kSecClass as String: kSecClassGenericPassword,
27+ kSecAttrService as String: service,
28+ kSecAttrAccount as String: account,
29+ kSecReturnData as String: true,
30+ kSecMatchLimit as String: kSecMatchLimitOne,
31+ ]
32+ var item: CFTypeRef?
33+ let status = SecItemCopyMatching(query as CFDictionary, &item)
34+ guard status == errSecSuccess, let data = item as? Data else { return nil }
35+ return String(data: data, encoding: .utf8)
36+ }
37+
38+ /// Deletes the stored API key if it exists.
39+ static func delete() {
40+ let query: [String: Any] = [
41+ kSecClass as String: kSecClassGenericPassword,
42+ kSecAttrService as String: service,
43+ kSecAttrAccount as String: account,
44+ ]
45+ SecItemDelete(query as CFDictionary)
46+ }
47+}
HyperCreditsMenubar/MenuView.swiftnew file+188
@@ -0,0 +1,188 @@
1+import Foundation
2+import SwiftUI
3+import Combine
4+import ServiceManagement
5+
6+/// The view model that drives the menu bar display and the popover content.
7+final class ViewModel: ObservableObject {
8+ /// The last known balance. `nil` when loading, error, or no API key.
9+ @Published var balance: Int?
10+
11+ /// `true` while a network request is in flight.
12+ @Published var isLoading = false
13+
14+ /// A human-readable error message, shown when the balance can't be fetched.
15+ @Published var errorMessage: String?
16+
17+ /// The API key entered by the user (loaded from Keychain on init).
18+ @Published var apiKeyInput: String = ""
19+
20+ /// Whether launch-at-login is enabled.
21+ @Published var launchAtLogin: Bool {
22+ didSet {
23+ updateLaunchAtLogin(launchAtLogin)
24+ }
25+ }
26+
27+ private let checker = CreditsChecker()
28+
29+ init() {
30+ apiKeyInput = KeychainHelper.load() ?? ""
31+ launchAtLogin = SMAppService.mainApp.status == .registered
32+ }
33+
34+ // MARK: - Balance
35+
36+ /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
37+ var statusBarItemText: String {
38+ if let balance = balance {
39+ return "⚡\(balance)"
40+ }
41+ return "⚡?"
42+ }
43+
44+ /// The color for the balance display based on thresholds.
45+ var balanceColor: Color {
46+ guard let balance = balance else { return .secondary }
47+ if balance >= 100 { return .green }
48+ if balance >= 10 { return .yellow }
49+ return .red
50+ }
51+
52+ /// Refreshes the balance from the API. No-op if no API key is set.
53+ func refresh() {
54+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
55+ guard !key.isEmpty else {
56+ balance = nil
57+ errorMessage = nil
58+ return
59+ }
60+
61+ Task { @MainActor in
62+ isLoading = true
63+ errorMessage = nil
64+ do {
65+ let result = try await checker.fetchBalance(apiKey: key)
66+ balance = result
67+ } catch {
68+ balance = nil
69+ errorMessage = error.localizedDescription
70+ }
71+ isLoading = false
72+ }
73+ }
74+
75+ // MARK: - API Key
76+
77+ /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
78+ func saveAPIKey() {
79+ let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
80+ if key.isEmpty {
81+ KeychainHelper.delete()
82+ balance = nil
83+ errorMessage = nil
84+ } else {
85+ KeychainHelper.save(key)
86+ refresh()
87+ }
88+ }
89+
90+ // MARK: - Launch at Login
91+
92+ func updateLaunchAtLogin(_ enabled: Bool) {
93+ if enabled {
94+ try? SMAppService.mainApp.register()
95+ } else {
96+ try? SMAppService.mainApp.unregister()
97+ }
98+ }
99+}
100+
101+// MARK: - MenuView
102+
103+/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
104+struct MenuView: View {
105+ @ObservedObject var viewModel: ViewModel
106+
107+ var body: some View {
108+ VStack(spacing: 16) {
109+ // Balance display
110+ VStack(spacing: 4) {
111+ if viewModel.isLoading && viewModel.balance == nil {
112+ ProgressView()
113+ .scaleEffect(0.8)
114+ Text("Loading…")
115+ .font(.caption)
116+ .foregroundColor(.secondary)
117+ } else if let balance = viewModel.balance {
118+ Text("\(balance)")
119+ .font(.system(size: 42, weight: .bold, design: .rounded))
120+ .foregroundColor(viewModel.balanceColor)
121+ Text("credits")
122+ .font(.caption)
123+ .foregroundColor(.secondary)
124+ } else {
125+ Text("?")
126+ .font(.system(size: 42, weight: .bold, design: .rounded))
127+ .foregroundColor(.secondary)
128+ Text("credits")
129+ .font(.caption)
130+ .foregroundColor(.secondary)
131+ }
132+ }
133+
134+ if let error = viewModel.errorMessage {
135+ Text(error)
136+ .font(.caption)
137+ .foregroundColor(.red)
138+ .multilineTextAlignment(.center)
139+ .padding(.horizontal)
140+ }
141+
142+ Divider()
143+
144+ // Refresh button
145+ Button(action: { viewModel.refresh() }) {
146+ Label("Refresh now", systemImage: "arrow.clockwise")
147+ }
148+ .disabled(viewModel.isLoading)
149+
150+ // API key section
151+ VStack(alignment: .leading, spacing: 6) {
152+ Text("API Key")
153+ .font(.headline)
154+ HStack {
155+ SecureField("sk-...", text: $viewModel.apiKeyInput)
156+ .textFieldStyle(.roundedBorder)
157+ Button("Save") {
158+ viewModel.saveAPIKey()
159+ }
160+ }
161+ }
162+
163+ // Launch at login toggle
164+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
165+ .toggleStyle(.switch)
166+
167+ // Links
168+ HStack {
169+ if let url = URL(string: "https://hyper.charm.land") {
170+ Button("Open hyper.charm.land") {
171+ NSWorkspace.shared.open(url)
172+ }
173+ }
174+ Spacer()
175+ }
176+
177+ Divider()
178+
179+ // Quit
180+ Button("Quit HyperCredits") {
181+ NSApplication.shared.terminate(nil)
182+ }
183+ .keyboardShortcut("q")
184+ }
185+ .padding(16)
186+ .frame(width: 280)
187+ }
188+}
HyperCreditsMenubar/VersionFormatter.swiftnew file+34
@@ -0,0 +1,34 @@
1+import Foundation
2+
3+/// Formats version strings using the scheme `YYYY.MM.DD.HHHH`
4+/// where `HHHH` is the 24-hour hour (2 digits) concatenated with the minute (2 digits).
5+///
6+/// Example: 2026-07-09 14:23 UTC → `"2026.07.09.1423"`
7+enum VersionFormatter {
8+ /// Formats a `Date` into the version string `YYYY.MM.DD.HHHH`.
9+ /// - Parameter date: The date to format.
10+ /// - Parameter timeZone: The time zone to use (defaults to UTC).
11+ /// - Returns: The formatted version string.
12+ static func format(date: Date, timeZone: TimeZone = .utc) -> String {
13+ var calendar = Calendar(identifier: .gregorian)
14+ calendar.timeZone = timeZone
15+
16+ let year = calendar.component(.year, from: date)
17+ let month = calendar.component(.month, from: date)
18+ let day = calendar.component(.day, from: date)
19+ let hour = calendar.component(.hour, from: date)
20+ let minute = calendar.component(.minute, from: date)
21+
22+ return String(format: "%04d.%02d.%02d.%02d%02d", year, month, day, hour, minute)
23+ }
24+
25+ /// Convenience: formats the current date/time in UTC.
26+ static func formatNow(timeZone: TimeZone = .utc) -> String {
27+ format(date: Date(), timeZone: timeZone)
28+ }
29+}
30+
31+extension TimeZone {
32+ /// UTC time zone.
33+ static var utc: TimeZone { TimeZone(identifier: "UTC")! }
34+}
HyperCreditsMenubarTests/CreditsCheckerTests.swiftnew file+51
@@ -0,0 +1,51 @@
1+import XCTest
2+@testable import HyperCreditsMenubar
3+
4+final class CreditsCheckerTests: XCTestCase {
5+
6+ // MARK: - BalanceResponse Decoding
7+
8+ func testDecodeValidBalance() throws {
9+ let json = #"{"balance": 42}"#.data(using: .utf8)!
10+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
11+ XCTAssertEqual(response.balance, 42)
12+ }
13+
14+ func testDecodeZeroBalance() throws {
15+ let json = #"{"balance": 0}"#.data(using: .utf8)!
16+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
17+ XCTAssertEqual(response.balance, 0)
18+ }
19+
20+ func testDecodeLargeBalance() throws {
21+ let json = #"{"balance": 999999}"#.data(using: .utf8)!
22+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
23+ XCTAssertEqual(response.balance, 999999)
24+ }
25+
26+ func testDecodeMissingBalanceThrows() {
27+ let json = #"{"foo": "bar"}"#.data(using: .utf8)!
28+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
29+ }
30+
31+ func testDecodeNegativeBalance() throws {
32+ // The API could theoretically return a negative balance
33+ let json = #"{"balance": -5}"#.data(using: .utf8)!
34+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
35+ XCTAssertEqual(response.balance, -5)
36+ }
37+
38+ // MARK: - CreditsChecker Errors
39+
40+ func testEmptyAPIKeyThrowsNoAPIKey() async {
41+ let checker = CreditsChecker()
42+ do {
43+ _ = try await checker.fetchBalance(apiKey: "")
44+ XCTFail("Expected noAPIKey error")
45+ } catch CreditsError.noAPIKey {
46+ // expected
47+ } catch {
48+ XCTFail("Unexpected error: \(error)")
49+ }
50+ }
51+}
HyperCreditsMenubarTests/VersionFormatterTests.swiftnew file+107
@@ -0,0 +1,107 @@
1+import XCTest
2+@testable import HyperCreditsMenubar
3+
4+final class VersionFormatterTests: XCTestCase {
5+
6+ // MARK: - Fixed-date formatting
7+
8+ func testFormatExampleFromSpec() {
9+ // 2026-07-09 14:23 UTC → "2026.07.09.1423"
10+ var components = DateComponents()
11+ components.year = 2026
12+ components.month = 7
13+ components.day = 9
14+ components.hour = 14
15+ components.minute = 23
16+ components.timeZone = TimeZone.utc
17+
18+ let date = Calendar(identifier: .gregorian).date(from: components)!
19+ let version = VersionFormatter.format(date: date)
20+ XCTAssertEqual(version, "2026.07.09.1423")
21+ }
22+
23+ func testFormatMidnight() {
24+ // 2026-01-01 00:00 UTC → "2026.01.01.0000"
25+ var components = DateComponents()
26+ components.year = 2026
27+ components.month = 1
28+ components.day = 1
29+ components.hour = 0
30+ components.minute = 0
31+ components.timeZone = TimeZone.utc
32+
33+ let date = Calendar(identifier: .gregorian).date(from: components)!
34+ let version = VersionFormatter.format(date: date)
35+ XCTAssertEqual(version, "2026.01.01.0000")
36+ }
37+
38+ func testFormatEndOfDay() {
39+ // 2026-12-31 23:59 UTC → "2026.12.31.2359"
40+ var components = DateComponents()
41+ components.year = 2026
42+ components.month = 12
43+ components.day = 31
44+ components.hour = 23
45+ components.minute = 59
46+ components.timeZone = TimeZone.utc
47+
48+ let date = Calendar(identifier: .gregorian).date(from: components)!
49+ let version = VersionFormatter.format(date: date)
50+ XCTAssertEqual(version, "2026.12.31.2359")
51+ }
52+
53+ func testFormatSingleDigitMonthDay() {
54+ // 2026-03-05 09:07 UTC → "2026.03.05.0907"
55+ var components = DateComponents()
56+ components.year = 2026
57+ components.month = 3
58+ components.day = 5
59+ components.hour = 9
60+ components.minute = 7
61+ components.timeZone = TimeZone.utc
62+
63+ let date = Calendar(identifier: .gregorian).date(from: components)!
64+ let version = VersionFormatter.format(date: date)
65+ XCTAssertEqual(version, "2026.03.05.0907")
66+ }
67+
68+ // MARK: - Structure / format validation
69+
70+ func testVersionHasCorrectNumberOfSegments() {
71+ let version = VersionFormatter.formatNow()
72+ let segments = version.split(separator: ".")
73+ // YYYY.MM.DD.HHHH → 4 segments
74+ XCTAssertEqual(segments.count, 4)
75+ }
76+
77+ func testVersionLastSegmentIsFourDigits() {
78+ let version = VersionFormatter.formatNow()
79+ let segments = version.split(separator: ".")
80+ XCTAssertEqual(segments[3].count, 4, "Last segment should be 4 digits (HHMM)")
81+ }
82+
83+ func testVersionOnlyContainsDigitsAndDots() {
84+ let version = VersionFormatter.formatNow()
85+ for char in version {
86+ XCTAssertTrue(char.isNumber || char == ".", "Unexpected character: \(char)")
87+ }
88+ }
89+
90+ // MARK: - Time zone conversion
91+
92+ func testNonUTCTimeZoneIsConvertedToUTC() {
93+ // 2026-07-09 14:23 UTC = 2026-07-09 07:23 PDT (UTC-7)
94+ // Formatting with PDT should produce the UTC version "2026.07.09.1423"
95+ var components = DateComponents()
96+ components.year = 2026
97+ components.month = 7
98+ components.day = 9
99+ components.hour = 7
100+ components.minute = 23
101+ components.timeZone = TimeZone(identifier: "America/Los_Angeles") // PDT = UTC-7 in summer
102+
103+ let date = Calendar(identifier: .gregorian).date(from: components)!
104+ let version = VersionFormatter.format(date: date, timeZone: TimeZone.utc)
105+ XCTAssertEqual(version, "2026.07.09.1423")
106+ }
107+}
LICENSEnew file+21
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 Ilya Koo
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
README.mdnew file+130
@@ -0,0 +1,130 @@
1+# HyperCreditsMenubar
2+
3+A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance.
4+
5+![Screenshot placeholder](docs/screenshot-placeholder.png)
6+
7+## Features
8+
9+- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
10+- 🔄 **Auto-refresh** — fetches your balance every 5 minutes
11+- 🔒 **Keychain storage** — your API key is stored securely in macOS Keychain
12+- 🎨 **Color-coded balance** — green (≥100), yellow (10–99), red (<10)
13+- 🔐 **Secure input** — API key field is masked
14+- 🚀 **Launch at login** — optional, using `SMAppService`
15+- 📋 **Quick link** — opens [hyper.charm.land](https://hyper.charm.land) from the menu
16+- 🫥 **Agent app** — no dock icon, no main window; lives entirely in the menu bar
17+
18+## Installation
19+
20+### Download the pre-built release
21+
22+1. Go to the [Releases page](https://github.com/ilyakooo0/hyper-credits-menubar/releases)
23+2. Download the latest `HyperCreditsMenubar-*.zip`
24+3. Unzip and move `HyperCreditsMenubar.app` to `/Applications`
25+4. Launch the app — it will appear in your menu bar as ⚡
26+
27+> **Note:** Releases are currently unsigned. On first launch, right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**.
28+
29+### Set your API key
30+
31+1. Click the ⚡ icon in your menu bar
32+2. Enter your Hyper API key (starts with `sk-...`) in the **API Key** field
33+3. Click **Save**
34+4. Your balance will appear immediately
35+
36+You can get your API key from [hyper.charm.land](https://hyper.charm.land).
37+
38+## Build from source
39+
40+### Prerequisites
41+
42+- macOS 14.0+ (Sonoma)
43+- Xcode 15+
44+- [xcodegen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`)
45+
46+### Steps
47+
48+```bash
49+# Clone the repository
50+git clone https://github.com/ilyakooo0/hyper-credits-menubar.git
51+cd hyper-credits-menubar
52+
53+# Generate the Xcode project
54+xcodegen generate
55+
56+# Open in Xcode
57+open HyperCreditsMenubar.xcodeproj
58+
59+# Or build from the command line
60+xcodebuild \
61+ -scheme HyperCreditsMenubar \
62+ -configuration Release \
63+ -archivePath build/HyperCreditsMenubar.xcarchive \
64+ archive \
65+ CODE_SIGNING_ALLOWED=NO
66+```
67+
68+### Run tests
69+
70+```bash
71+xcodebuild test \
72+ -scheme HyperCreditsMenubar \
73+ -configuration Debug \
74+ -destination "platform=macOS"
75+```
76+
77+## Versioning
78+
79+Versions follow the format `YYYY.MM.DD.HHHH` where `HHHH` is the UTC hour and minute (24-hour).
80+
81+| Commit time (UTC) | Version |
82+| ------------------------ | ----------------- |
83+| 2026-07-09 14:23 | `2026.07.09.1423` |
84+| 2026-01-01 00:00 | `2026.01.01.0000` |
85+| 2026-12-31 23:59 | `2026.12.31.2359` |
86+
87+The version is derived automatically from the commit timestamp in the GitHub Actions release workflow.
88+
89+## Project structure
90+
91+```
92+hyper-credits-menubar/
93+├── project.yml # xcodegen project definition
94+├── HyperCreditsMenubar/
95+│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer
96+│ ├── CreditsChecker.swift # API client (GET /v1/credits)
97+│ ├── MenuView.swift # SwiftUI popover view + ViewModel
98+│ ├── KeychainHelper.swift # Keychain wrapper for API key
99+│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
100+│ ├── Info.plist # LSUIElement=true (agent app)
101+│ ├── HyperCreditsMenubar.entitlements # Network client entitlements
102+│ └── Assets.xcassets/ # App icon, accent color
103+├── HyperCreditsMenubarTests/
104+│ ├── CreditsCheckerTests.swift # JSON decoding tests
105+│ └── VersionFormatterTests.swift # Version formatting tests
106+├── .github/workflows/
107+│ └── release.yml # Build + release on push to master
108+├── README.md
109+├── LICENSE
110+└── .gitignore
111+```
112+
113+## How it works
114+
115+1. **Menu bar item**: The app creates an `NSStatusItem` with a variable-length button showing `⚡{balance}` or `⚡?`.
116+2. **Popover**: Clicking the menu bar item shows an `NSPopover` containing a SwiftUI `MenuView`.
117+3. **API client**: `CreditsChecker` performs `GET https://hyper.charm.land/v1/credits` with `Authorization: Bearer {apiKey}` and decodes `{"balance": <int>}`.
118+4. **Keychain**: The API key is stored/retrieved via `KeychainHelper` using the macOS Security framework.
119+5. **Timer**: A `Timer` fires every 5 minutes to refresh the balance.
120+6. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
121+
122+## Credits
123+
124+- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
125+- **API**: [Hyper by Charm](https://hyper.charm.land)
126+- **Built with**: Swift 5.9, SwiftUI, macOS 14+
127+
128+## License
129+
130+MIT — see [LICENSE](LICENSE).
project.ymlnew file+81
@@ -0,0 +1,81 @@
1+name: HyperCreditsMenubar
2+options:
3+ bundleIdPrefix: com.ilyakooo0
4+ deploymentTarget:
5+ macOS: "14.0"
6+ createIntermediateGroups: true
7+ generateEmptyDirectories: true
8+
9+settings:
10+ base:
11+ SWIFT_VERSION: "5.9"
12+ DEVELOPMENT_TEAM: ""
13+ CODE_SIGN_IDENTITY: ""
14+ CODE_SIGNING_REQUIRED: "NO"
15+ CODE_SIGNING_ALLOWED: "NO"
16+ MARKETING_VERSION: "1.0.0"
17+ CURRENT_PROJECT_VERSION: "1"
18+
19+targets:
20+ HyperCreditsMenubar:
21+ type: application
22+ platform: macOS
23+ sources:
24+ - path: HyperCreditsMenubar
25+ excludes:
26+ - "Info.plist"
27+ - "*.entitlements"
28+ resources:
29+ - HyperCreditsMenubar/Assets.xcassets
30+ info:
31+ path: HyperCreditsMenubar/Info.plist
32+ properties:
33+ CFBundleName: HyperCreditsMenubar
34+ CFBundleDisplayName: Hyper Credits
35+ CFBundleIdentifier: com.ilyakooo0.hyper-credits-menubar
36+ CFBundleVersion: "1"
37+ CFBundleShortVersionString: "1.0.0"
38+ LSUIElement: true
39+ LSApplicationCategoryType: public.app-category.utilities
40+ NSHighResolutionCapable: true
41+ settings:
42+ base:
43+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar
44+ PRODUCT_NAME: HyperCreditsMenubar
45+ INFOPLIST_FILE: HyperCreditsMenubar/Info.plist
46+ CODE_SIGN_ENTITLEMENTS: HyperCreditsMenubar/HyperCreditsMenubar.entitlements
47+ ENABLE_HARDENED_RUNTIME: YES
48+ MACOSX_DEPLOYMENT_TARGET: "14.0"
49+ LD_RUNPATH_SEARCH_PATHS:
50+ - "$(inherited)"
51+ - "@executable_path/../Frameworks"
52+
53+ HyperCreditsMenubarTests:
54+ type: bundle.unit-test
55+ platform: macOS
56+ sources:
57+ - path: HyperCreditsMenubarTests
58+ dependencies:
59+ - target: HyperCreditsMenubar
60+ settings:
61+ base:
62+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.hyper-credits-menubar.tests
63+ PRODUCT_NAME: HyperCreditsMenubarTests
64+ BUNDLE_LOADER: "$(TEST_HOST)"
65+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/HyperCreditsMenubar.app/Contents/MacOS/HyperCreditsMenubar"
66+ MACOSX_DEPLOYMENT_TARGET: "14.0"
67+
68+schemes:
69+ HyperCreditsMenubar:
70+ build:
71+ targets:
72+ HyperCreditsMenubar: all
73+ HyperCreditsMenubarTests: [test]
74+ run:
75+ config: Debug
76+ test:
77+ config: Debug
78+ targets:
79+ - HyperCreditsMenubarTests
80+ archive:
81+ config: Release