mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-27 01:00:36 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
The catalog comparison matched specifier lists, so a key whose placeholders are all %@ passed even when the Chinese value bound the arguments into the wrong slots — the drift that shipped in three early-reset keys and was only caught by rendering real data bilingually (#1331). All 42 multi-argument keys in both tables now use positional specifiers (%1$@, %2$lld, ...); the parity check compares slot to verb so Chinese word order stays free while each slot keeps its type; a new test fails on any multi-argument key that is not positional. The source scanner closes its three expression gaps: ternary branches, concatenated literals, and let-bound literals passed by name. Text(verbatim:) stays the explicit opt-out; the four provider-name fallbacks the new rules surfaced in HeatmapSection route through the catalog as verbatim entries. Closes #1331
145 lines
5.7 KiB
Swift
145 lines
5.7 KiB
Swift
import Foundation
|
|
|
|
/// User preference for quota-crossing notifications. Absent key is true,
|
|
/// matching `UpdateNotificationPreference`: existing installs get the alert
|
|
/// without visiting Settings first.
|
|
enum QuotaCrossingPreference {
|
|
static let defaultsKey = "codeburn.quota.crossingNotificationsEnabled"
|
|
|
|
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
defaults.object(forKey: defaultsKey) as? Bool ?? true
|
|
}
|
|
}
|
|
|
|
/// One provider window as `AppStore.quotaWindows` reports it: the same list the
|
|
/// warning banner and the menu-bar flame are built from.
|
|
struct QuotaCrossingWindow: Equatable, Sendable {
|
|
let providerName: String
|
|
/// The adapter's own label ("5-hour", "Weekly · Opus"), never translated.
|
|
let label: String
|
|
/// Share of the window used, 0...100; nil when the provider did not report it.
|
|
let percent: Double?
|
|
let resetsAt: Date?
|
|
|
|
/// One cycle of one window. A window that carries no reset instant cannot
|
|
/// tell one cycle from the next, so it notifies once and then stays quiet.
|
|
var cycleKey: String {
|
|
let stamp = resetsAt.map { String(Int($0.timeIntervalSince1970.rounded())) } ?? "-"
|
|
return "\(providerName)|\(label)|\(stamp)"
|
|
}
|
|
}
|
|
|
|
struct QuotaCrossingEvent: Equatable, Sendable {
|
|
enum Level: String, Sendable {
|
|
case warning
|
|
case limit
|
|
}
|
|
|
|
let providerName: String
|
|
let windowLabel: String
|
|
let level: Level
|
|
/// Fired-set entry and notification identifier: one per level per cycle.
|
|
let key: String
|
|
|
|
var notificationTitle: String {
|
|
switch level {
|
|
case .warning: L("%1$@ · %2$@ at 80%%", providerName, windowLabel)
|
|
case .limit: L("%1$@ · %2$@ limit reached", providerName, windowLabel)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decides which windows crossed a notify-worthy threshold. Pure: the fired set
|
|
/// goes in and comes back out, so the caller owns persistence and nothing here
|
|
/// reads a clock or a default.
|
|
enum QuotaCrossingDetector {
|
|
static let warningPercent: Double = 80
|
|
static let limitPercent: Double = 100
|
|
|
|
static func evaluate(
|
|
windows: [QuotaCrossingWindow],
|
|
fired: Set<String>
|
|
) -> (events: [QuotaCrossingEvent], fired: Set<String>) {
|
|
var live = fired
|
|
for window in windows {
|
|
// A window now on a different cycle is re-armed; absence is not a
|
|
// reset, so a provider between fetches keeps what it has fired.
|
|
let prefix = "\(window.providerName)|\(window.label)|"
|
|
let current = window.cycleKey + "|"
|
|
live = live.filter { !$0.hasPrefix(prefix) || $0.hasPrefix(current) }
|
|
}
|
|
|
|
var events: [QuotaCrossingEvent] = []
|
|
for window in windows {
|
|
guard let percent = window.percent, percent.isFinite else { continue }
|
|
guard let level = level(for: percent) else { continue }
|
|
let key = "\(window.cycleKey)|\(level.rawValue)"
|
|
guard live.insert(key).inserted else { continue }
|
|
// Once the window is full the 80% notice is water under the bridge:
|
|
// marking it fired keeps a later reading from posting it after the
|
|
// louder one.
|
|
if level == .limit {
|
|
live.insert("\(window.cycleKey)|\(QuotaCrossingEvent.Level.warning.rawValue)")
|
|
}
|
|
events.append(QuotaCrossingEvent(
|
|
providerName: window.providerName,
|
|
windowLabel: window.label,
|
|
level: level,
|
|
key: key
|
|
))
|
|
}
|
|
return (events, live)
|
|
}
|
|
|
|
private static func level(for percent: Double) -> QuotaCrossingEvent.Level? {
|
|
if percent >= limitPercent { return .limit }
|
|
if percent >= warningPercent { return .warning }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Runs the detector over each quota refresh and posts through the same notifier
|
|
/// the update check uses. It adds no polling: the caller invokes it from the
|
|
/// existing refresh lifecycle. The fired set lives in `UserDefaults` so a
|
|
/// relaunch does not repeat a crossing already announced.
|
|
@MainActor
|
|
final class QuotaCrossingMonitor {
|
|
static let defaultsKey = "codeburn.quota.crossingsFired"
|
|
nonisolated static let notificationIdentifierPrefix = "QuotaCrossing."
|
|
|
|
private let defaults: UserDefaults
|
|
private let makeNotifier: () -> any UpdateNotifier
|
|
private var notifier: (any UpdateNotifier)?
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() }
|
|
) {
|
|
self.defaults = defaults
|
|
self.makeNotifier = makeNotifier
|
|
}
|
|
|
|
@discardableResult
|
|
func record(windows: [QuotaCrossingWindow]) async -> [QuotaCrossingEvent] {
|
|
let stored = Set(defaults.stringArray(forKey: Self.defaultsKey) ?? [])
|
|
let (events, fired) = QuotaCrossingDetector.evaluate(windows: windows, fired: stored)
|
|
// Persisted before delivery: a crossing is one-shot even when the user
|
|
// has notifications off or denied.
|
|
defaults.set(Array(fired), forKey: Self.defaultsKey)
|
|
|
|
guard !events.isEmpty, QuotaCrossingPreference.isEnabled(defaults: defaults) else { return [] }
|
|
let notifier = notifier ?? makeNotifier()
|
|
self.notifier = notifier
|
|
// Authorization is requested here and nowhere else, so a user who never
|
|
// crosses a threshold is never asked.
|
|
guard await notifier.requestAuthorizationIfNeeded() else { return [] }
|
|
for event in events {
|
|
notifier.post(
|
|
title: event.notificationTitle,
|
|
body: "",
|
|
identifier: Self.notificationIdentifierPrefix + event.key
|
|
)
|
|
}
|
|
return events
|
|
}
|
|
}
|