codeburn/mac/Sources/CodeBurnMenubar/Data/CodexBankedResets.swift
ozymandiashh a208c25f86
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
fix(menubar): pin multi-argument catalog keys to positional specifiers
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
2026-09-15 01:33:28 +03:00

320 lines
14 KiB
Swift

import Foundation
// Banked limit-reset credits ("goodwill resets"): OpenAI grants a Codex account
// a credit that restores a rate-limit window early. They arrive without warning
// from the app's point of view — the only evidence we have is that a new credit
// appears in the `rate-limit-reset-credits` inventory we already fetch on the
// existing usage cadence. Nothing here polls, and nothing here ever spends a
// credit: this is a notice, not an action.
// MARK: - Persisted state
/// What we have already seen, so a credit announces itself exactly once — across
/// refreshes, across a failed fetch, and across a relaunch.
///
/// Persisted with the same mechanism `SubscriptionSnapshotStore` uses (one JSON
/// document in the CodeBurn cache directory, written through `SafeFile`), not a
/// second one.
struct CodexBankedResetState: Codable, Sendable, Equatable {
struct SeenCredit: Codable, Sendable, Equatable {
let id: String
let firstSeenAt: Date
}
/// Nil until the first successful observation. Its absence is what makes
/// that first observation a baseline instead of a burst of notifications
/// for every credit the account already held.
var baselineAt: Date?
var credits: [SeenCredit]
init(baselineAt: Date? = nil, credits: [SeenCredit] = []) {
self.baselineAt = baselineAt
self.credits = credits
}
}
// MARK: - Detection
/// Pure: takes an observation and the state it is judged against, returns the
/// events and the state to persist. No clock, no disk, no network.
enum CodexBankedResetDetector {
/// Ids of credits that are gone from the payload are kept this long before
/// being forgotten, so a credit that flickers out of one response and back
/// into the next cannot announce itself twice. Mirrors the 30-day retention
/// `SubscriptionSnapshotStore` prunes to.
static let retentionSeconds: TimeInterval = 30 * 24 * 3600
struct Outcome: Equatable {
/// Credits observed for the first time, in payload order. Empty for a
/// baseline observation, for an unchanged inventory, and for every
/// observation we have no opinion about.
let events: [CodexUsage.ResetCredits.Grant]
let state: CodexBankedResetState
}
/// `observed == nil` means the credits payload was absent or malformed. That
/// is "no opinion", never an event and never a state change: a reconnect
/// after a failed fetch must not re-baseline, and a schema drift must not
/// silently forget every credit we have already announced.
static func evaluate(
observed: CodexUsage.ResetCredits?,
state: CodexBankedResetState,
now: Date
) -> Outcome {
guard let observed else { return Outcome(events: [], state: state) }
// Only credits the parser could give a stable identity to are in here;
// an unidentifiable credit still counts toward `availableCount` but can
// never be told apart from the next one, so it never fires.
let grants = observed.grants
guard state.baselineAt != nil else {
// First-ever observation: everything the account holds is history,
// not news.
return Outcome(
events: [],
state: CodexBankedResetState(
baselineAt: now,
credits: grants.map { .init(id: $0.id, firstSeenAt: now) }
)
)
}
var knownIDs = Set(state.credits.map(\.id))
var credits = state.credits
var events: [CodexUsage.ResetCredits.Grant] = []
for grant in grants where knownIDs.insert(grant.id).inserted {
events.append(grant)
credits.append(.init(id: grant.id, firstSeenAt: now))
}
// A credit that disappears was consumed or expired — never an event. Its
// id is held for the retention window so it cannot come back as news.
let observedIDs = Set(grants.map(\.id))
let cutoff = now.addingTimeInterval(-retentionSeconds)
credits = credits.filter { observedIDs.contains($0.id) || $0.firstSeenAt >= cutoff }
return Outcome(events: events, state: CodexBankedResetState(baselineAt: state.baselineAt, credits: credits))
}
}
// MARK: - Wording
/// The one place the banked-reset wording lives. `src/quota/codex.ts` carries a
/// line-for-line mirror of `detail` and `compactAge` so the CLI and the menubar
/// say the same sentence.
enum CodexBankedResetPresentation {
/// Label the detail hangs off, in the Plan tab and in front of the detail on
/// every other surface.
static var rowLabel: String { L("Limit resets") }
/// `2 available · 1 usable now · latest weekly reset granted 2h ago · next expires in 16h`
/// Nil when the account holds nothing — the row hides rather than printing a zero.
static func detail(_ credits: CodexUsage.ResetCredits, now: Date) -> String? {
guard credits.availableCount > 0 else { return nil }
var parts = [L("%lld available", credits.availableCount)]
// Only worth saying when it disagrees with the headline count: equal
// numbers would just be the same fact twice.
if let applicable = credits.applicableAvailableCount, applicable != credits.availableCount {
parts.append(L("%lld usable now", applicable))
}
if let grant = credits.latestGrant, let grantedAt = grant.grantedAt {
let type = resetTypeLabel(grant.resetType)
parts.append(grantedAt > now
? L("next %1$@ lands %2$@", type, compactAge(of: grantedAt, now: now))
: L("latest %1$@ granted %2$@", type, compactAge(of: grantedAt, now: now)))
}
if let expiry = credits.nextExpiresAt {
parts.append(L("next expires %@", compactAge(of: expiry, now: now)))
}
return parts.joined(separator: " · ")
}
/// `Limit resets · 2 available · …`, the single-string form the quota hover
/// card and `codeburn quota` both print.
static func line(_ credits: CodexUsage.ResetCredits, now: Date) -> String? {
detail(credits, now: now).map { "\(rowLabel) · \($0)" }
}
/// Notification copy for one newly observed credit: what was granted, when,
/// and how many the account can use. Never the credit id — it is an account
/// identifier of sorts and says nothing to the user.
static func notice(
for grant: CodexUsage.ResetCredits.Grant,
credits: CodexUsage.ResetCredits,
now: Date
) -> (title: String, body: String) {
let type = resetTypeLabel(grant.resetType)
var body: String
if let grantedAt = grant.grantedAt {
body = grantedAt > now
? L("A %1$@ lands %2$@.", type, compactAge(of: grantedAt, now: now))
: L("A %1$@ was added to your account %2$@.", type, compactAge(of: grantedAt, now: now))
} else {
body = L("A %@ was added to your account.", type)
}
// `applicable_available_count` is the number that answers "can I use one
// right now"; the plain count is the fallback when it is absent.
let usable = credits.applicableAvailableCount ?? credits.availableCount
body += " " + L("You have %lld available to use.", usable)
return (L("Codex banked a limit reset"), body)
}
/// "weekly" -> "weekly reset". An absent or empty `reset_type` degrades to
/// the bare noun rather than inventing a scope the payload did not state.
///
/// `reset_type` is vendor data with no fixed vocabulary, so only the cadence
/// words OpenAI has actually sent are translated; anything else rides
/// through verbatim inside a translated frame.
static func resetTypeLabel(_ resetType: String?) -> String {
let raw = (resetType ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.lowercased()
guard !raw.isEmpty else { return L("limit reset") }
let cadence = switch raw {
case "weekly": L("weekly")
case "daily": L("daily")
case "monthly": L("monthly")
case "hourly": L("hourly")
default: raw
}
return L("%@ reset", cadence)
}
/// Deliberately not `RelativeDateTimeFormatter`: the English form has to
/// come out character-identical in Swift and in TypeScript, so the rules
/// are spelled out instead of delegated to a locale-aware formatter. The
/// catalog keeps that: `en` is an identity table, so English is the key.
/// Other locales diverge from the CLI, which is not localized.
static func compactAge(of date: Date, now: Date) -> String {
let elapsed = now.timeIntervalSince(date)
if elapsed >= 0 {
if elapsed < 60 { return L("just now") }
if elapsed < 3600 { return L("%lldm ago", Int(elapsed / 60)) }
if elapsed < 86_400 { return L("%lldh ago", Int(elapsed / 3600)) }
return L("%lldd ago", Int(elapsed / 86_400))
}
let ahead = -elapsed
if ahead < 60 { return L("in under a minute") }
if ahead < 3600 { return L("in %lldm", Int(ahead / 60)) }
if ahead < 86_400 { return L("in %lldh", Int(ahead / 3600)) }
return L("in %lldd", Int(ahead / 86_400))
}
}
// MARK: - Persistence
protocol CodexBankedResetStateStoring: Sendable {
func load() async -> CodexBankedResetState
func save(_ state: CodexBankedResetState) async
}
private let bankedResetFilename = "codex-banked-resets.json"
private func bankedResetPath() -> String {
(CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent(bankedResetFilename)
}
private actor BankedResetLock {
static let shared = BankedResetLock()
func run<T>(_ fn: () throws -> T) rethrows -> T { try fn() }
}
/// Same shape as `SubscriptionSnapshotStore`: one JSON document in the CodeBurn
/// cache directory, serialized behind an actor, written 0600 through `SafeFile`
/// (which refuses a symlinked target and does the tmp+rename dance).
struct CodexBankedResetStore: CodexBankedResetStateStoring {
func load() async -> CodexBankedResetState {
await BankedResetLock.shared.run {
let path = bankedResetPath()
guard FileManager.default.fileExists(atPath: path),
let data = try? SafeFile.read(from: path) else { return CodexBankedResetState() }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode(CodexBankedResetState.self, from: data)) ?? CodexBankedResetState()
}
}
func save(_ state: CodexBankedResetState) async {
await BankedResetLock.shared.run {
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try SafeFile.write(encoder.encode(state), to: bankedResetPath(), mode: 0o600)
} catch {
NSLog("CodeBurn: codex banked-reset state write failed: %@", String(describing: error))
}
}
}
/// Called on disconnect, so a reconnect under a different account baselines
/// again instead of announcing that account's whole inventory.
static func clearAll() async {
await BankedResetLock.shared.run {
try? FileManager.default.removeItem(atPath: bankedResetPath())
}
}
}
// MARK: - Preference
/// Absent key is true, matching `UpdateNotificationPreference`: existing installs
/// get the notice without visiting Settings first.
enum CodexBankedResetNotificationPreference {
static let defaultsKey = "codeburn.codex.bankedResetNotificationsEnabled"
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
defaults.object(forKey: defaultsKey) as? Bool ?? true
}
}
// MARK: - Announcer
/// Wires the pure detector to the existing notification path. Owns no fetch: it
/// is handed whatever the usage refresh already decoded.
@MainActor
final class CodexBankedResetAnnouncer {
private let defaults: UserDefaults
private let store: any CodexBankedResetStateStoring
private let makeNotifier: () -> any UpdateNotifier
private var notifier: (any UpdateNotifier)?
private var isObserving = false
init(
defaults: UserDefaults = .standard,
store: any CodexBankedResetStateStoring = CodexBankedResetStore(),
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() }
) {
self.defaults = defaults
self.store = store
self.makeNotifier = makeNotifier
}
func observe(_ credits: CodexUsage.ResetCredits?, now: Date = Date()) async {
// The load/save pair spans two awaits; a second refresh landing inside
// it would judge against pre-save state and post twice. Skipping is
// free — the next refresh observes the same inventory.
guard !isObserving else { return }
isObserving = true
defer { isObserving = false }
let state = await store.load()
let outcome = CodexBankedResetDetector.evaluate(observed: credits, state: state, now: now)
guard outcome.state != state else { return }
// Persisted before the post, not after: a notice we drop is a smaller
// failure than one that repeats on every refresh.
await store.save(outcome.state)
guard !outcome.events.isEmpty, let credits else { return }
// The state is recorded either way, so turning the toggle back on does
// not release a backlog of grants that landed while it was off.
guard CodexBankedResetNotificationPreference.isEnabled(defaults: defaults) else { return }
let notifier = notifier ?? makeNotifier()
self.notifier = notifier
guard await notifier.requestAuthorizationIfNeeded() else { return }
for event in outcome.events {
let copy = CodexBankedResetPresentation.notice(for: event, credits: credits, now: now)
notifier.post(title: copy.title, body: copy.body, identifier: "CodexBankedReset.\(event.id)")
}
}
}