From 3d1bae3d0a4a2c6fd03730c02eed0b3e2638c2ad Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Thu, 7 May 2026 15:44:38 +0200 Subject: [PATCH] =?UTF-8?q?v2.0.9=20=E2=80=94=20limit=20notifications=20us?= =?UTF-8?q?ing=20custom=20floating=20window=20(same=20style=20as=20update?= =?UTF-8?q?=20notifications)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ClaudeChecker/ClaudeCheckerApp.swift | 28 ++++--- ClaudeChecker/UpdateNotificationWindow.swift | 83 +++++++++++++++++--- ClaudeChecker/UsageViewModel.swift | 29 +++---- 3 files changed, 101 insertions(+), 39 deletions(-) diff --git a/ClaudeChecker/ClaudeCheckerApp.swift b/ClaudeChecker/ClaudeCheckerApp.swift index 45ae5d9..52a773c 100644 --- a/ClaudeChecker/ClaudeCheckerApp.swift +++ b/ClaudeChecker/ClaudeCheckerApp.swift @@ -1,7 +1,6 @@ import SwiftUI import WebKit import Combine -import UserNotifications @main struct ClaudeCheckerApp: App { @@ -12,7 +11,7 @@ struct ClaudeCheckerApp: App { } @MainActor -class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { +class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? var popover: NSPopover? var usageViewModel = UsageViewModel() @@ -24,10 +23,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele 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() @@ -85,6 +80,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele } } + NotificationCenter.default.addObserver(forName: .limitWarning, object: nil, queue: .main) { [weak self] note in + Task { @MainActor [weak self] in + guard let self, let info = note.userInfo, + let windowName = info["windowName"] as? String, + let percent = info["percent"] as? Int else { return } + UpdateNotificationWindowController.showLimitWarning(windowName: windowName, percent: percent, near: self.statusItem) + } + } + + NotificationCenter.default.addObserver(forName: .limitReset, object: nil, queue: .main) { [weak self] note in + Task { @MainActor [weak self] in + guard let self, let windowName = note.userInfo?["windowName"] as? String else { return } + UpdateNotificationWindowController.showLimitReset(windowName: windowName, near: self.statusItem) + } + } + NotificationCenter.default.addObserver(forName: .closePopover, object: nil, queue: .main) { [weak self] _ in Task { @MainActor [weak self] in self?.popover?.performClose(nil) @@ -211,9 +222,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele refreshTimer?.invalidate() } - func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler handler: @escaping (UNNotificationPresentationOptions) -> Void) { - handler([.banner, .sound]) - } } diff --git a/ClaudeChecker/UpdateNotificationWindow.swift b/ClaudeChecker/UpdateNotificationWindow.swift index d125881..9bfcb55 100644 --- a/ClaudeChecker/UpdateNotificationWindow.swift +++ b/ClaudeChecker/UpdateNotificationWindow.swift @@ -5,6 +5,7 @@ import AppKit class UpdateNotificationWindowController: NSWindowController { static var shared: UpdateNotificationWindowController? + static var sharedLimit: UpdateNotificationWindowController? // Called after successful install + relaunch static func show(version: String, notes: String, near statusItem: NSStatusItem?) { @@ -16,16 +17,55 @@ class UpdateNotificationWindowController: NSWindowController { showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true) } + static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) { + let icon = percent >= 95 ? "exclamationmark.circle.fill" : "exclamationmark.triangle.fill" + let color = percent >= 95 ? Color.red : Color.orange + let title = "Claude \(windowName) limit at \(percent)%" + let body = percent >= 95 ? "Almost out of quota — usage is critically high." : "Approaching your quota limit." + showLimitWindow(title: title, subtitle: body, badgeIcon: icon, badgeColor: color, near: statusItem) + } + + static func showLimitReset(windowName: String, near statusItem: NSStatusItem?) { + showLimitWindow( + title: "Claude \(windowName) limit reset", + subtitle: "Your quota has been reset — you're good to go.", + badgeIcon: "arrow.clockwise.circle.fill", + badgeColor: .green, + near: statusItem + ) + } + + private static func showLimitWindow(title: String, subtitle: String, badgeIcon: String, badgeColor: Color, near statusItem: NSStatusItem?) { + sharedLimit?.close() + let content = UpdateNotificationView( + version: "", subtitle: subtitle, isUpdate: false, + titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor + ) { + sharedLimit?.close() + sharedLimit = nil + } + let controller = makeWindow(content: content) + position(window: controller.window!, near: statusItem, existingWindow: shared?.window) + sharedLimit = controller + controller.showWindow(nil) + autoDismiss(controller: controller, slot: .limit) + } + private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) { shared?.close() - let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) { shared?.close() shared = nil } + let controller = makeWindow(content: content) + position(window: controller.window!, near: statusItem, existingWindow: nil) + shared = controller + controller.showWindow(nil) + autoDismiss(controller: controller, slot: .update) + } + private static func makeWindow(content: V) -> UpdateNotificationWindowController { let hosting = NSHostingController(rootView: content) - let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 340, height: 80), styleMask: [.borderless, .nonactivatingPanel], @@ -37,31 +77,39 @@ class UpdateNotificationWindowController: NSWindowController { window.level = .statusBar window.hasShadow = false window.contentViewController = hosting + return UpdateNotificationWindowController(window: window) + } + private static func position(window: NSWindow, near statusItem: NSStatusItem?, existingWindow: NSWindow?) { if let button = statusItem?.button, let buttonWindow = button.window, let screen = buttonWindow.screen { let buttonFrame = buttonWindow.convertToScreen(button.frame) let x = buttonFrame.midX - 170 - let y = buttonFrame.minY - 80 - 8 + // Stack below the update notification if it's showing + let yOffset: CGFloat = existingWindow != nil ? 80 + 8 + 8 : 8 + let y = buttonFrame.minY - 80 - yOffset let clampedX = max(8, min(x, screen.frame.maxX - 348)) window.setFrameOrigin(NSPoint(x: clampedX, y: y)) } else { window.center() } + } - let controller = UpdateNotificationWindowController(window: window) - shared = controller - controller.showWindow(nil) + private enum NotificationSlot { case update, limit } - // Auto-dismiss after 8 seconds + private static func autoDismiss(controller: UpdateNotificationWindowController, slot: NotificationSlot) { + let window = controller.window! DispatchQueue.main.asyncAfter(deadline: .now() + 8) { NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.3 window.animator().alphaValue = 0 } completionHandler: { controller.close() - if shared === controller { shared = nil } + switch slot { + case .update: if shared === controller { shared = nil } + case .limit: if sharedLimit === controller { sharedLimit = nil } + } } } } @@ -73,8 +121,21 @@ struct UpdateNotificationView: View { let version: String let subtitle: String let isUpdate: Bool + var titleText: String? = nil + var badgeIcon: String? = nil + var badgeColor: Color? = nil let onDismiss: () -> Void + private var effectiveTitle: String { + titleText ?? (isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓") + } + private var effectiveBadgeIcon: String { + badgeIcon ?? (isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill") + } + private var effectiveBadgeColor: Color { + badgeColor ?? (isUpdate ? .blue : .green) + } + var body: some View { HStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { @@ -83,15 +144,15 @@ struct UpdateNotificationView: View { .frame(width: 44, height: 44) .cornerRadius(10) - Image(systemName: isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill") + Image(systemName: effectiveBadgeIcon) .font(.system(size: 16, weight: .semibold)) - .foregroundColor(isUpdate ? .blue : .green) + .foregroundColor(effectiveBadgeColor) .background(Color(nsColor: .windowBackgroundColor).clipShape(Circle())) .offset(x: 4, y: 4) } VStack(alignment: .leading, spacing: 3) { - Text(isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓") + Text(effectiveTitle) .font(.system(size: 13, weight: .semibold)) Text(subtitle) .font(.system(size: 11)) diff --git a/ClaudeChecker/UsageViewModel.swift b/ClaudeChecker/UsageViewModel.swift index db93e80..e4ff3ea 100644 --- a/ClaudeChecker/UsageViewModel.swift +++ b/ClaudeChecker/UsageViewModel.swift @@ -1,6 +1,5 @@ import SwiftUI import WebKit -import UserNotifications @MainActor class UsageViewModel: ObservableObject { @@ -241,7 +240,6 @@ class UsageViewModel: ObservableObject { // MARK: - Limit notifications private func checkLimitNotifications(for limits: [AgentLimit]) { - let center = UNUserNotificationCenter.current() let thresholds = [80, 95] for limit in limits { @@ -255,26 +253,21 @@ class UsageViewModel: ObservableObject { 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)) + NotificationCenter.default.post( + name: .limitWarning, + object: nil, + userInfo: ["windowName": limit.window.displayName, "percent": t] + ) } // 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 - )) + NotificationCenter.default.post( + name: .limitReset, + object: nil, + userInfo: ["windowName": limit.window.displayName] + ) } firedThresholds[key] = fired @@ -315,4 +308,6 @@ extension Notification.Name { static let closePopover = Notification.Name("closePopover") static let showPopover = Notification.Name("showPopover") static let updateDetected = Notification.Name("updateDetected") + static let limitWarning = Notification.Name("limitWarning") + static let limitReset = Notification.Name("limitReset") }