diff --git a/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift b/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift index 0811b2df5..05dacf96c 100644 --- a/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift +++ b/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift @@ -111,10 +111,21 @@ extension UsageLimitsResponse { enum UsageLimitsCache { static let defaultsKey = "UsageLimitsLastGoodResponse" + private static let maximumFutureSkew: TimeInterval = 5 * 60 - static func load(defaults: UserDefaults = .standard) -> UsageLimitsResponse? { + static func load( + defaults: UserDefaults = .standard, + now: Date = Date() + ) -> UsageLimitsResponse? { guard let data = defaults.data(forKey: defaultsKey) else { return nil } - return try? JSONDecoder().decode(UsageLimitsResponse.self, from: data) + guard let limits = try? JSONDecoder().decode(UsageLimitsResponse.self, from: data) else { + return nil + } + if let fetchedAt = parseTimestamp(limits.fetchedAt), + fetchedAt.timeIntervalSince(now) > maximumFutureSkew { + return nil + } + return limits } static func save( @@ -125,6 +136,14 @@ enum UsageLimitsCache { let data = try? JSONEncoder().encode(limits) else { return } defaults.set(data, forKey: defaultsKey) } + + private static func parseTimestamp(_ rawValue: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: rawValue) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: rawValue) + } } struct ClaudeLimits: Codable, Equatable { diff --git a/TokenTrackerBar/TokenTrackerBar/Services/APIClient.swift b/TokenTrackerBar/TokenTrackerBar/Services/APIClient.swift index a4f3c4765..adde08b4a 100644 --- a/TokenTrackerBar/TokenTrackerBar/Services/APIClient.swift +++ b/TokenTrackerBar/TokenTrackerBar/Services/APIClient.swift @@ -28,6 +28,10 @@ actor APIClient { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 30 + // Local API responses are live state. A future system clock can otherwise + // leave URLCache entries "fresh" after the clock is restored. + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil self.session = URLSession(configuration: config) let syncConfig = URLSessionConfiguration.default diff --git a/TokenTrackerBar/TokenTrackerBarTests/UsageLimitsRetentionTests.swift b/TokenTrackerBar/TokenTrackerBarTests/UsageLimitsRetentionTests.swift index 2c73e4c20..27a475ab8 100644 --- a/TokenTrackerBar/TokenTrackerBarTests/UsageLimitsRetentionTests.swift +++ b/TokenTrackerBar/TokenTrackerBarTests/UsageLimitsRetentionTests.swift @@ -29,6 +29,22 @@ final class UsageLimitsRetentionTests: XCTestCase { XCTAssertNil(UsageLimitsCache.load(defaults: defaults)) } + func testFutureDatedLastGoodCacheIsIgnoredAfterClockRollback() throws { + let suiteName = "UsageLimitsRetentionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let futureResponse = try decodeResponse(overrides: [ + "fetched_at": "2026-11-01T00:59:36.105Z", + "codex": ["configured": true], + ]) + let now = try XCTUnwrap( + ISO8601DateFormatter().date(from: "2026-09-07T00:00:00Z") + ) + + UsageLimitsCache.save(futureResponse, defaults: defaults) + + XCTAssertNil(UsageLimitsCache.load(defaults: defaults, now: now)) + } // MARK: - hasAnyProviderWithoutError diff --git a/test/macos-usage-limits-timeout.test.js b/test/macos-usage-limits-timeout.test.js index dd89095b9..17cd6e3c4 100644 --- a/test/macos-usage-limits-timeout.test.js +++ b/test/macos-usage-limits-timeout.test.js @@ -58,3 +58,13 @@ test("macOS usage-limits hydrates the last good record before refreshing", () => "A successful background refresh should persist the replacement record.", ); }); + +test("macOS local API session bypasses URLCache after a system clock rollback", () => { + const source = readAPIClient(); + + assert.match( + source, + /let config = URLSessionConfiguration\.default[\s\S]*config\.requestCachePolicy = \.reloadIgnoringLocalCacheData[\s\S]*config\.urlCache = nil[\s\S]*self\.session = URLSession\(configuration: config\)/, + "Dynamic localhost responses must not be replayed from a future-dated URLCache entry.", + ); +});