|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// User preference for quota-crossing notifications. Absent key is true, |
| 4 | +/// matching `UpdateNotificationPreference`: existing installs get the alert |
| 5 | +/// without visiting Settings first. |
| 6 | +enum QuotaCrossingPreference { |
| 7 | + static let defaultsKey = "codeburn.quota.crossingNotificationsEnabled" |
| 8 | + |
| 9 | + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { |
| 10 | + defaults.object(forKey: defaultsKey) as? Bool ?? true |
| 11 | + } |
| 12 | +} |
| 13 | + |
| 14 | +/// One provider window as `AppStore.quotaWindows` reports it: the same list the |
| 15 | +/// warning banner and the menu-bar flame are built from. |
| 16 | +struct QuotaCrossingWindow: Equatable, Sendable { |
| 17 | + let providerName: String |
| 18 | + /// The adapter's own label ("5-hour", "Weekly · Opus"), never translated. |
| 19 | + let label: String |
| 20 | + /// Share of the window used, 0...100; nil when the provider did not report it. |
| 21 | + let percent: Double? |
| 22 | + let resetsAt: Date? |
| 23 | + |
| 24 | + /// One cycle of one window. A window that carries no reset instant cannot |
| 25 | + /// tell one cycle from the next, so it notifies once and then stays quiet. |
| 26 | + var cycleKey: String { |
| 27 | + let stamp = resetsAt.map { String(Int($0.timeIntervalSince1970.rounded())) } ?? "-" |
| 28 | + return "\(providerName)|\(label)|\(stamp)" |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +struct QuotaCrossingEvent: Equatable, Sendable { |
| 33 | + enum Level: String, Sendable { |
| 34 | + case warning |
| 35 | + case limit |
| 36 | + } |
| 37 | + |
| 38 | + let providerName: String |
| 39 | + let windowLabel: String |
| 40 | + let level: Level |
| 41 | + /// Fired-set entry and notification identifier: one per level per cycle. |
| 42 | + let key: String |
| 43 | + |
| 44 | + var notificationTitle: String { |
| 45 | + switch level { |
| 46 | + case .warning: L("%@ · %@ at 80%%", providerName, windowLabel) |
| 47 | + case .limit: L("%@ · %@ limit reached", providerName, windowLabel) |
| 48 | + } |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +/// Decides which windows crossed a notify-worthy threshold. Pure: the fired set |
| 53 | +/// goes in and comes back out, so the caller owns persistence and nothing here |
| 54 | +/// reads a clock or a default. |
| 55 | +enum QuotaCrossingDetector { |
| 56 | + static let warningPercent: Double = 80 |
| 57 | + static let limitPercent: Double = 100 |
| 58 | + |
| 59 | + static func evaluate( |
| 60 | + windows: [QuotaCrossingWindow], |
| 61 | + fired: Set<String> |
| 62 | + ) -> (events: [QuotaCrossingEvent], fired: Set<String>) { |
| 63 | + var live = fired |
| 64 | + for window in windows { |
| 65 | + // A window now on a different cycle is re-armed; absence is not a |
| 66 | + // reset, so a provider between fetches keeps what it has fired. |
| 67 | + let prefix = "\(window.providerName)|\(window.label)|" |
| 68 | + let current = window.cycleKey + "|" |
| 69 | + live = live.filter { !$0.hasPrefix(prefix) || $0.hasPrefix(current) } |
| 70 | + } |
| 71 | + |
| 72 | + var events: [QuotaCrossingEvent] = [] |
| 73 | + for window in windows { |
| 74 | + guard let percent = window.percent, percent.isFinite else { continue } |
| 75 | + guard let level = level(for: percent) else { continue } |
| 76 | + let key = "\(window.cycleKey)|\(level.rawValue)" |
| 77 | + guard live.insert(key).inserted else { continue } |
| 78 | + // Once the window is full the 80% notice is water under the bridge: |
| 79 | + // marking it fired keeps a later reading from posting it after the |
| 80 | + // louder one. |
| 81 | + if level == .limit { |
| 82 | + live.insert("\(window.cycleKey)|\(QuotaCrossingEvent.Level.warning.rawValue)") |
| 83 | + } |
| 84 | + events.append(QuotaCrossingEvent( |
| 85 | + providerName: window.providerName, |
| 86 | + windowLabel: window.label, |
| 87 | + level: level, |
| 88 | + key: key |
| 89 | + )) |
| 90 | + } |
| 91 | + return (events, live) |
| 92 | + } |
| 93 | + |
| 94 | + private static func level(for percent: Double) -> QuotaCrossingEvent.Level? { |
| 95 | + if percent >= limitPercent { return .limit } |
| 96 | + if percent >= warningPercent { return .warning } |
| 97 | + return nil |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +/// Runs the detector over each quota refresh and posts through the same notifier |
| 102 | +/// the update check uses. It adds no polling: the caller invokes it from the |
| 103 | +/// existing refresh lifecycle. The fired set lives in `UserDefaults` so a |
| 104 | +/// relaunch does not repeat a crossing already announced. |
| 105 | +@MainActor |
| 106 | +final class QuotaCrossingMonitor { |
| 107 | + static let defaultsKey = "codeburn.quota.crossingsFired" |
| 108 | + nonisolated static let notificationIdentifierPrefix = "QuotaCrossing." |
| 109 | + |
| 110 | + private let defaults: UserDefaults |
| 111 | + private let makeNotifier: () -> any UpdateNotifier |
| 112 | + private var notifier: (any UpdateNotifier)? |
| 113 | + |
| 114 | + init( |
| 115 | + defaults: UserDefaults = .standard, |
| 116 | + makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() } |
| 117 | + ) { |
| 118 | + self.defaults = defaults |
| 119 | + self.makeNotifier = makeNotifier |
| 120 | + } |
| 121 | + |
| 122 | + @discardableResult |
| 123 | + func record(windows: [QuotaCrossingWindow]) async -> [QuotaCrossingEvent] { |
| 124 | + let stored = Set(defaults.stringArray(forKey: Self.defaultsKey) ?? []) |
| 125 | + let (events, fired) = QuotaCrossingDetector.evaluate(windows: windows, fired: stored) |
| 126 | + // Persisted before delivery: a crossing is one-shot even when the user |
| 127 | + // has notifications off or denied. |
| 128 | + defaults.set(Array(fired), forKey: Self.defaultsKey) |
| 129 | + |
| 130 | + guard !events.isEmpty, QuotaCrossingPreference.isEnabled(defaults: defaults) else { return [] } |
| 131 | + let notifier = notifier ?? makeNotifier() |
| 132 | + self.notifier = notifier |
| 133 | + // Authorization is requested here and nowhere else, so a user who never |
| 134 | + // crosses a threshold is never asked. |
| 135 | + guard await notifier.requestAuthorizationIfNeeded() else { return [] } |
| 136 | + for event in events { |
| 137 | + notifier.post( |
| 138 | + title: event.notificationTitle, |
| 139 | + body: "", |
| 140 | + identifier: Self.notificationIdentifierPrefix + event.key |
| 141 | + ) |
| 142 | + } |
| 143 | + return events |
| 144 | + } |
| 145 | +} |
0 commit comments