diff --git a/ClaudeChecker/ClaudeCheckerApp.swift b/ClaudeChecker/ClaudeCheckerApp.swift index 41702f1..45ae5d9 100644 --- a/ClaudeChecker/ClaudeCheckerApp.swift +++ b/ClaudeChecker/ClaudeCheckerApp.swift @@ -1,6 +1,7 @@ import SwiftUI import WebKit import Combine +import UserNotifications @main struct ClaudeCheckerApp: App { @@ -11,7 +12,7 @@ struct ClaudeCheckerApp: App { } @MainActor -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { var statusItem: NSStatusItem? var popover: NSPopover? var usageViewModel = UsageViewModel() @@ -23,6 +24,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + // Hidden WKWebView to prime the shared cookie store let config = WKWebViewConfiguration() config.websiteDataStore = WKWebsiteDataStore.default() @@ -205,4 +210,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { refreshTimer?.invalidate() } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler handler: @escaping (UNNotificationPresentationOptions) -> Void) { + handler([.banner, .sound]) + } } diff --git a/ClaudeChecker/Info.plist b/ClaudeChecker/Info.plist index 47315f2..84b7dd2 100644 --- a/ClaudeChecker/Info.plist +++ b/ClaudeChecker/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0.8 + 2.0.9 CFBundleVersion - 15 + 16 LSMinimumSystemVersion 13.0 LSUIElement diff --git a/ClaudeChecker/Models.swift b/ClaudeChecker/Models.swift index cd016f1..465fdc9 100644 --- a/ClaudeChecker/Models.swift +++ b/ClaudeChecker/Models.swift @@ -31,6 +31,12 @@ enum WindowType: String { case fiveHour = "5h" case sevenDay = "7d" var label: String { rawValue } + var displayName: String { + switch self { + case .fiveHour: return "5-hour" + case .sevenDay: return "7-day" + } + } } enum ProjectedHit { diff --git a/ClaudeChecker/UsageViewModel.swift b/ClaudeChecker/UsageViewModel.swift index e20ce9b..db93e80 100644 --- a/ClaudeChecker/UsageViewModel.swift +++ b/ClaudeChecker/UsageViewModel.swift @@ -1,5 +1,6 @@ import SwiftUI import WebKit +import UserNotifications @MainActor class UsageViewModel: ObservableObject { @@ -29,6 +30,8 @@ class UsageViewModel: ObservableObject { private var burnHistoryStore: [String: [Double]] = [:] private let maxHistorySamples = 24 private var cachedOrgId: String? + private var previousPercents: [String: Double] = [:] + private var firedThresholds: [String: Set] = [:] init() { cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId @@ -84,6 +87,7 @@ class UsageViewModel: ObservableObject { burnHistoryStore[key] = history if history.count > 1 { limits[i].burnHistory = history } } + checkLimitNotifications(for: limits) } catch AppError.notAuthenticated { isNotAuthenticated = true errorMessage = "Not signed in" @@ -234,6 +238,50 @@ class UsageViewModel: ObservableObject { return result } + // MARK: - Limit notifications + + private func checkLimitNotifications(for limits: [AgentLimit]) { + let center = UNUserNotificationCenter.current() + let thresholds = [80, 95] + + for limit in limits { + let key = limit.window.rawValue + let curr = limit.usedPercent + let prev = previousPercents[key] + var fired = firedThresholds[key] ?? [] + + // Threshold warnings (80% and 95%) + for t in thresholds { + let threshold = Double(t) + guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue } + fired.insert(t) + let content = UNMutableNotificationContent() + content.title = "Claude \(limit.window.displayName) limit" + content.body = t == 95 + ? "\(Int(curr.rounded()))% used — almost at your limit" + : "\(Int(curr.rounded()))% used — approaching your limit" + content.sound = .default + center.add(UNNotificationRequest(identifier: "cc_limit_\(key)_\(t)", content: content, trigger: nil)) + } + + // Reset detection: was high, now low + if let prev, prev > 50, curr < 20 { + fired.removeAll() + let content = UNMutableNotificationContent() + content.title = "Claude \(limit.window.displayName) limit reset" + content.body = "Your \(limit.window.displayName) quota has been reset" + content.sound = .default + center.add(UNNotificationRequest( + identifier: "cc_reset_\(key)_\(Int(Date().timeIntervalSince1970))", + content: content, trigger: nil + )) + } + + firedThresholds[key] = fired + previousPercents[key] = curr + } + } + private func loadPlaceholderData() { let now = Date() limits = [ diff --git a/README.md b/README.md index 04ce071..fe3130e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/) [![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) -[![Version](https://img.shields.io/badge/version-2.0.8-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) +[![Version](https://img.shields.io/badge/version-2.0.9-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/version.json b/version.json index 34a17ff..f4dc77d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "2.0.8", - "url": "https://github.com/superdooper86/claudechecker/releases/download/v2.0.8/ClaudeChecker.zip", - "notes": "Updated data sources list in settings." + "version": "2.0.9", + "url": "https://github.com/superdooper86/claudechecker/releases/download/v2.0.9/ClaudeChecker.zip", + "notes": "Added limit notifications: warns at 80% and 95% usage, and notifies when a limit resets." }