v2.0.9 — limit notifications using custom floating window (same style as update notifications)
This commit is contained in:
@@ -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])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user