on master help sign in
8 files changed+72−52
since splitsnapshots
unifiedsplit
CLAUDE.md+2−2 README.md+6−6 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+24−4 UsageMenubar/ViewModel.swift+8−8 UsageMenubarTests/StatusBarTextTests.swift+25−25 project.yml+4−4
CLAUDE.md+2−2
@@ -20,8 +20,8 @@ click-to-copy on the headline, and threshold-crossing notifications.
2020- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
2121- `VersionFormatter.swift` — Version string formatting
2222
23−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
24−`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
2525(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
2626service is not configured (no API key or no credentials). An unconfigured service
2727produces no placeholder in the menu bar. The three fetches are independent —
README.md+6−6
@@ -9,8 +9,8 @@ A macOS menu bar application that displays usage limits and credit balances for
99## Features
1010
1111- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12−- 🤖 **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13−- 🤖 **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
1414- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
1515- 🖱️ **Right-click menu** — quick access to Refresh and Quit
1616- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
@@ -24,10 +24,10 @@ Each service shows an emoji-prefixed segment when configured and above 0%:
2424| Service | Emoji | Example |
2525|---------|-------|---------|
2626| Hyper balance | ⚡ | `⚡42` |
27−| Claude 5-hour | 🕐 | `🕐62%` |
28−| Claude 7-day | 📅 | `📅8%` |
29−| z.ai 5-hour | 🤖 | `🤖12%` |
30−| z.ai weekly | 📆 | `📆3%` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29+| z.ai 5-hour | Z | `Z12%` |
30+| z.ai weekly | Z | `Z3%` |
3131
3232Unconfigured services don't appear at all — no placeholder, no icon.
3333
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "com.ilyakooo0.usage-menubar"
115+ private static let service = "soy.iko.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+24−4
@@ -259,7 +259,7 @@ struct MenuView: View {
259259 VStack(spacing: 8) {
260260 HStack(alignment: .firstTextBaseline, spacing: 8) {
261261 headlineNumber(
262− text: "🅲 \(percent)%",
262+ text: "⁕ \\(percent)%",
263263 color: Self.usageColor(percent),
264264 trend: viewModel.claudeTrend,
265265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
@@ -379,7 +379,7 @@ struct MenuView: View {
379379 VStack(spacing: 8) {
380380 HStack(alignment: .firstTextBaseline, spacing: 8) {
381381 headlineNumber(
382− text: "🅉 \(fiveHour)%",
382+ text: "Z \\(fiveHour)%",
383383 color: Self.usageColor(fiveHour),
384384 trend: viewModel.zaiTrend,
385385 copiedConfirmation: viewModel.copiedZaiConfirmation,
@@ -455,7 +455,8 @@ struct MenuView: View {
455455 }
456456
457457 /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458− /// Limits are most likely to bind during this window.
458+ /// Limits are most likely to bind during this window. Displayed in the
459+ /// user's local timezone so they know when to expect slowdowns.
459460 private var zaiPeakHours: some View {
460461 let inPeak = ViewModel.zaiInPeakHours
461462
@@ -464,12 +465,31 @@ struct MenuView: View {
464465 .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
465466 .frame(width: 6, height: 6)
466467
467− Text(inPeak ? "Peak hours now" : "Peak 14:00–18:00 CST")
468+ Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
468469 .font(Self.footnoteFont)
469470 .foregroundColor(inPeak ? .orange : .secondary)
470471 }
471472 }
472473
474+ /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475+ private static var zaiPeakHoursLocal: String {
476+ let formatter = DateFormatter()
477+ formatter.timeZone = TimeZone.current
478+ formatter.dateFormat = "HH:mm"
479+
480+ var utc = DateComponents()
481+ utc.timeZone = TimeZone(identifier: "UTC")
482+ utc.hour = 6
483+ var calendar = Calendar(identifier: .gregorian)
484+ calendar.timeZone = TimeZone(identifier: "UTC")!
485+ let start = calendar.date(from: utc)!
486+
487+ utc.hour = 10
488+ let end = calendar.date(from: utc)!
489+
490+ return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491+ }
492+
473493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
474494 private static func zaiResetsIn(from date: Date) -> String {
475495 let interval = date.timeIntervalSince(Date())
UsageMenubar/ViewModel.swift+8−8
@@ -7,7 +7,7 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
1111}
1212
1313/// The direction a metric has moved over the most recent history points.
@@ -243,7 +243,7 @@ final class ViewModel: ObservableObject {
243243 /// separated by spaces. An unconfigured service produces no segment.
244244 /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245245 ///
246− /// Example: `⚡42 · 🅲62% 8% · 🅉12% 3%🔶`
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
247247 ///
248248 /// Takes its values as parameters rather than reading the properties: `@Published`
249249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -267,26 +267,26 @@ final class ViewModel: ObservableObject {
267267
268268 // Claude — provider emoji + bare percentages
269269 var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
270+ if let fiveHour = claudeFiveHourPercent {
271271 claudeWindows.append("\(fiveHour)%")
272272 }
273− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
273+ if let sevenDay = claudeSevenDayPercent {
274274 claudeWindows.append("\(sevenDay)%")
275275 }
276276 if !claudeWindows.isEmpty {
277− segments.append("🅲" + claudeWindows.joined(separator: " "))
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
278278 }
279279
280280 // z.ai — provider emoji + bare percentages
281281 var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
282+ if let zaiFiveHour = zaiFiveHourPercent {
283283 zaiWindows.append("\(zaiFiveHour)%")
284284 }
285− if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
285+ if let zaiWeekly = zaiWeeklyPercent {
286286 zaiWindows.append("\(zaiWeekly)%")
287287 }
288288 if !zaiWindows.isEmpty {
289− var segment = "🅉" + zaiWindows.joined(separator: " ")
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290290 if zaiInPeakHours {
291291 segment += "🔶"
292292 }
UsageMenubarTests/StatusBarTextTests.swift+25−25
@@ -18,7 +18,7 @@ final class StatusBarTextTests: XCTestCase {
1818 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
1919 zaiInPeakHours: false
2020 )
21− XCTAssertEqual(title, "⚡42 · \u{1F172}62% 8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
2222 }
2323
2424 func testHyperOnly() {
@@ -40,7 +40,7 @@ final class StatusBarTextTests: XCTestCase {
4040 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
4141 zaiInPeakHours: false
4242 )
43− XCTAssertEqual(title, "\u{1F172}62% 8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
4444 }
4545
4646 func testLoadingWithNothingToShow() {
@@ -76,7 +76,7 @@ final class StatusBarTextTests: XCTestCase {
7676 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
7777 zaiInPeakHours: false
7878 )
79− XCTAssertEqual(title, "\u{1F172}62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
8080 }
8181
8282 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -87,10 +87,10 @@ final class StatusBarTextTests: XCTestCase {
8787 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
8888 zaiInPeakHours: false
8989 )
90− XCTAssertEqual(title, "⚡42 · \u{1F172}62% 8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
9191 }
9292
93− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
9494 let title = ViewModel.statusBarText(
9595 balance: 42, isLoading: false,
9696 hyperConfigured: true,
@@ -98,7 +98,7 @@ final class StatusBarTextTests: XCTestCase {
9898 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
9999 zaiInPeakHours: false
100100 )
101− XCTAssertEqual(title, "⚡42 · \u{1F172}8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
102102 }
103103
104104 func testBothClaudeWindowsZero() {
@@ -109,7 +109,7 @@ final class StatusBarTextTests: XCTestCase {
109109 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110110 zaiInPeakHours: false
111111 )
112− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
113113 }
114114
115115 func testFiveHourOnly() {
@@ -120,7 +120,7 @@ final class StatusBarTextTests: XCTestCase {
120120 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121121 zaiInPeakHours: false
122122 )
123− XCTAssertEqual(title, "\u{1F172}62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
124124 }
125125
126126 func testSevenDayOnly() {
@@ -131,7 +131,7 @@ final class StatusBarTextTests: XCTestCase {
131131 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132132 zaiInPeakHours: false
133133 )
134− XCTAssertEqual(title, "\u{1F172}8%")
134+ XCTAssertEqual(title, "\u{2055}8%")
135135 }
136136
137137 // MARK: - Hyper not configured
@@ -168,7 +168,7 @@ final class StatusBarTextTests: XCTestCase {
168168 zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169169 zaiInPeakHours: false
170170 )
171− XCTAssertEqual(title, "\u{1F172}62% 8%")
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
172172 }
173173
174174 // MARK: - z.ai in Title
@@ -181,7 +181,7 @@ final class StatusBarTextTests: XCTestCase {
181181 zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182182 zaiInPeakHours: false
183183 )
184− XCTAssertEqual(title, "\u{1F149}12%")
184+ XCTAssertEqual(title, "\u{005A}12%")
185185 }
186186
187187 func testZaiWithHyper() {
@@ -192,7 +192,7 @@ final class StatusBarTextTests: XCTestCase {
192192 zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193193 zaiInPeakHours: false
194194 )
195− XCTAssertEqual(title, "⚡42 · \u{1F149}12%")
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
196196 }
197197
198198 func testZaiWithClaude() {
@@ -203,7 +203,7 @@ final class StatusBarTextTests: XCTestCase {
203203 zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204204 zaiInPeakHours: false
205205 )
206− XCTAssertEqual(title, "\u{1F172}62% 8% · \u{1F149}12%")
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
207207 }
208208
209209 func testAllThreeServices() {
@@ -214,10 +214,10 @@ final class StatusBarTextTests: XCTestCase {
214214 zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215215 zaiInPeakHours: false
216216 )
217− XCTAssertEqual(title, "⚡42 · \u{1F172}62% 8% · \u{1F149}12% 3%")
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
218218 }
219219
220− func testZaiZeroIsOmitted() {
220+ func testZaiZeroIsShown() {
221221 let title = ViewModel.statusBarText(
222222 balance: 42, isLoading: false,
223223 hyperConfigured: true,
@@ -225,7 +225,7 @@ final class StatusBarTextTests: XCTestCase {
225225 zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226226 zaiInPeakHours: false
227227 )
228− XCTAssertEqual(title, "⚡42")
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
229229 }
230230
231231 func testLoadingKeepsZaiPercentOnScreen() {
@@ -236,7 +236,7 @@ final class StatusBarTextTests: XCTestCase {
236236 zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237237 zaiInPeakHours: false
238238 )
239− XCTAssertEqual(title, "\u{1F149}12%")
239+ XCTAssertEqual(title, "\u{005A}12%")
240240 }
241241
242242 // MARK: - z.ai Weekly
@@ -249,7 +249,7 @@ final class StatusBarTextTests: XCTestCase {
249249 zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250250 zaiInPeakHours: false
251251 )
252− XCTAssertEqual(title, "\u{1F149}3%")
252+ XCTAssertEqual(title, "\u{005A}3%")
253253 }
254254
255255 func testZaiWeeklyWithFiveHour() {
@@ -260,10 +260,10 @@ final class StatusBarTextTests: XCTestCase {
260260 zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261261 zaiInPeakHours: false
262262 )
263− XCTAssertEqual(title, "\u{1F149}12% 3%")
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
264264 }
265265
266− func testZaiWeeklyZeroIsOmitted() {
266+ func testZaiWeeklyZeroIsShown() {
267267 let title = ViewModel.statusBarText(
268268 balance: nil, isLoading: false,
269269 hyperConfigured: false,
@@ -271,7 +271,7 @@ final class StatusBarTextTests: XCTestCase {
271271 zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272272 zaiInPeakHours: false
273273 )
274− XCTAssertEqual(title, "\u{1F149}12%")
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
275275 }
276276
277277 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -282,7 +282,7 @@ final class StatusBarTextTests: XCTestCase {
282282 zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283283 zaiInPeakHours: false
284284 )
285− XCTAssertEqual(title, "\u{1F149}3%")
285+ XCTAssertEqual(title, "\u{005A}3%")
286286 }
287287
288288 // MARK: - z.ai Peak Hours
@@ -295,7 +295,7 @@ final class StatusBarTextTests: XCTestCase {
295295 zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296296 zaiInPeakHours: true
297297 )
298− XCTAssertEqual(title, "⚡42 · \u{1F172}62% 8% · \u{1F149}12% 3%\u{1F536}")
298+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%\u{1F536}")
299299 }
300300
301301 func testPeakHoursWithZaiOnly() {
@@ -306,7 +306,7 @@ final class StatusBarTextTests: XCTestCase {
306306 zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307307 zaiInPeakHours: true
308308 )
309− XCTAssertEqual(title, "\u{1F149}12%\u{1F536}")
309+ XCTAssertEqual(title, "\u{005A}12%\u{1F536}")
310310 }
311311
312312 func testPeakHoursIgnoredWhenZaiNotConfigured() {
@@ -318,7 +318,7 @@ final class StatusBarTextTests: XCTestCase {
318318 zaiInPeakHours: true
319319 )
320320 // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{1F172}62% 8%")
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
322322 }
323323
324324 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"