v2.0.9 — limit notifications using custom floating window (same style as update notifications)

This commit is contained in:
superdooper86
2026-05-07 15:44:38 +02:00
parent b95102cb1b
commit 3d1bae3d0a
3 changed files with 101 additions and 39 deletions
+17 -11
View File
@@ -1,7 +1,6 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
import Combine import Combine
import UserNotifications
@main @main
struct ClaudeCheckerApp: App { struct ClaudeCheckerApp: App {
@@ -12,7 +11,7 @@ struct ClaudeCheckerApp: App {
} }
@MainActor @MainActor
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem? var statusItem: NSStatusItem?
var popover: NSPopover? var popover: NSPopover?
var usageViewModel = UsageViewModel() var usageViewModel = UsageViewModel()
@@ -24,10 +23,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
let center = UNUserNotificationCenter.current()
center.delegate = self
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
// Hidden WKWebView to prime the shared cookie store // Hidden WKWebView to prime the shared cookie store
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() 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 NotificationCenter.default.addObserver(forName: .closePopover, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.popover?.performClose(nil) self?.popover?.performClose(nil)
@@ -211,9 +222,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
refreshTimer?.invalidate() refreshTimer?.invalidate()
} }
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler handler: @escaping (UNNotificationPresentationOptions) -> Void) {
handler([.banner, .sound])
}
} }
+72 -11
View File
@@ -5,6 +5,7 @@ import AppKit
class UpdateNotificationWindowController: NSWindowController { class UpdateNotificationWindowController: NSWindowController {
static var shared: UpdateNotificationWindowController? static var shared: UpdateNotificationWindowController?
static var sharedLimit: UpdateNotificationWindowController?
// Called after successful install + relaunch // Called after successful install + relaunch
static func show(version: String, notes: String, near statusItem: NSStatusItem?) { 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) 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) { private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
shared?.close() shared?.close()
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) { let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
shared?.close() shared?.close()
shared = nil 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<V: View>(content: V) -> UpdateNotificationWindowController {
let hosting = NSHostingController(rootView: content) let hosting = NSHostingController(rootView: content)
let window = NSWindow( let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 340, height: 80), contentRect: NSRect(x: 0, y: 0, width: 340, height: 80),
styleMask: [.borderless, .nonactivatingPanel], styleMask: [.borderless, .nonactivatingPanel],
@@ -37,31 +77,39 @@ class UpdateNotificationWindowController: NSWindowController {
window.level = .statusBar window.level = .statusBar
window.hasShadow = false window.hasShadow = false
window.contentViewController = hosting window.contentViewController = hosting
return UpdateNotificationWindowController(window: window)
}
private static func position(window: NSWindow, near statusItem: NSStatusItem?, existingWindow: NSWindow?) {
if let button = statusItem?.button, if let button = statusItem?.button,
let buttonWindow = button.window, let buttonWindow = button.window,
let screen = buttonWindow.screen { let screen = buttonWindow.screen {
let buttonFrame = buttonWindow.convertToScreen(button.frame) let buttonFrame = buttonWindow.convertToScreen(button.frame)
let x = buttonFrame.midX - 170 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)) let clampedX = max(8, min(x, screen.frame.maxX - 348))
window.setFrameOrigin(NSPoint(x: clampedX, y: y)) window.setFrameOrigin(NSPoint(x: clampedX, y: y))
} else { } else {
window.center() window.center()
} }
}
let controller = UpdateNotificationWindowController(window: window) private enum NotificationSlot { case update, limit }
shared = controller
controller.showWindow(nil)
// Auto-dismiss after 8 seconds private static func autoDismiss(controller: UpdateNotificationWindowController, slot: NotificationSlot) {
let window = controller.window!
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
NSAnimationContext.runAnimationGroup { ctx in NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.3 ctx.duration = 0.3
window.animator().alphaValue = 0 window.animator().alphaValue = 0
} completionHandler: { } completionHandler: {
controller.close() 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 version: String
let subtitle: String let subtitle: String
let isUpdate: Bool let isUpdate: Bool
var titleText: String? = nil
var badgeIcon: String? = nil
var badgeColor: Color? = nil
let onDismiss: () -> Void 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 { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
@@ -83,15 +144,15 @@ struct UpdateNotificationView: View {
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.cornerRadius(10) .cornerRadius(10)
Image(systemName: isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill") Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundColor(isUpdate ? .blue : .green) .foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle())) .background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4) .offset(x: 4, y: 4)
} }
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(isUpdate ? "Update available — v\(version)" : "Updated to v\(version)") Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Text(subtitle) Text(subtitle)
.font(.system(size: 11)) .font(.system(size: 11))
+12 -17
View File
@@ -1,6 +1,5 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
import UserNotifications
@MainActor @MainActor
class UsageViewModel: ObservableObject { class UsageViewModel: ObservableObject {
@@ -241,7 +240,6 @@ class UsageViewModel: ObservableObject {
// MARK: - Limit notifications // MARK: - Limit notifications
private func checkLimitNotifications(for limits: [AgentLimit]) { private func checkLimitNotifications(for limits: [AgentLimit]) {
let center = UNUserNotificationCenter.current()
let thresholds = [80, 95] let thresholds = [80, 95]
for limit in limits { for limit in limits {
@@ -255,26 +253,21 @@ class UsageViewModel: ObservableObject {
let threshold = Double(t) let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue } guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
fired.insert(t) fired.insert(t)
let content = UNMutableNotificationContent() NotificationCenter.default.post(
content.title = "Claude \(limit.window.displayName) limit" name: .limitWarning,
content.body = t == 95 object: nil,
? "\(Int(curr.rounded()))% used — almost at your limit" userInfo: ["windowName": limit.window.displayName, "percent": t]
: "\(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 // Reset detection: was high, now low
if let prev, prev > 50, curr < 20 { if let prev, prev > 50, curr < 20 {
fired.removeAll() fired.removeAll()
let content = UNMutableNotificationContent() NotificationCenter.default.post(
content.title = "Claude \(limit.window.displayName) limit reset" name: .limitReset,
content.body = "Your \(limit.window.displayName) quota has been reset" object: nil,
content.sound = .default userInfo: ["windowName": limit.window.displayName]
center.add(UNNotificationRequest( )
identifier: "cc_reset_\(key)_\(Int(Date().timeIntervalSince1970))",
content: content, trigger: nil
))
} }
firedThresholds[key] = fired firedThresholds[key] = fired
@@ -315,4 +308,6 @@ extension Notification.Name {
static let closePopover = Notification.Name("closePopover") static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover") static let showPopover = Notification.Name("showPopover")
static let updateDetected = Notification.Name("updateDetected") static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset")
} }