mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
Add real quota gauges with threshold bands and a reset countdown for quota-capable accounts. Introduce a pure, deterministic alert rule engine (CCSBarCore) covering quota-remaining, daily/monthly spend caps, reauth, and cooldown, with per-rule dedupe keys that re-arm on period rollover or clears-then-recurs so it never spams. Deliver via UNUserNotificationCenter with an in-dropdown Alerts fallback when notifications are denied. Add a preferences surface (per-rule toggles, caps, quota levels, glance mode) persisted in UserDefaults, and a configurable menu-bar glance (auto/today/month/lowest-quota/account-count) that still never shows a lifetime dollar. Default spend caps raised to $500/day and $10000/month.
85 lines
3.2 KiB
Swift
85 lines
3.2 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
import CCSBarCore
|
|
|
|
/// Real notification delivery backed by `UNUserNotificationCenter`.
|
|
///
|
|
/// Authorization is requested LAZILY on the first non-empty deliver, not at
|
|
/// launch. Ad-hoc-signed menu-bar apps launched via `open` get a flaky / silently
|
|
/// dropped prompt when authorization is requested during startup, so we defer the
|
|
/// request until there is actually something to show — the prompt then lands with
|
|
/// user-visible context.
|
|
///
|
|
/// When authorization is denied, `deliver` is a no-op, but the rule engine keeps
|
|
/// updating its fired-keys regardless (the App persists them independent of
|
|
/// delivery). That avoids a backlog of stale alerts replaying if the user later
|
|
/// grants permission — only conditions still true at that later poll re-fire.
|
|
@MainActor
|
|
final class BarNotifier: NotificationDelivering {
|
|
enum AuthState {
|
|
case unknown
|
|
case authorized
|
|
case denied
|
|
}
|
|
|
|
private let center: UNUserNotificationCenter?
|
|
private var authState: AuthState = .unknown
|
|
private var didRequest = false
|
|
|
|
/// `UNUserNotificationCenter.current()` traps when there is no main bundle
|
|
/// identifier (e.g. a bare `swift run` with no .app wrapper). Guard it so the
|
|
/// wiring still compiles and runs headlessly; delivery is simply a no-op there.
|
|
init() {
|
|
if Bundle.main.bundleIdentifier != nil {
|
|
center = UNUserNotificationCenter.current()
|
|
} else {
|
|
center = nil
|
|
}
|
|
}
|
|
|
|
/// Deliver one notification. The first call with a notification triggers a
|
|
/// one-time authorization request; subsequent calls reuse the cached state.
|
|
nonisolated func deliver(_ notification: BarNotification) {
|
|
Task { @MainActor in
|
|
self.send(notification)
|
|
}
|
|
}
|
|
|
|
private func send(_ notification: BarNotification) {
|
|
guard let center else { return }
|
|
|
|
if !didRequest {
|
|
didRequest = true
|
|
// Request once, lazily. The completion updates cached state; this in-flight
|
|
// notification is enqueued after, so an accepted prompt still shows it.
|
|
center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
|
|
// Hop back to the main actor and re-read `self.center` there rather than
|
|
// capturing the non-Sendable center across the closure boundary.
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.authState = granted ? .authorized : .denied
|
|
if granted, let c = self.center { self.post(notification, on: c) }
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Already requested: post only when authorized; a denied state is a no-op.
|
|
if authState == .authorized || authState == .unknown {
|
|
post(notification, on: center)
|
|
}
|
|
}
|
|
|
|
private func post(_ notification: BarNotification, on center: UNUserNotificationCenter) {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = notification.title
|
|
content.body = notification.body
|
|
content.sound = .default
|
|
// Identifier == fired-key so the OS de-dupes at the delivery layer too: a
|
|
// re-posted same-key request replaces rather than stacks.
|
|
let request = UNNotificationRequest(
|
|
identifier: notification.id, content: content, trigger: nil)
|
|
center.add(request, withCompletionHandler: nil)
|
|
}
|
|
}
|