diff --git a/Sources/ClaudeUsageApp/App.swift b/Sources/ClaudeUsageApp/App.swift index 46e8c68..913f6d2 100644 --- a/Sources/ClaudeUsageApp/App.swift +++ b/Sources/ClaudeUsageApp/App.swift @@ -6,16 +6,6 @@ struct ClaudeUsageApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { - MenuBarExtra { - MenuContentView( - viewModel: appDelegate.viewModel, - widgetController: appDelegate.widgetController - ) - } label: { - MenuBarLabel(viewModel: appDelegate.viewModel) - } - .menuBarExtraStyle(.window) - Settings { SettingsRootView(viewModel: appDelegate.viewModel) } @@ -34,9 +24,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) lazy var widgetController = FloatingWidgetController(viewModel: viewModel) + private var statusBarController: StatusBarController? func applicationDidFinishLaunching(_ notification: Notification) { NSApplication.shared.setActivationPolicy(.accessory) + statusBarController = StatusBarController( + viewModel: viewModel, + widgetController: widgetController + ) notificationService.requestPermission() viewModel.startPolling() diff --git a/Sources/ClaudeUsageApp/MenuBarLabel.swift b/Sources/ClaudeUsageApp/MenuBarLabel.swift index 537358b..9a1aad4 100644 --- a/Sources/ClaudeUsageApp/MenuBarLabel.swift +++ b/Sources/ClaudeUsageApp/MenuBarLabel.swift @@ -7,6 +7,7 @@ struct MenuBarLabel: View { @AppStorage("warningThreshold") private var warningThreshold = 50.0 @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 @AppStorage("colorMode") private var colorMode = "traffic_light" + @AppStorage("menuBarLayout") private var menuBarLayout = MenuBarLayoutOption.fiveHourOnly.rawValue var body: some View { if viewModel.isEnterprise { @@ -21,9 +22,11 @@ struct MenuBarLabel: View { let secondary = MenuBarMetrics.consumerSecondaryPercent(from: viewModel.usage) return HStack(spacing: 2) { percentText(primary) - Text("·") - .foregroundStyle(.secondary) - percentText(secondary) + if menuBarLayout == MenuBarLayoutOption.dual.rawValue { + Text("·") + .foregroundStyle(.secondary) + percentText(secondary) + } } .font(.system(size: 11, weight: .medium, design: .rounded)) .monospacedDigit() diff --git a/Sources/ClaudeUsageApp/SettingsView.swift b/Sources/ClaudeUsageApp/SettingsView.swift index 0fb0611..380cf42 100644 --- a/Sources/ClaudeUsageApp/SettingsView.swift +++ b/Sources/ClaudeUsageApp/SettingsView.swift @@ -14,6 +14,30 @@ enum ColorMode: String, CaseIterable { } } +enum MenuBarLayoutOption: String, CaseIterable { + case fiveHourOnly // 5h% only + case dual // 5h% · 7d% + + var label: LocalizedStringKey { + switch self { + case .fiveHourOnly: return "5-hour" + case .dual: return "5-hour + 7-day" + } + } +} + +enum EnterpriseDisplayOption: String, CaseIterable { + case dollars + case percent + + var label: LocalizedStringKey { + switch self { + case .dollars: return "Dollars" + case .percent: return "Percent" + } + } +} + struct SettingsRootView: View { @ObservedObject var viewModel: UsageViewModel @@ -21,12 +45,14 @@ struct SettingsRootView: View { TabView { GeneralTab(viewModel: viewModel) .tabItem { Label("General", systemImage: "gearshape") } - ThresholdsTab() - .tabItem { Label("Thresholds", systemImage: "slider.horizontal.3") } + DisplayTab(viewModel: viewModel) + .tabItem { Label("Display", systemImage: "paintbrush") } + NotificationsTab() + .tabItem { Label("Notifications", systemImage: "bell") } AccountTab(viewModel: viewModel) .tabItem { Label("Account", systemImage: "person.crop.circle") } } - .frame(width: 420, height: 260) + .frame(width: 440, height: 340) } } @@ -56,48 +82,112 @@ private struct GeneralTab: View { } } -// MARK: - Thresholds +// MARK: - Display -private struct ThresholdsTab: View { +private struct DisplayTab: View { + @ObservedObject var viewModel: UsageViewModel + @AppStorage("menuBarLayout") private var menuBarLayout = MenuBarLayoutOption.fiveHourOnly.rawValue + @AppStorage("colorMode") private var colorMode = ColorMode.trafficLight @AppStorage("warningThreshold") private var warningThreshold = 50.0 @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 - @AppStorage("colorMode") private var colorMode = ColorMode.trafficLight + @AppStorage("enterpriseShowPct") private var enterpriseShowPct = false var body: some View { Form { - Picker("Color mode", selection: $colorMode) { - ForEach(ColorMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) + Section { + Picker("Menu bar", selection: $menuBarLayout) { + ForEach(MenuBarLayoutOption.allCases, id: \.rawValue) { option in + Text(option.label).tag(option.rawValue) + } + } + .pickerStyle(.segmented) + + if viewModel.isEnterprise { + Picker("Enterprise display", selection: enterpriseDisplayBinding) { + ForEach(EnterpriseDisplayOption.allCases, id: \.rawValue) { option in + Text(option.label).tag(option.rawValue) + } + } + .pickerStyle(.segmented) } } - .pickerStyle(.segmented) - HStack { - Text("Warning") - Slider(value: $warningThreshold, in: 10...90, step: 5) - .tint(.orange) - Text("\(Int(warningThreshold))%") - .monospacedDigit() - .frame(width: 40, alignment: .trailing) - } - .onChange(of: warningThreshold) { _, newValue in - if newValue >= criticalThreshold { - criticalThreshold = min(newValue + 10, 100) + Section { + Picker("Color mode", selection: $colorMode) { + ForEach(ColorMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } } + .pickerStyle(.segmented) + + HStack { + Text("Warning") + Slider(value: $warningThreshold, in: 10...90, step: 5) + .tint(.orange) + Text("\(Int(warningThreshold))%") + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .onChange(of: warningThreshold) { _, newValue in + if newValue >= criticalThreshold { + criticalThreshold = min(newValue + 10, 100) + } + } + + HStack { + Text("Critical") + Slider(value: $criticalThreshold, in: 20...100, step: 5) + .tint(.red) + Text("\(Int(criticalThreshold))%") + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .onChange(of: criticalThreshold) { _, newValue in + if newValue <= warningThreshold { + warningThreshold = max(newValue - 10, 0) + } + } + } footer: { + Text("Used for menu bar colors and notification triggers.") + .font(.caption) + .foregroundStyle(.secondary) } + } + .formStyle(.grouped) + } - HStack { - Text("Critical") - Slider(value: $criticalThreshold, in: 20...100, step: 5) - .tint(.red) - Text("\(Int(criticalThreshold))%") - .monospacedDigit() - .frame(width: 40, alignment: .trailing) + /// `@AppStorage` stores Bool; the picker takes `EnterpriseDisplayOption.rawValue`. + /// Bridge the two so the picker selects the right segment. + private var enterpriseDisplayBinding: Binding { + Binding( + get: { enterpriseShowPct ? EnterpriseDisplayOption.percent.rawValue : EnterpriseDisplayOption.dollars.rawValue }, + set: { enterpriseShowPct = ($0 == EnterpriseDisplayOption.percent.rawValue) } + ) + } +} + +// MARK: - Notifications + +private struct NotificationsTab: View { + @AppStorage("notificationThresholdAlerts") private var thresholdAlerts = true + @AppStorage("notificationBurnRateAlerts") private var burnRateAlerts = true + + var body: some View { + Form { + Section { + Toggle("Threshold alerts", isOn: $thresholdAlerts) + } footer: { + Text("Notify when 5-hour usage crosses the warning or critical level set in Display.") + .font(.caption) + .foregroundStyle(.secondary) } - .onChange(of: criticalThreshold) { _, newValue in - if newValue <= warningThreshold { - warningThreshold = max(newValue - 10, 0) - } + + Section { + Toggle("Burn rate alert", isOn: $burnRateAlerts) + } footer: { + Text("Notify once when projected to exhaust the 5-hour bucket within 60 minutes.") + .font(.caption) + .foregroundStyle(.secondary) } } .formStyle(.grouped) diff --git a/Sources/ClaudeUsageApp/StatusBarController.swift b/Sources/ClaudeUsageApp/StatusBarController.swift new file mode 100644 index 0000000..df26a1d --- /dev/null +++ b/Sources/ClaudeUsageApp/StatusBarController.swift @@ -0,0 +1,104 @@ +import AppKit +import Combine +import SwiftUI +import ClaudeUsageCore + +@MainActor +final class StatusBarController: NSObject { + private let statusItem: NSStatusItem + private let popover: NSPopover + private let viewModel: UsageViewModel + private let widgetController: FloatingWidgetController + private var labelHostingView: NSHostingView? + private var cancellables = Set() + + init(viewModel: UsageViewModel, widgetController: FloatingWidgetController) { + self.viewModel = viewModel + self.widgetController = widgetController + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.popover = NSPopover() + super.init() + + configureStatusItem() + configurePopover() + observeLabelSizeChanges() + } + + private func configureStatusItem() { + guard let button = statusItem.button else { return } + + let hostingView = NSHostingView(rootView: MenuBarLabel(viewModel: viewModel)) + hostingView.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: button.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: button.trailingAnchor), + hostingView.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) + labelHostingView = hostingView + + button.target = self + button.action = #selector(statusItemClicked(_:)) + + updateStatusItemLength() + } + + private func configurePopover() { + let content = MenuContentView( + viewModel: viewModel, + widgetController: widgetController + ) + popover.contentViewController = NSHostingController(rootView: content) + popover.behavior = .transient + popover.animates = true + } + + /// The status button's intrinsic width is zero (no title/image), so + /// `NSStatusItem.variableLength` would clip the SwiftUI label. Push the + /// hosting view's measured width back into `statusItem.length` whenever + /// the model — or the enterprise $/% display preference — changes. + private func observeLabelSizeChanges() { + viewModel.objectWillChange + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.updateStatusItemLength() + } + } + .store(in: &cancellables) + + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.updateStatusItemLength() + } + } + .store(in: &cancellables) + } + + private func updateStatusItemLength() { + guard let hostingView = labelHostingView else { return } + let width = hostingView.intrinsicContentSize.width + guard width.isFinite, width > 0 else { return } + statusItem.length = ceil(width) + } + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + if popover.isShown { + popover.performClose(sender) + } else { + showPopover(from: sender) + } + } + + private func showPopover(from button: NSStatusBarButton) { + // Activating the app ensures focusable controls (e.g. the OAuth code field) + // receive keyboard input — without this, .accessory apps swallow text events. + NSApp.activate(ignoringOtherApps: true) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + // AppKit auto-focuses the first focusable subview (the update Link) on + // open, which paints a focus ring around `v1.2.0`. Clear it so the + // popover opens with no focus ring; clicking a control still focuses it. + popover.contentViewController?.view.window?.makeFirstResponder(nil) + } +} diff --git a/Sources/ClaudeUsageCore/NotificationService.swift b/Sources/ClaudeUsageCore/NotificationService.swift index 7b161a6..eacf913 100644 --- a/Sources/ClaudeUsageCore/NotificationService.swift +++ b/Sources/ClaudeUsageCore/NotificationService.swift @@ -3,15 +3,19 @@ import UserNotifications public final class NotificationService: Sendable { private let notifiedKey = "claude-usage.notified.thresholds" + // UserDefaults is documented thread-safe but isn't marked Sendable in Swift 6. + nonisolated(unsafe) private let defaults: UserDefaults /// Returns active thresholds based on user settings private var thresholds: [Double] { - let warning = UserDefaults.standard.object(forKey: "warningThreshold") as? Double ?? 50 - let critical = UserDefaults.standard.object(forKey: "criticalThreshold") as? Double ?? 80 + let warning = defaults.object(forKey: "warningThreshold") as? Double ?? 50 + let critical = defaults.object(forKey: "criticalThreshold") as? Double ?? 80 return [warning, critical] } - public init() {} + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } public func requestPermission() { guard Bundle.main.bundleIdentifier != nil else { return } @@ -19,20 +23,24 @@ public final class NotificationService: Sendable { } public func checkAndNotify(fiveHourPct: Double) { - let notified = UserDefaults.standard.array(forKey: notifiedKey) as? [Double] ?? [] + let enabled = defaults.object(forKey: "notificationThresholdAlerts") as? Bool ?? true + guard enabled else { return } + + // Track the persisted set in-place; reading once per iteration would + // miss earlier appends and let the per-iteration overwrite drop them. + var notified = defaults.array(forKey: notifiedKey) as? [Double] ?? [] for threshold in thresholds { if fiveHourPct >= threshold && !notified.contains(threshold) { sendNotification(threshold: threshold, current: fiveHourPct) - var updated = notified - updated.append(threshold) - UserDefaults.standard.set(updated, forKey: notifiedKey) + notified.append(threshold) + defaults.set(notified, forKey: notifiedKey) } } // Reset notifications when usage drops below lowest threshold if fiveHourPct < (thresholds.min() ?? 80) { - UserDefaults.standard.removeObject(forKey: notifiedKey) + defaults.removeObject(forKey: notifiedKey) } } @@ -42,23 +50,25 @@ public final class NotificationService: Sendable { /// Check if we should send a burn rate notification (< 60 min to exhaustion, not already notified) public func shouldNotifyBurnRate(projection: BurnRateProjection, bucketLabel: String) -> Bool { + let enabled = defaults.object(forKey: "notificationBurnRateAlerts") as? Bool ?? true + guard enabled else { return false } guard let minutes = projection.minutesUntilExhaustion, minutes > 0, minutes <= 60, projection.velocityPerHour > 0 else { return false } let key = Self.burnRateKeyPrefix + bucketLabel - return !UserDefaults.standard.bool(forKey: key) + return !defaults.bool(forKey: key) } public func markBurnRateNotified(bucketLabel: String) { let key = Self.burnRateKeyPrefix + bucketLabel - UserDefaults.standard.set(true, forKey: key) + defaults.set(true, forKey: key) } public func resetBurnRateNotification(bucketLabel: String) { let key = Self.burnRateKeyPrefix + bucketLabel - UserDefaults.standard.removeObject(forKey: key) + defaults.removeObject(forKey: key) } public func sendBurnRateNotification(bucketLabel: String, minutesRemaining: Double) { diff --git a/Tests/ClaudeUsageTests/NotificationGatingTests.swift b/Tests/ClaudeUsageTests/NotificationGatingTests.swift new file mode 100644 index 0000000..5a185d5 --- /dev/null +++ b/Tests/ClaudeUsageTests/NotificationGatingTests.swift @@ -0,0 +1,77 @@ +import Testing +import Foundation +@testable import ClaudeUsageCore + +/// Validates that the new "Notifications" settings toggles actually gate +/// `NotificationService.checkAndNotify` and `shouldNotifyBurnRate`. +/// Each test uses an isolated UserDefaults suite so the gate keys never leak +/// into the global standard defaults that other parallel tests read from. +@Suite("Notification gating") +struct NotificationGatingTests { + private let notifiedKey = "claude-usage.notified.thresholds" + + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "claude-usage.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func validProjection() -> BurnRateProjection { + BurnRateProjection( + velocityPerHour: 80, + projectedExhaustionDate: Date().addingTimeInterval(30 * 60), + minutesUntilExhaustion: 30 + ) + } + + @Test("Threshold alerts disabled blocks side effects") + func thresholdAlertsDisabledBlocksSideEffects() { + let defaults = makeIsolatedDefaults() + defaults.set(false, forKey: "notificationThresholdAlerts") + let service = NotificationService(defaults: defaults) + + service.checkAndNotify(fiveHourPct: 95.0) + + // The early-return in checkAndNotify must short-circuit before recording + // the threshold cross — otherwise the gate isn't actually gating. + #expect(defaults.array(forKey: notifiedKey) == nil) + } + + @Test("Threshold alerts default (key unset) records crossings") + func thresholdAlertsDefaultRecordsCrossings() { + let defaults = makeIsolatedDefaults() + // Don't set the gate key — must default to enabled (`?? true`). + let service = NotificationService(defaults: defaults) + + service.checkAndNotify(fiveHourPct: 95.0) + + let notified = defaults.array(forKey: notifiedKey) as? [Double] ?? [] + #expect(notified.contains(50.0)) + #expect(notified.contains(80.0)) + } + + @Test("Burn rate alerts disabled returns false even when criteria met") + func burnRateAlertsDisabledReturnsFalse() { + let defaults = makeIsolatedDefaults() + defaults.set(false, forKey: "notificationBurnRateAlerts") + let service = NotificationService(defaults: defaults) + + #expect(service.shouldNotifyBurnRate( + projection: validProjection(), + bucketLabel: "5-Hour" + ) == false) + } + + @Test("Burn rate alerts default (key unset) returns true when criteria met") + func burnRateAlertsDefaultReturnsTrue() { + let defaults = makeIsolatedDefaults() + // Gate key intentionally unset. + let service = NotificationService(defaults: defaults) + + #expect(service.shouldNotifyBurnRate( + projection: validProjection(), + bucketLabel: "5-Hour" + ) == true) + } +}