diff --git a/Sources/TokPeek/TokPeekApp.swift b/Sources/TokPeek/TokPeekApp.swift index 84f7bba..6cc3dfb 100644 --- a/Sources/TokPeek/TokPeekApp.swift +++ b/Sources/TokPeek/TokPeekApp.swift @@ -14,7 +14,8 @@ struct TokPeekApp: App { private var appDelegate @StateObject private var store = UsageStore( - loader: TokscaleClient() + loader: TokscaleClient(), + reportCache: FileUsageReportCache() ) @StateObject private var settings = SettingsStore() @StateObject private var launchAtLogin = LaunchAtLoginController() diff --git a/Sources/TokPeekKit/UsageReport.swift b/Sources/TokPeekKit/UsageReport.swift index 9795490..7079471 100644 --- a/Sources/TokPeekKit/UsageReport.swift +++ b/Sources/TokPeekKit/UsageReport.swift @@ -1,6 +1,6 @@ import Foundation -public struct UsageReport: Decodable, Sendable, Equatable { +public struct UsageReport: Codable, Sendable, Equatable { public let meta: ReportMetadata public let summary: UsageSummary public let years: [YearSummary] @@ -61,7 +61,7 @@ public struct UsageReport: Decodable, Sendable, Equatable { } } -public struct ReportMetadata: Decodable, Sendable, Equatable { +public struct ReportMetadata: Codable, Sendable, Equatable { public let generatedAt: String public let version: String public let dateRangeStart: String @@ -69,7 +69,7 @@ public struct ReportMetadata: Decodable, Sendable, Equatable { public let processingTimeMs: UInt32 } -public struct UsageSummary: Decodable, Sendable, Equatable { +public struct UsageSummary: Codable, Sendable, Equatable { public let totalTokens: Int64 public let totalCost: Double public let totalDays: Int @@ -80,7 +80,7 @@ public struct UsageSummary: Decodable, Sendable, Equatable { public let models: [String] } -public struct YearSummary: Decodable, Sendable, Equatable { +public struct YearSummary: Codable, Sendable, Equatable { public let year: String public let totalTokens: Int64 public let totalCost: Double @@ -88,7 +88,7 @@ public struct YearSummary: Decodable, Sendable, Equatable { public let rangeEnd: String } -public struct DailyContribution: Decodable, Sendable, Equatable, Identifiable { +public struct DailyContribution: Codable, Sendable, Equatable, Identifiable { public var id: String { date } public let date: String @@ -99,7 +99,7 @@ public struct DailyContribution: Decodable, Sendable, Equatable, Identifiable { public let activeTimeMs: Int64? } -public struct HourlyContribution: Decodable, Sendable, Equatable, Identifiable { +public struct HourlyContribution: Codable, Sendable, Equatable, Identifiable { public var id: String { hour } public let hour: String @@ -120,13 +120,13 @@ public struct HourlyContribution: Decodable, Sendable, Equatable, Identifiable { } } -public struct DailyTotals: Decodable, Sendable, Equatable { +public struct DailyTotals: Codable, Sendable, Equatable { public let tokens: Int64 public let cost: Double public let messages: Int } -public struct TokenBreakdown: Decodable, Sendable, Equatable { +public struct TokenBreakdown: Codable, Sendable, Equatable { public let input: Int64 public let output: Int64 public let cacheRead: Int64 @@ -134,7 +134,7 @@ public struct TokenBreakdown: Decodable, Sendable, Equatable { public let reasoning: Int64 } -public struct ClientContribution: Decodable, Sendable, Equatable, Identifiable { +public struct ClientContribution: Codable, Sendable, Equatable, Identifiable { public var id: String { "\(client)|\(providerId)|\(modelId)" } diff --git a/Sources/TokPeekKit/UsageReportCache.swift b/Sources/TokPeekKit/UsageReportCache.swift new file mode 100644 index 0000000..dcf67c6 --- /dev/null +++ b/Sources/TokPeekKit/UsageReportCache.swift @@ -0,0 +1,96 @@ +import Foundation + +public struct UsageReportSnapshot: Codable, Sendable, Equatable { + public let request: UsageRequest + public let report: UsageReport + public let refreshedAt: Date + + public init( + request: UsageRequest, + report: UsageReport, + refreshedAt: Date + ) { + self.request = request + self.report = report + self.refreshedAt = refreshedAt + } +} + +public protocol UsageReportCaching: Sendable { + func load() async -> UsageReportSnapshot? + func save(_ snapshot: UsageReportSnapshot) async +} + +public actor FileUsageReportCache: UsageReportCaching { + private struct Envelope: Codable { + let version: Int + let snapshot: UsageReportSnapshot + } + + private static let formatVersion = 1 + private let fileURL: URL + + public init() { + fileURL = Self.defaultFileURL() + } + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public func load() async -> UsageReportSnapshot? { + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + guard + let envelope = try? JSONDecoder().decode( + Envelope.self, + from: data + ), + envelope.version == Self.formatVersion + else { + try? FileManager.default.removeItem(at: fileURL) + return nil + } + + return envelope.snapshot + } + + public func save(_ snapshot: UsageReportSnapshot) async { + let envelope = Envelope( + version: Self.formatVersion, + snapshot: snapshot + ) + + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(envelope) + try data.write(to: fileURL, options: .atomic) + } catch { + // A disposable startup cache must never make a successful usage + // refresh look like a failure. + } + } + + private static func defaultFileURL() -> URL { + let baseDirectory = + FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + + return baseDirectory + .appendingPathComponent( + "com.tokpeek.app", + isDirectory: true + ) + .appendingPathComponent( + "usage-report-v1.json", + isDirectory: false + ) + } +} diff --git a/Sources/TokPeekKit/UsageRequest.swift b/Sources/TokPeekKit/UsageRequest.swift index 24eb6ba..a07094c 100644 --- a/Sources/TokPeekKit/UsageRequest.swift +++ b/Sources/TokPeekKit/UsageRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct UsageRequest: Codable, Sendable, Equatable { +public struct UsageRequest: Codable, Sendable, Hashable { public var homeDirectory: String? public var clients: [String]? public var since: String? diff --git a/Sources/TokPeekKit/UsageStore.swift b/Sources/TokPeekKit/UsageStore.swift index e465f94..8211421 100644 --- a/Sources/TokPeekKit/UsageStore.swift +++ b/Sources/TokPeekKit/UsageStore.swift @@ -7,6 +7,11 @@ public protocol UsageLoading: Sendable { @MainActor public final class UsageStore: ObservableObject { + private enum ReportLoadOutcome: Sendable { + case success(UsageReport) + case failure(String) + } + @Published public private(set) var report: UsageReport? @Published public private(set) var comparisonReport: UsageReport? @Published public private(set) var budgetReport: UsageReport? @@ -51,6 +56,11 @@ public final class UsageStore: ObservableObject { private var lastSuccessfulBudgetRefreshAt: Date? private var lastSuccessfulModelCatalogRequest: UsageRequest? private var lastSuccessfulModelCatalogRefreshAt: Date? + private let reportCache: (any UsageReportCaching)? + private var lastCacheRestoreRequest: UsageRequest? + private var inFlightReportLoads: [ + UsageRequest: Task + ] = [:] public var isLoadingNewRequest: Bool { // Scheduled refreshes keep the current report visible. A changed @@ -62,10 +72,12 @@ public final class UsageStore: ObservableObject { public init( loader: any UsageLoading, - request: UsageRequest = UsageRequest() + request: UsageRequest = UsageRequest(), + reportCache: (any UsageReportCaching)? = nil ) { self.loader = loader self.request = request + self.reportCache = reportCache comparisonRequest = nil budgetRequest = nil } @@ -74,6 +86,7 @@ public final class UsageStore: ObservableObject { maxAge: TimeInterval?, now: Date = Date() ) async { + await restoreCachedReportIfNeeded() guard shouldRefresh(maxAge: maxAge, now: now) else { return } @@ -95,10 +108,11 @@ public final class UsageStore: ObservableObject { } } - do { - let loadedReport = try await activeLoader.loadReport( - request: requestedReport - ) + switch await loadReport( + for: requestedReport, + using: activeLoader + ) { + case let .success(loadedReport): // A period change can start another scan before the detached FFI // call returns. Only the newest request is allowed to publish. guard generation == refreshGeneration else { @@ -106,15 +120,52 @@ public final class UsageStore: ObservableObject { } report = loadedReport lastSuccessfulRequest = requestedReport - lastSuccessfulRefreshAt = Date() - } catch { + let refreshedAt = Date() + lastSuccessfulRefreshAt = refreshedAt + if let reportCache { + await reportCache.save( + UsageReportSnapshot( + request: requestedReport, + report: loadedReport, + refreshedAt: refreshedAt + ) + ) + } + case let .failure(message): guard generation == refreshGeneration else { return } - errorMessage = error.localizedDescription + errorMessage = message } } + private func restoreCachedReportIfNeeded() async { + let requestedReport = request + guard + report == nil, + lastCacheRestoreRequest != requestedReport, + let reportCache + else { + return + } + + lastCacheRestoreRequest = requestedReport + // Usage periods are encoded in the request, so restoring a mismatched + // snapshot would show stale totals under the wrong period label. + guard + let snapshot = await reportCache.load(), + snapshot.request == requestedReport, + request == requestedReport, + report == nil + else { + return + } + + report = snapshot.report + lastSuccessfulRequest = snapshot.request + lastSuccessfulRefreshAt = snapshot.refreshedAt + } + public func refreshComparisonIfNeeded( maxAge: TimeInterval? = nil, now: Date = Date() @@ -137,19 +188,21 @@ public final class UsageStore: ObservableObject { let generation = comparisonGeneration let activeLoader = loader - do { - let loadedReport = try await activeLoader.loadReport( - request: comparisonRequest - ) + switch await loadReport( + for: comparisonRequest, + using: activeLoader + ) { + case let .success(loadedReport): guard generation == comparisonGeneration else { return } comparisonReport = loadedReport lastSuccessfulComparisonRequest = comparisonRequest lastSuccessfulComparisonRefreshAt = now - } catch { + case .failure: // Comparison is supplementary; the current report remains useful // when an older range cannot be loaded. + break } } @@ -175,19 +228,21 @@ public final class UsageStore: ObservableObject { let generation = budgetGeneration let activeLoader = loader - do { - let loadedReport = try await activeLoader.loadReport( - request: budgetRequest - ) + switch await loadReport( + for: budgetRequest, + using: activeLoader + ) { + case let .success(loadedReport): guard generation == budgetGeneration else { return } budgetReport = loadedReport lastSuccessfulBudgetRequest = budgetRequest lastSuccessfulBudgetRefreshAt = now - } catch { + case .failure: // Budget analytics are supplementary; the main report remains // available if their broader date scan cannot be loaded. + break } } @@ -212,10 +267,11 @@ public final class UsageStore: ObservableObject { let generation = modelCatalogGeneration let activeLoader = loader - do { - let catalogReport = try await activeLoader.loadReport( - request: requestedCatalog - ) + switch await loadReport( + for: requestedCatalog, + using: activeLoader + ) { + case let .success(catalogReport): guard generation == modelCatalogGeneration else { return } @@ -223,12 +279,41 @@ public final class UsageStore: ObservableObject { modelCatalog = catalogReport.modelFilterOptions lastSuccessfulModelCatalogRequest = requestedCatalog lastSuccessfulModelCatalogRefreshAt = now - } catch { + case .failure: // The current-period report remains usable when the optional // all-time catalog scan fails. + break } } + private func loadReport( + for request: UsageRequest, + using loader: any UsageLoading + ) async -> ReportLoadOutcome { + if let inFlightLoad = inFlightReportLoads[request] { + return await inFlightLoad.value + } + + // Every report lane goes through this request-keyed task so matching + // dashboard and menu-bar work cannot start duplicate core scans. + let load = Task { + do { + return ReportLoadOutcome.success( + try await loader.loadReport(request: request) + ) + } catch { + return ReportLoadOutcome.failure( + error.localizedDescription + ) + } + } + inFlightReportLoads[request] = load + + let outcome = await load.value + inFlightReportLoads[request] = nil + return outcome + } + private func shouldRefresh( maxAge: TimeInterval?, now: Date diff --git a/Tests/TokPeekKitTests/UsageStoreTests.swift b/Tests/TokPeekKitTests/UsageStoreTests.swift index 48aae39..c83c07b 100644 --- a/Tests/TokPeekKitTests/UsageStoreTests.swift +++ b/Tests/TokPeekKitTests/UsageStoreTests.swift @@ -126,6 +126,181 @@ func freshCacheSkipsRefresh() async throws { #expect(await loader.loadCount == 1) } +@MainActor +@Test("Concurrent refresh checks wait for the shared core scan") +func concurrentRefreshChecksWaitForSharedScan() async throws { + let loader = SuspendedCountingLoader(report: try fixtureReport()) + let store = UsageStore(loader: loader) + + let firstRefresh = Task { + await store.refreshIfNeeded(maxAge: 60) + } + await loader.waitUntilStarted() + + var secondRefreshStarted = false + var secondRefreshCompleted = false + let secondRefresh = Task { @MainActor in + secondRefreshStarted = true + await store.refreshIfNeeded(maxAge: 60) + secondRefreshCompleted = true + } + while !secondRefreshStarted { + await Task.yield() + } + + #expect(secondRefreshCompleted == false) + + await loader.release() + + await firstRefresh.value + await secondRefresh.value + + #expect(secondRefreshCompleted) + #expect(await loader.loadCount == 1) +} + +@MainActor +@Test("Matching report kinds share one core scan") +func matchingReportKindsShareCoreScan() async throws { + let report = try fixtureReport(models: ["mock-model"]) + let loader = SuspendedCountingLoader(report: report) + let request = UsageRequest() + let store = UsageStore(loader: loader, request: request) + store.comparisonRequest = request + store.budgetRequest = request + + let currentRefresh = Task { + await store.refresh() + } + await loader.waitUntilStarted() + + var comparisonStarted = false + var budgetStarted = false + var catalogStarted = false + let comparisonRefresh = Task { @MainActor in + comparisonStarted = true + await store.refreshComparisonIfNeeded() + } + let budgetRefresh = Task { @MainActor in + budgetStarted = true + await store.refreshBudgetIfNeeded() + } + let catalogRefresh = Task { @MainActor in + catalogStarted = true + await store.refreshModelCatalogIfNeeded(maxAge: nil) + } + while !comparisonStarted || !budgetStarted || !catalogStarted { + await Task.yield() + } + + await loader.release() + + await currentRefresh.value + await comparisonRefresh.value + await budgetRefresh.value + await catalogRefresh.value + + #expect(store.report == report) + #expect(store.comparisonReport == report) + #expect(store.budgetReport == report) + #expect(store.modelCatalog == ["mock-model"]) + #expect(await loader.loadCount == 1) +} + +@MainActor +@Test("A persisted report is restored before another core scan") +func persistedReportRestoresBeforeRefresh() async throws { + let cachedAt = Date(timeIntervalSince1970: 1_785_283_200) + let request = UsageRequest(since: "2026-07-28") + let cachedReport = try fixtureReport(totalTokens: 450) + let loader = CountingLoader( + report: try fixtureReport(totalTokens: 900) + ) + let cache = MemoryUsageReportCache( + snapshot: UsageReportSnapshot( + request: request, + report: cachedReport, + refreshedAt: cachedAt + ) + ) + let store = UsageStore( + loader: loader, + request: request, + reportCache: cache + ) + + await store.refreshIfNeeded( + maxAge: 60, + now: cachedAt.addingTimeInterval(30) + ) + + #expect(store.report == cachedReport) + #expect(await loader.loadCount == 0) +} + +@MainActor +@Test("A persisted report for another request is not displayed") +func mismatchedPersistedReportIsIgnored() async throws { + let requestedReport = try fixtureReport(totalTokens: 900) + let loader = CountingLoader(report: requestedReport) + let cache = MemoryUsageReportCache( + snapshot: UsageReportSnapshot( + request: UsageRequest(since: "2026-07-27"), + report: try fixtureReport(totalTokens: 450), + refreshedAt: Date(timeIntervalSince1970: 1_785_196_800) + ) + ) + let store = UsageStore( + loader: loader, + request: UsageRequest(since: "2026-07-28"), + reportCache: cache + ) + + await store.refreshIfNeeded(maxAge: 60) + + #expect(store.report == requestedReport) + #expect(await loader.loadCount == 1) + #expect(await cache.savedSnapshot?.report == requestedReport) +} + +@Test("The file report cache round-trips a synthetic snapshot") +func fileReportCacheRoundTripsSnapshot() async throws { + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let cacheURL = cacheDirectory + .appendingPathComponent("usage-report.json", isDirectory: false) + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + + let cache = FileUsageReportCache(fileURL: cacheURL) + let snapshot = UsageReportSnapshot( + request: UsageRequest(since: "2026-07-28"), + report: try fixtureReport(totalTokens: 450), + refreshedAt: Date(timeIntervalSince1970: 1_785_283_200) + ) + + await cache.save(snapshot) + + #expect(await cache.load() == snapshot) +} + +@Test("The file report cache ignores malformed data") +func fileReportCacheIgnoresMalformedData() async throws { + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let cacheURL = cacheDirectory + .appendingPathComponent("usage-report.json", isDirectory: false) + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + try FileManager.default.createDirectory( + at: cacheDirectory, + withIntermediateDirectories: true + ) + try Data("not-json".utf8).write(to: cacheURL) + + let cache = FileUsageReportCache(fileURL: cacheURL) + + #expect(await cache.load() == nil) +} + @MainActor @Test("An expired cached report is refreshed") func expiredCacheRefreshes() async throws { @@ -312,6 +487,82 @@ private actor CountingLoader: UsageLoading { } } +private actor DelayedCountingLoader: UsageLoading { + let report: UsageReport + private(set) var loadCount = 0 + + init(report: UsageReport) { + self.report = report + } + + func loadReport(request: UsageRequest) async throws -> UsageReport { + loadCount += 1 + try await Task.sleep(for: .milliseconds(50)) + return report + } +} + +private actor SuspendedCountingLoader: UsageLoading { + let report: UsageReport + private(set) var loadCount = 0 + private var isStarted = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + init(report: UsageReport) { + self.report = report + } + + func loadReport(request: UsageRequest) async throws -> UsageReport { + loadCount += 1 + isStarted = true + let waiters = startWaiters + startWaiters.removeAll() + waiters.forEach { $0.resume() } + + if !isReleased { + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + return report + } + + func waitUntilStarted() async { + guard !isStarted else { + return + } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + isReleased = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor MemoryUsageReportCache: UsageReportCaching { + private let snapshot: UsageReportSnapshot? + private(set) var savedSnapshot: UsageReportSnapshot? + + init(snapshot: UsageReportSnapshot?) { + self.snapshot = snapshot + } + + func load() async -> UsageReportSnapshot? { + snapshot + } + + func save(_ snapshot: UsageReportSnapshot) async { + savedSnapshot = snapshot + } +} + private actor CatalogLoader: UsageLoading { let currentReport: UsageReport let catalogReport: UsageReport diff --git a/TokPeek.xcodeproj/project.pbxproj b/TokPeek.xcodeproj/project.pbxproj index 1eb43ad..c414875 100644 --- a/TokPeek.xcodeproj/project.pbxproj +++ b/TokPeek.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 0477EB946A46B9A6943407917E44B380 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 4CCC971AAD5048A5C4B06882C97D5E7A /* Sparkle */; }; 04ACDF24B7F1BE5CFD508822DD90DD92 /* UpdateCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3667C769C3700BB997049297E3C1E27 /* UpdateCoordinatorTests.swift */; }; 0570B6075FD6A7E2570476FC88631322 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E77DA364C7AA3E1B69E07435D709CD97 /* CoreFoundation.framework */; }; + 0C9F0F3D6A5243128F2A1002 /* UsageReportCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F0F3D6A5243128F2A1001 /* UsageReportCache.swift */; }; + 0C9F0F3D6A5243128F2A1003 /* UsageReportCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F0F3D6A5243128F2A1001 /* UsageReportCache.swift */; }; 1350D8C4C4385A0A46D4436DF234261C /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1976B9C1DAD1290CA6525E409A738916 /* SystemConfiguration.framework */; }; 156B2B2811D494E9C1847E6B710CE53E /* libtokpeek_core_ffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 60E3A57259E6455FF1D7BF77F7744EDE /* libtokpeek_core_ffi.a */; }; 19BC73DBD2B9EC947BBBABEF49BFA2AC /* UsageOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E099BEA9E0CC5DF9A02FA9AD3C5FC4 /* UsageOverview.swift */; }; @@ -77,6 +79,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0C9F0F3D6A5243128F2A1001 /* UsageReportCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UsageReportCache.swift; path = TokPeekKit/UsageReportCache.swift; sourceTree = ""; }; 10077C83AF7BBFCEFB23BFDA94816F68 /* BudgetOverview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BudgetOverview.swift; path = TokPeek/BudgetOverview.swift; sourceTree = ""; }; 11D3344648555DAD5F9C0105E68485CB /* TokPeekTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TokPeekTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 139F2BEAF258794938ED1DB042BB0D59 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -269,6 +272,7 @@ 55066C97ACC1BCFCFE9B2FF2F239B5EC /* UsageFormatting.swift */, 6A971F3ADA3D0CA76DE740E37A91B79D /* UsageInsights.swift */, 2BF69944B8971A82BFB493138ECA9714 /* UsageReport.swift */, + 0C9F0F3D6A5243128F2A1001 /* UsageReportCache.swift */, 355940114A516A53634806D7AD832B9D /* UsageRequest.swift */, FB87DD866E93B4047E9EBB506EC6B5F7 /* UsageStore.swift */, 32D29E744F47CAB0FA1F2E6282829C50 /* tokpeek_core.h */, @@ -424,6 +428,7 @@ D5966B4C8D05818B451673F943B1EC71 /* UsageFormatting.swift in Sources */, F21D87806B9AECAFA08B260D207A6F6A /* UsageInsights.swift in Sources */, 21CF368766BF85F812E37C094A385FF2 /* UsageReport.swift in Sources */, + 0C9F0F3D6A5243128F2A1002 /* UsageReportCache.swift in Sources */, F421DF10AECEC6ED4C946CA8091070A2 /* UsageRequest.swift in Sources */, 545601EA32565830FBC2E02F274C6608 /* UsageStore.swift in Sources */, ); @@ -447,6 +452,7 @@ 5B036BCBAFEF9998F81FD8B4B05C92AF /* UsageFormatting.swift in Sources */, 71FB55A08A592B7B36847A5AF7272F6B /* UsageInsights.swift in Sources */, 464DA1F62A9081FCBB74B151CC8E93E1 /* UsageReport.swift in Sources */, + 0C9F0F3D6A5243128F2A1003 /* UsageReportCache.swift in Sources */, A614347260460A44E8E2E40CE02D2878 /* UsageRequest.swift in Sources */, 3E18342C30B6E3456E7945624768BA65 /* UsageStore.swift in Sources */, 6F3A2CD3E2ACC68F08433B708E6CD0C6 /* ClientBreakdownLayoutTests.swift in Sources */,