diff --git a/Scripts/verify_lifetime_spend_runtime_proof.sh b/Scripts/verify_lifetime_spend_runtime_proof.sh new file mode 100755 index 0000000000..03ab474c4b --- /dev/null +++ b/Scripts/verify_lifetime_spend_runtime_proof.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +PROOF_ROOT=$(mktemp -d /private/tmp/codexbar-lifetime-proof.XXXXXX) +chmod 700 "$PROOF_ROOT" +mkdir -p "$PROOF_ROOT/home" "$PROOF_ROOT/tmp" "$PROOF_ROOT/codex-home/sessions/2026/01/30" +mkdir -p "$PROOF_ROOT/codex-home/sessions/2026/03/01" + +RUN_ID=$(uuidgen) +cat > "$PROOF_ROOT/.codexbar-lifetime-proof.json" < "$target" <&2 + exit 1 + fi +} + +assert_manifest_permissions() { + local manifest="$1" + local permissions + local schema_version + schema_version=$(/usr/bin/plutil -extract schemaVersion raw -o - "$manifest") + if [[ "$schema_version" != "2" ]]; then + echo "ERROR: proof manifest schemaVersion is $schema_version, expected 2" >&2 + exit 1 + fi + permissions=$(/usr/bin/plutil -extract ledgerPermissions raw -o - "$manifest") + if [[ "$permissions" != "0600" ]]; then + echo "ERROR: proof manifest ledgerPermissions is $permissions, expected 0600" >&2 + exit 1 + fi +} + +run_phase record +assert_ledger_permissions +run_phase reload +assert_ledger_permissions + +test -s "$PROOF_ROOT/output/record-manifest.json" +test -s "$PROOF_ROOT/output/reload-manifest.json" +test -s "$PROOF_ROOT/output/all-time-dashboard.png" +test -s "$PROOF_ROOT/output/all-time-share-stats.png" +test "$(find "$PROOF_ROOT/output" -name '*.png' -type f | wc -l | tr -d ' ')" = 2 +assert_manifest_permissions "$PROOF_ROOT/output/record-manifest.json" +assert_manifest_permissions "$PROOF_ROOT/output/reload-manifest.json" + +if rg -i '@|/Users/|/home/|akshay|example\.com' "$PROOF_ROOT/output"/*.json; then + echo "ERROR: proof manifest privacy check failed" >&2 + exit 1 +fi + +echo "Packaged lifetime proof passed. Artifacts retained at: $PROOF_ROOT/output" diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 84c1f37b15..8efe424ccc 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -26,6 +26,17 @@ enum CodexBarEntryPoint { if CodexBarCoreResourceSmoke.isRequested() { exit(CodexBarCoreResourceSmoke.run()) } + #if DEBUG + switch LifetimeSpendRuntimeProofConfiguration.resolve() { + case .notRequested: + break + case let .accepted(configuration): + exit(LifetimeSpendRuntimeProofRunner.execute(configuration)) + case .rejected: + fputs("CodexBar lifetime proof refused its isolated root.\n", stderr) + exit(78) + } + #endif guard CodexBarLaunchMode.resolve(arguments: CommandLine.arguments) == .application else { return } diff --git a/Sources/CodexBar/LifetimeSpendRuntimeProof.swift b/Sources/CodexBar/LifetimeSpendRuntimeProof.swift new file mode 100644 index 0000000000..cbb99518e6 --- /dev/null +++ b/Sources/CodexBar/LifetimeSpendRuntimeProof.swift @@ -0,0 +1,404 @@ +#if DEBUG +import AppKit +import CodexBarCore +import CryptoKit +import Darwin +import Foundation +import SwiftUI + +/// Opt-in packaged-app proof for lifetime spend persistence. This launch mode is +/// compiled out of release builds and resolves before `CodexBarApp` constructs any +/// settings, provider, browser, or Keychain services. +struct LifetimeSpendRuntimeProofConfiguration: Equatable, Sendable { + enum Phase: String, Codable, Sendable { + case record + case reload + + var now: Date { + switch self { + case .record: Date(timeIntervalSince1970: 1_769_731_200) // 2026-01-30T00:00:00Z + case .reload: Date(timeIntervalSince1970: 1_772_323_200) // 2026-03-01T00:00:00Z + } + } + + var expectedCoveredDays: Int { + switch self { + case .record: 30 + case .reload: 60 + } + } + } + + enum Resolution: Equatable { + case notRequested + case accepted(LifetimeSpendRuntimeProofConfiguration) + case rejected + } + + private struct Sentinel: Decodable { + let schemaVersion: Int + let runID: String + } + + static let rootEnvironmentKey = "CODEXBAR_LIFETIME_RUNTIME_PROOF_ROOT" + static let phaseEnvironmentKey = "CODEXBAR_LIFETIME_RUNTIME_PROOF_PHASE" + static let sentinelFilename = ".codexbar-lifetime-proof.json" + + let root: URL + let phase: Phase + let runID: String + + var codexHome: URL { + self.root.appendingPathComponent("codex-home", isDirectory: true) + } + + var cacheRoot: URL { + self.root.appendingPathComponent("cache", isDirectory: true) + } + + var ledgerURL: URL { + self.root.appendingPathComponent("spend-history.json", isDirectory: false) + } + + var outputDirectory: URL { + self.root.appendingPathComponent("output", isDirectory: true) + } + + static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> Resolution + { + guard let rawRoot = environment[self.rootEnvironmentKey] else { return .notRequested } + guard let phaseRaw = environment[self.phaseEnvironmentKey], + let phase = Phase(rawValue: phaseRaw) + else { return .rejected } + + let suppliedRoot = URL(fileURLWithPath: rawRoot, isDirectory: true) + let resolvedRoot = suppliedRoot.standardizedFileURL + let requiredPrefix = "/private/tmp/codexbar-lifetime-proof." + guard rawRoot.hasPrefix(requiredPrefix), + !rawRoot.contains("/../"), + !rawRoot.contains("/./"), + !self.isSymbolicLink(rawRoot), + resolvedRoot.deletingLastPathComponent().path == "/tmp", + fileManager.fileExists(atPath: resolvedRoot.path), + let rootValues = try? resolvedRoot.resourceValues(forKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey, + ]), + rootValues.isDirectory == true, + rootValues.isSymbolicLink != true + else { return .rejected } + + guard let attributes = try? fileManager.attributesOfItem(atPath: resolvedRoot.path), + let permissions = attributes[.posixPermissions] as? NSNumber, + let owner = attributes[.ownerAccountID] as? NSNumber, + permissions.intValue & 0o077 == 0, + owner.uint32Value == getuid() + else { return .rejected } + + let sentinelURL = resolvedRoot.appendingPathComponent(self.sentinelFilename, isDirectory: false) + let rawSentinelPath = suppliedRoot.appendingPathComponent(self.sentinelFilename).path + guard !self.isSymbolicLink(rawSentinelPath), + let sentinelValues = try? sentinelURL.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + ]), + sentinelValues.isRegularFile == true, + sentinelValues.isSymbolicLink != true, + let data = try? Data(contentsOf: sentinelURL), + let sentinel = try? JSONDecoder().decode(Sentinel.self, from: data), + sentinel.schemaVersion == 1, + sentinel.runID.range(of: #"^[A-F0-9-]{36}$"#, options: .regularExpression) != nil + else { return .rejected } + + return .accepted(Self(root: resolvedRoot, phase: phase, runID: sentinel.runID)) + } + + static func verifiedLedgerPermissions( + at url: URL, + fileManager: FileManager = .default) -> String? + { + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), + let permissions = attributes[.posixPermissions] as? NSNumber, + permissions.intValue & 0o7777 == 0o600 + else { return nil } + return "0600" + } + + private static func isSymbolicLink(_ path: String) -> Bool { + var metadata = stat() + guard lstat(path, &metadata) == 0 else { return true } + return metadata.st_mode & S_IFMT == S_IFLNK + } +} + +@MainActor +enum LifetimeSpendRuntimeProofRunner { + private struct Manifest: Codable { + let schemaVersion: Int + let phase: LifetimeSpendRuntimeProofConfiguration.Phase + let bundleIdentifier: String + let gitCommit: String + let executableSHA256: String + let ledgerBeforeSHA256: String? + let ledgerAfterSHA256: String + let ledgerPermissions: String + let currentWindowDays: Int + let allTimeCoveredDays: Int + let allTimeProviderCount: Int + let allTimeModelNames: [String] + let emittedPNGs: [String] + } + + private static let dashboardSize = CGSize(width: 900, height: 1370) + // Provider-specific by design: the isolated proof uses a fixed dummy Codex account identity. + private static let accountID = "synthetic" + private static let accountIdentity = "synthetic|lifetime-proof-v1" + + static func execute(_ configuration: LifetimeSpendRuntimeProofConfiguration) -> Int32 { + var result: Int32? + Task { @MainActor in + result = await self.run(configuration) + } + while result == nil { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + return result ?? 1 + } + + private static func run(_ configuration: LifetimeSpendRuntimeProofConfiguration) async -> Int32 { + do { + try FileManager.default.createDirectory( + at: configuration.outputDirectory, + withIntermediateDirectories: true) + let ledgerBefore = Self.sha256(at: configuration.ledgerURL) + if configuration.phase == .reload, ledgerBefore == nil { + throw ProofError.missingRecordedLedger + } + + let controller = self.makeController(configuration) + controller.update(configuration: self.dashboardConfiguration) + let deadline = Date(timeIntervalSinceNow: 30) + while controller.isRefreshing, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + guard !controller.isRefreshing else { throw ProofError.timedOut } + guard controller.failedSourceCount == 0 else { throw ProofError.sourceFailed } + guard controller.model.range == .allTime, + controller.model.groups.count == 1, + controller.model.groups[0].coveredDayCount == configuration.phase.expectedCoveredDays + else { + throw ProofError.unexpectedCoverage( + expected: configuration.phase.expectedCoveredDays, + groups: controller.model.groups.count, + actual: controller.model.groups.first?.coveredDayCount) + } + + let model = controller.model + let payload = try Self.require(ShareStatsBuilder.make(model: model)) + try self.validatePrivacy(model: model, payload: payload, root: configuration.root) + + var emittedPNGs: [String] = [] + if configuration.phase == .reload { + let dashboardData = try Self.require(self.dashboardPNGData(model: model)) + let shareData = try Self.require(ShareStatsRenderer.pngData(for: payload)) + let dashboardName = "all-time-dashboard.png" + let shareName = "all-time-share-stats.png" + try dashboardData.write( + to: configuration.outputDirectory.appendingPathComponent(dashboardName), + options: .atomic) + try shareData.write( + to: configuration.outputDirectory.appendingPathComponent(shareName), + options: .atomic) + emittedPNGs = [dashboardName, shareName] + } + + guard let ledgerAfter = Self.sha256(at: configuration.ledgerURL) else { + throw ProofError.missingRecordedLedger + } + guard let ledgerPermissions = LifetimeSpendRuntimeProofConfiguration.verifiedLedgerPermissions( + at: configuration.ledgerURL) + else { throw ProofError.insecureLedgerPermissions } + let manifest = Manifest( + schemaVersion: 2, + phase: configuration.phase, + bundleIdentifier: Bundle.main.bundleIdentifier ?? "unknown", + gitCommit: Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String ?? "unknown", + executableSHA256: Self.sha256(at: URL(fileURLWithPath: CommandLine.arguments[0])) ?? "unknown", + ledgerBeforeSHA256: ledgerBefore, + ledgerAfterSHA256: ledgerAfter, + ledgerPermissions: ledgerPermissions, + currentWindowDays: 30, + allTimeCoveredDays: model.groups[0].coveredDayCount, + allTimeProviderCount: model.groups[0].providers.count, + allTimeModelNames: model.groups[0].models.map(\.modelName).sorted(), + emittedPNGs: emittedPNGs) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let manifestData = try encoder.encode(manifest) + try self.validateManifestPrivacy(manifestData, root: configuration.root) + try manifestData.write( + to: configuration.outputDirectory + .appendingPathComponent("\(configuration.phase.rawValue)-manifest.json"), + options: .atomic) + return 0 + } catch { + fputs("CodexBar lifetime proof failed closed: \(String(describing: error))\n", stderr) + return 1 + } + } + + private static var dashboardConfiguration: SpendDashboardConfiguration { + // Provider-specific by design: the proof exercises the Codex JSONL scanner used by lifetime history. + SpendDashboardConfiguration( + costUsageEnabled: true, + preferredCurrencyCode: "USD", + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [self.accountIdentity], + codexAccountDisplayNames: ["codex:\(self.accountID)": "Codex"]) + } + + private static func makeController( + _ configuration: LifetimeSpendRuntimeProofConfiguration) -> SpendDashboardController + { + let account = CodexSpendScanRequest( + id: self.accountID, + displayName: "Codex", + source: .profileHome(path: configuration.codexHome.path), + homePath: configuration.codexHome.path, + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "lifetime-proof-v1") + let request = SpendDashboardLoadRequest( + configuration: self.dashboardConfiguration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: configuration.phase.now, + force: false) + let suiteName = "com.steipete.codexbar.lifetime-proof.\(configuration.runID)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { _ in request }, + loader: { request in + await SpendDashboardSource.load( + request, + cacheRootResolver: { _ in configuration.cacheRoot }, + codexSnapshotLoader: { context in + // Provider-specific by design: the proof must invoke the real Codex cost-history scanner. + try await CostUsageFetcher(cacheRoot: context.cacheRoot).loadTokenSnapshot( + provider: .codex, + environment: [:], + now: context.now, + forceRefresh: true, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: false) + }) + }, + historyLedger: SpendHistoryLedger(fileURL: configuration.ledgerURL), + nowProvider: { configuration.phase.now }) + controller.selectRange(.allTime) + defaults.removePersistentDomain(forName: suiteName) + return controller + } + + private static func dashboardPNGData(model: SpendDashboardModel) -> Data? { + let rootView = ScrollView { + VStack(alignment: .leading, spacing: 18) { + SpendDashboardHeader( + selectedRange: .allTime, + isRefreshing: false, + isCostTrackingEnabled: true, + selectRange: { _ in }, + refresh: {}) + ForEach(model.groups) { group in + SpendCurrencySection( + group: group, + range: model.range, + requestedDays: model.requestedDays) + } + } + .padding(24) + } + .frame(width: self.dashboardSize.width, height: self.dashboardSize.height) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, .dark) + let view = NSHostingView(rootView: rootView) + view.appearance = NSAppearance(named: .darkAqua) + view.frame = CGRect(origin: .zero, size: self.dashboardSize) + view.layoutSubtreeIfNeeded() + + let scale: CGFloat = 2 + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(self.dashboardSize.width * scale), + pixelsHigh: Int(self.dashboardSize.height * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = self.dashboardSize + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + view.displayIgnoringOpacity(view.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } + + private static func validatePrivacy( + model: SpendDashboardModel, + payload: ShareStatsPayload, + root: URL) throws + { + let providerNames = Set(model.groups.flatMap(\.providers).map(\.displayName)) + let modelNames = Set(model.groups.flatMap(\.models).map(\.modelName)) + guard providerNames == ["Codex"], modelNames == ["gpt-5.4"] else { + throw ProofError.privacyViolation + } + let visibleText = ShareStatsFormatting.text(payload) + // Provider-specific by design: reject the fixed dummy identity if it ever reaches rendered proof output. + for forbidden in ["@", "/Users/", "/home/", root.path, "synthetic", "proof"] + where visibleText.localizedCaseInsensitiveContains(forbidden) + { + throw ProofError.privacyViolation + } + } + + private static func validateManifestPrivacy(_ data: Data, root: URL) throws { + guard let text = String(data: data, encoding: .utf8) else { throw ProofError.privacyViolation } + for forbidden in ["@", "/Users/", "/home/", root.path] + where text.localizedCaseInsensitiveContains(forbidden) + { + throw ProofError.privacyViolation + } + } + + private static func sha256(at url: URL) -> String? { + guard let data = try? Data(contentsOf: url) else { return nil } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func require(_ value: T?) throws -> T { + guard let value else { throw ProofError.renderFailed } + return value + } + + private enum ProofError: Error { + case insecureLedgerPermissions + case missingRecordedLedger + case privacyViolation + case renderFailed + case sourceFailed + case timedOut + case unexpectedCoverage(expected: Int, groups: Int, actual: Int?) + } +} +#endif diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 8cd6d85ba8..1652a81bf8 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -15,6 +15,24 @@ func spendDashboardDayRangeText(_ days: Int) -> String { with: codexBarLocalizedInteger(days)) } +func spendDashboardRangeText(_ range: SpendDashboardRange) -> String { + range.dayCount.map(spendDashboardDayRangeText) ?? L("All Time") +} + +func spendDashboardAvailableHistoryText( + since date: Date, + coveredDayCount: Int, + calendar: Calendar = .current) -> String +{ + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = codexBarLocalizedLocale() + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return "\(L("Available history since %@", formatter.string(from: date))) · " + + L("%d covered days", coveredDayCount) +} + func spendDashboardRankText(_ rank: Int) -> String { "#\(codexBarLocalizedInteger(rank))" } @@ -44,6 +62,58 @@ func codexCostCatchUpProgressText(_ activity: CodexCostCatchUpActivity) -> Strin return L("Loading…") } +struct SpendDashboardHeader: View { + let selectedRange: SpendDashboardRange + let isRefreshing: Bool + let isCostTrackingEnabled: Bool + let selectRange: (SpendDashboardRange) -> Void + let refresh: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(L("Usage & Spend")) + .font(.title2.weight(.semibold)) + Text(L("Local estimated cost history across supported providers.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 2) { + ForEach(SpendDashboardRange.allCases, id: \.self) { range in + Button { + self.selectRange(range) + } label: { + Text(spendDashboardRangeText(range)) + .font(.callout.weight(range == self.selectedRange ? .semibold : .regular)) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background( + range == self.selectedRange ? Color.accentColor : Color.clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .foregroundStyle(range == self.selectedRange ? Color.white : Color.primary) + } + .buttonStyle(.plain) + .accessibilityLabel(spendDashboardRangeText(range)) + .accessibilityAddTraits(range == self.selectedRange ? .isSelected : []) + } + } + .padding(2) + .background(Color.secondary.opacity(0.16), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .frame(width: 210) + + Button(action: self.refresh) { + if self.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.isRefreshing || !self.isCostTrackingEnabled) + } + } +} + enum SpendDashboardModelHistoryPresentation: Equatable { case unavailable case empty @@ -74,7 +144,7 @@ struct SpendDashboardPane: View { await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }, cachedLoader: { request in await SpendDashboardSource.loadCached(request) - })) + }, historyLedger: SpendHistoryLedger())) } var body: some View { @@ -128,34 +198,12 @@ struct SpendDashboardPane: View { } private var header: some View { - HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(L("Usage & Spend")) - .font(.title2.weight(.semibold)) - Text(L("Local estimated cost history across supported providers.")) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - Picker(L("Time range"), selection: self.daysBinding) { - Text(spendDashboardDayRangeText(7)).tag(7) - Text(spendDashboardDayRangeText(30)).tag(30) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 116) - - Button { - self.controller.refresh() - } label: { - if self.controller.isRefreshing { - ProgressView().controlSize(.small) - } else { - Label(L("Refresh"), systemImage: "arrow.clockwise") - } - } - .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) - } + SpendDashboardHeader( + selectedRange: self.controller.selectedRange, + isRefreshing: self.controller.isRefreshing, + isCostTrackingEnabled: self.settings.costUsageEnabled, + selectRange: { self.controller.selectRange($0) }, + refresh: { self.controller.refresh() }) } @ViewBuilder @@ -306,7 +354,10 @@ struct SpendDashboardPane: View { } } else { ForEach(self.controller.model.groups) { group in - SpendCurrencySection(group: group, requestedDays: self.controller.model.requestedDays) + SpendCurrencySection( + group: group, + range: self.controller.model.range, + requestedDays: self.controller.model.requestedDays) } } @@ -385,10 +436,10 @@ struct SpendDashboardPane: View { return names } - private var daysBinding: Binding { + private var rangeBinding: Binding { Binding( - get: { self.controller.selectedDays }, - set: { self.controller.selectDays($0) }) + get: { self.controller.selectedRange }, + set: { self.controller.selectRange($0) }) } } @@ -408,8 +459,9 @@ struct SpendDashboardEmptyState: Equatable { } } -private struct SpendCurrencySection: View { +struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup + let range: SpendDashboardRange let requestedDays: Int var body: some View { @@ -425,11 +477,7 @@ private struct SpendCurrencySection: View { .monospacedDigit() } - Text( - "\(L("Local estimated history")) · " + - spendDashboardCoverageText( - covered: self.group.coveredDayCount, - requested: self.requestedDays)) + Text(self.coverageText) .font(.caption) .foregroundStyle(.secondary) @@ -455,6 +503,18 @@ private struct SpendCurrencySection: View { SpendDailyChart(group: self.group) } } + + private var coverageText: String { + if self.range == .allTime { + return spendDashboardAvailableHistoryText( + since: self.group.providers.compactMap(\.coverageStart).min() + ?? self.group.chartDomain.lowerBound, + coveredDayCount: self.group.coveredDayCount) + } + return "\(L("Local estimated history")) · " + spendDashboardCoverageText( + covered: self.group.coveredDayCount, + requested: self.requestedDays) + } } private struct SpendSummaryValue: View { diff --git a/Sources/CodexBar/ShareStatsCardView.swift b/Sources/CodexBar/ShareStatsCardView.swift index 1cd70ccfe2..4c6c702752 100644 --- a/Sources/CodexBar/ShareStatsCardView.swift +++ b/Sources/CodexBar/ShareStatsCardView.swift @@ -68,7 +68,7 @@ struct ShareStatsCardView: View { private var hero: some View { HStack(alignment: .bottom, spacing: 52) { VStack(alignment: .leading, spacing: 2) { - Text("TRACKED TOKENS · \(self.payload.days) DAYS") + Text(self.trackedTokensTitle) .font(.system(size: 20, weight: .semibold, design: .rounded)) .tracking(1.8) .foregroundStyle(self.secondary) @@ -81,13 +81,13 @@ struct ShareStatsCardView: View { .frame(maxWidth: .infinity, alignment: .leading) VStack(alignment: .leading, spacing: 9) { - Text("EST. \(self.payload.days)-DAY SPEND") + Text(self.spendTitle) .font(.system(size: 18, weight: .semibold, design: .rounded)) .tracking(1.2) .foregroundStyle(self.secondary) ForEach(self.payload.currencies.prefix(2)) { currency in HStack(alignment: .firstTextBaseline) { - Text("\(currency.currencyCode) · \(currency.coveredDayCount)/\(self.payload.days)d") + Text("\(currency.currencyCode) · \(self.coverageText(currency.coveredDayCount))") .font(.system(size: 17, weight: .semibold, design: .rounded)) .foregroundStyle(self.secondary) Spacer() @@ -127,6 +127,7 @@ struct ShareStatsCardView: View { ShareStatsProviderRow( rank: index + 1, provider: provider, + range: self.payload.range, days: self.payload.days, color: ShareStatsPalette.color(at: index)) } @@ -172,6 +173,24 @@ struct ShareStatsCardView: View { Self.providerDisplayLimit(for: self.payload.providers.count) } + private var trackedTokensTitle: String { + self.payload.range == .allTime + ? "TRACKED TOKENS · AVAILABLE HISTORY" + : "TRACKED TOKENS · \(self.payload.days) DAYS" + } + + private var spendTitle: String { + self.payload.range == .allTime + ? "EST. SPEND · SINCE \(ShareStatsFormatting.dataThrough(self.payload.periodStart).uppercased())" + : "EST. \(self.payload.days)-DAY SPEND" + } + + private func coverageText(_ coveredDayCount: Int) -> String { + self.payload.range == .allTime + ? "\(coveredDayCount) covered days" + : "\(coveredDayCount)/\(self.payload.days)d" + } + private func color(for model: ShareStatsModelPayload) -> Color { guard let index = Self.providerPaletteIndex(for: model, providers: self.payload.providers) else { return self.secondary @@ -256,6 +275,7 @@ private struct ShareStatsModelRow: View { private struct ShareStatsProviderRow: View { let rank: Int let provider: ShareStatsProviderPayload + let range: SpendDashboardRange let days: Int let color: Color @@ -305,7 +325,9 @@ private struct ShareStatsProviderRow: View { } if let cost = self.provider.estimatedCost, cost.isFinite { metrics.append("~\(ShareStatsFormatting.currency(cost, code: self.provider.currencyCode))") - if self.provider.coveredDayCount < self.days { + if self.range == .allTime { + metrics.append("\(self.provider.coveredDayCount) covered days") + } else if self.provider.coveredDayCount < self.days { metrics.append("\(self.provider.coveredDayCount)/\(self.days)d") } } else { diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 8a8e2945e9..2f98316042 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -92,7 +92,9 @@ struct ShareStatsCurrencyPayload: Sendable, Equatable, Identifiable { } struct ShareStatsPayload: Sendable, Equatable { + let range: SpendDashboardRange let days: Int + let periodStart: Date let periodEnd: Date let providers: [ShareStatsProviderPayload] let topModels: [ShareStatsModelPayload] @@ -286,8 +288,13 @@ enum ShareStatsBuilder { } let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() + let periodStart = model.groups.flatMap(\.providers).compactMap(\.coverageStart).min() + ?? model.groups.map(\.chartDomain.lowerBound).min() + ?? periodEnd let payload = ShareStatsPayload( + range: model.range, days: model.requestedDays, + periodStart: periodStart, periodEnd: periodEnd, providers: providers, topModels: topModels, @@ -343,15 +350,16 @@ enum ShareStatsFormatting { } static func text(_ payload: ShareStatsPayload) -> String { - var lines = ["My AI subscriptions · last \(payload.days) days"] + var lines = ["My AI subscriptions · \(self.periodLabel(payload))"] if let tokens = payload.totalTokens { lines.append("\(self.compactCount(tokens)) tracked tokens") } lines.append(contentsOf: payload.currencies.map { currency in let spend = currency.estimatedCost.map { "\(self.currency($0, code: currency.currencyCode)) estimated" } ?? "Spend unavailable" - return "\(currency.currencyCode): \(spend) · " - + "coverage \(currency.coveredDayCount)/\(payload.days) days" + return "\(currency.currencyCode): \(spend) · " + self.coverageLabel( + coveredDayCount: currency.coveredDayCount, + payload: payload) }) lines.append(contentsOf: payload.providers.map { provider in var metrics: [String] = [] @@ -363,8 +371,12 @@ enum ShareStatsFormatting { } else { metrics.append("Spend unavailable") } - if provider.estimatedCost != nil, provider.coveredDayCount < payload.days { - metrics.append("\(provider.coveredDayCount)/\(payload.days) days") + if provider.estimatedCost != nil, + payload.range == .allTime || provider.coveredDayCount < payload.days + { + metrics.append(self.coverageLabel( + coveredDayCount: provider.coveredDayCount, + payload: payload)) } let subscription = provider.subscriptionName.map { " · \($0)" } ?? "" return "\(provider.providerName)\(subscription): \(metrics.joined(separator: " · "))" @@ -385,4 +397,18 @@ enum ShareStatsFormatting { lines.append("Generated locally by CodexBar · Data through \(self.dataThrough(payload.periodEnd))") return lines.joined(separator: "\n") } + + static func periodLabel(_ payload: ShareStatsPayload) -> String { + if payload.range == .allTime { + return "available history since \(self.dataThrough(payload.periodStart))" + } + return "last \(payload.days) days" + } + + static func coverageLabel(coveredDayCount: Int, payload: ShareStatsPayload) -> String { + if payload.range == .allTime { + return "\(coveredDayCount) covered days" + } + return "coverage \(coveredDayCount)/\(payload.days) days" + } } diff --git a/Sources/CodexBar/ShareStatsRenderer.swift b/Sources/CodexBar/ShareStatsRenderer.swift index 7d440c8bea..ded455a5ee 100644 --- a/Sources/CodexBar/ShareStatsRenderer.swift +++ b/Sources/CodexBar/ShareStatsRenderer.swift @@ -70,6 +70,8 @@ enum ShareStatsExporter { } private static func defaultFilename(_ payload: ShareStatsPayload) -> String { - "codexbar-subscriptions-last-\(payload.days)-days.png" + payload.range == .allTime + ? "codexbar-subscriptions-available-history.png" + : "codexbar-subscriptions-last-\(payload.days)-days.png" } } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 3ff06c651b..98a9ad480d 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -802,16 +802,22 @@ final class SpendDashboardController { private(set) var failedSourceCount = 0 private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? - private(set) var selectedDays: Int + private(set) var selectedRange: SpendDashboardRange + + var selectedDays: Int { + self.selectedRange.dayCount ?? self.model.requestedDays + } private static let daysDefaultsKey = "settingsSpendDashboardDays" private let userDefaults: UserDefaults private let requestBuilder: RequestBuilder private let cachedLoader: CachedLoader? private let loader: Loader + private let historyLedger: SpendHistoryLedger? private let nowProvider: @Sendable () -> Date private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] + private var ledgerInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary @@ -821,14 +827,18 @@ final class SpendDashboardController { requestBuilder: @escaping RequestBuilder, cachedLoader: CachedLoader? = nil, loader: @escaping Loader = SpendDashboardSource.load, + historyLedger: SpendHistoryLedger? = nil, nowProvider: @escaping @Sendable () -> Date = { Date() }) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder self.cachedLoader = cachedLoader self.loader = loader + self.historyLedger = historyLedger self.nowProvider = nowProvider - self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) + self.selectedRange = userDefaults.object(forKey: Self.daysDefaultsKey) == nil + ? .last30Days + : SpendDashboardRange.storedValue(userDefaults.integer(forKey: Self.daysDefaultsKey)) } func update(configuration: SpendDashboardConfiguration, force: Bool = false) { @@ -869,6 +879,7 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + self.ledgerInputs.removeAll { invalidatedSourceIDs.contains($0.id) } self.failedSourceCount = 0 self.rebuildModel() } @@ -881,6 +892,7 @@ final class SpendDashboardController { guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { self.loadedInputs = [] + self.ledgerInputs = [] self.failedSourceCount = 0 self.isRefreshing = false self.lastSuccessfulConfiguration = configuration @@ -993,7 +1005,7 @@ final class SpendDashboardController { self.startLoad(configuration: latestConfiguration, phase: .ordinary) return } - self.apply( + await self.apply( request: request, result: result, invalidatedSourceIDs: invalidatedSourceIDs, @@ -1020,7 +1032,7 @@ final class SpendDashboardController { case let .reconciling(outcome): let reconciled = Self.merge(outcome: outcome, capture: request) - self.apply( + await self.apply( request: request, result: reconciled.result, invalidatedSourceIDs: outcome.invalidatedSourceIDs, @@ -1048,7 +1060,7 @@ final class SpendDashboardController { request: SpendDashboardLoadRequest, result: SpendDashboardLoadResult, invalidatedSourceIDs: Set, - confirmedEmptySourceIDs: Set) + confirmedEmptySourceIDs: Set) async { let codexDisplayNames = request.configuration.codexAccountDisplayNames self.refreshRetainedCodexDisplayNames(codexDisplayNames) @@ -1072,6 +1084,16 @@ final class SpendDashboardController { self.isRefreshing = false self.phase = .ordinary self.loadTask = nil + if let historyLedger = self.historyLedger { + let ownership = SpendHistoryLedger.ownershipScopes( + sourceOwnershipFingerprints: request.configuration.sourceOwnershipFingerprints, + codexAccountIdentities: request.configuration.codexAccountIdentities) + let snapshots = await historyLedger.record( + inputs: self.loadedInputs, + ownership: ownership) + guard self.configuration == request.configuration else { return } + self.ledgerInputs = snapshots.map(\.input) + } self.rebuildModel() } @@ -1121,10 +1143,13 @@ final class SpendDashboardController { } func selectDays(_ days: Int) { - let days = Self.normalizedDays(days) - guard days != self.selectedDays else { return } - self.selectedDays = days - self.userDefaults.set(days, forKey: Self.daysDefaultsKey) + self.selectRange(SpendDashboardRange.storedValue(days)) + } + + func selectRange(_ range: SpendDashboardRange) { + guard range != self.selectedRange else { return } + self.selectedRange = range + self.userDefaults.set(range.rawValue, forKey: Self.daysDefaultsKey) self.rebuildModel() } @@ -1145,13 +1170,75 @@ final class SpendDashboardController { } private func rebuildModel() { + let inputs = self.selectedRange == .allTime ? self.allTimeInputs() : self.loadedInputs self.model = SpendDashboardModel.build( - inputs: self.loadedInputs, - requestedDays: self.selectedDays, + inputs: inputs, + range: self.selectedRange, now: self.loadedAt, preferredCurrencyCode: self.configuration?.preferredCurrencyCode ?? "auto") } + private func allTimeInputs() -> [SpendDashboardModel.ProviderInput] { + let liveInputsByID = Dictionary(uniqueKeysWithValues: self.loadedInputs.map { ($0.id, $0) }) + return self.ledgerInputs.map { ledgerInput in + guard Self.hasCaptureGaps(ledgerInput), + let liveInput = liveInputsByID[ledgerInput.id], + let tokenActivityCache = Self.establishedTokenActivity(from: liveInput) + else { return ledgerInput } + return SpendDashboardModel.ProviderInput( + id: ledgerInput.id, + provider: ledgerInput.provider, + displayName: ledgerInput.displayName, + modelProviderName: ledgerInput.modelProviderName, + snapshot: ledgerInput.snapshot, + tokenActivityCache: tokenActivityCache, + trackedCoverage: ledgerInput.trackedCoverage) + } + } + + private static func hasCaptureGaps(_ input: SpendDashboardModel.ProviderInput) -> Bool { + guard let coverage = input.trackedCoverage else { return false } + var calendar = Calendar(identifier: .gregorian) + // Provider-specific by design: Mistral's persisted activity buckets are keyed by UTC calendar day. + calendar.timeZone = input.provider == .mistral + ? TimeZone(secondsFromGMT: 0) ?? .gmt + : .current + let start = calendar.startOfDay(for: coverage.start) + let end = calendar.startOfDay(for: coverage.end) + let span = (calendar.dateComponents([.day], from: start, to: end).day ?? 0) + 1 + return coverage.coveredDayCount < max(0, span) + } + + private static func establishedTokenActivity( + from input: SpendDashboardModel.ProviderInput) -> CostUsageTokenActivityCache? + { + if let tokenActivityCache = input.tokenActivityCache { return tokenActivityCache } + let snapshot = input.snapshot + guard snapshot.historyCoverageIsEstablished, snapshot.historyDays > 0 else { return nil } + var calendar = Calendar(identifier: .gregorian) + // Provider-specific by design: Mistral's live activity coverage is expressed in UTC day keys. + calendar.timeZone = input.provider == .mistral + ? TimeZone(secondsFromGMT: 0) ?? .gmt + : .current + let end = calendar.startOfDay(for: snapshot.updatedAt) + guard let start = calendar.date(byAdding: .day, value: -(snapshot.historyDays - 1), to: end) else { + return nil + } + return CostUsageTokenActivityCache( + daily: snapshot.daily, + coverageSinceKey: Self.dayKey(start, calendar: calendar), + coverageUntilKey: Self.dayKey(end, calendar: calendar)) + } + + private static func dayKey(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { guard !displayNamesByID.isEmpty else { return } var didChange = false @@ -1178,7 +1265,9 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, - snapshot: input.snapshot) + snapshot: input.snapshot, + tokenActivityCache: input.tokenActivityCache, + trackedCoverage: input.trackedCoverage) } private static func sameSourceOwnership( @@ -1227,8 +1316,4 @@ final class SpendDashboardController { return ("codex:\(accountID)", identity) }) } - - private static func normalizedDays(_ value: Int) -> Int { - value == 7 ? 7 : 30 - } } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 1ec6fde68e..dfaf30ea95 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation struct SpendDashboardModel: Equatable, Sendable { + struct TrackedCoverage: Equatable, Sendable { + let start: Date + let end: Date + let coveredDayCount: Int + } + struct ProviderInput: Sendable { let id: String let provider: UsageProvider @@ -9,6 +15,7 @@ struct SpendDashboardModel: Equatable, Sendable { let modelProviderName: String let snapshot: CostUsageTokenSnapshot let tokenActivityCache: CostUsageTokenActivityCache? + let trackedCoverage: TrackedCoverage? init( id: String? = nil, @@ -16,7 +23,8 @@ struct SpendDashboardModel: Equatable, Sendable { displayName: String, modelProviderName: String? = nil, snapshot: CostUsageTokenSnapshot, - tokenActivityCache: CostUsageTokenActivityCache? = nil) + tokenActivityCache: CostUsageTokenActivityCache? = nil, + trackedCoverage: TrackedCoverage? = nil) { self.id = id ?? provider.rawValue self.provider = provider @@ -24,6 +32,7 @@ struct SpendDashboardModel: Equatable, Sendable { self.modelProviderName = modelProviderName ?? displayName self.snapshot = snapshot self.tokenActivityCache = tokenActivityCache + self.trackedCoverage = trackedCoverage } } @@ -35,6 +44,27 @@ struct SpendDashboardModel: Equatable, Sendable { let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int + let coverageStart: Date? + + init( + id: String, + rank: Int, + provider: UsageProvider, + displayName: String, + totalTokens: Int?, + totalCost: Double?, + coveredDayCount: Int, + coverageStart: Date? = nil) + { + self.id = id + self.rank = rank + self.provider = provider + self.displayName = displayName + self.totalTokens = totalTokens + self.totalCost = totalCost + self.coveredDayCount = coveredDayCount + self.coverageStart = coverageStart + } } struct ModelRow: Identifiable, Equatable, Sendable { @@ -96,6 +126,7 @@ struct SpendDashboardModel: Equatable, Sendable { } } + let range: SpendDashboardRange let requestedDays: Int let groups: [CurrencyGroup] let tokenActivity: [TokenActivityPoint] @@ -107,6 +138,19 @@ struct SpendDashboardModel: Equatable, Sendable { groups: [CurrencyGroup], tokenActivity: [TokenActivityPoint] = []) { + self.range = requestedDays == 7 ? .last7Days : .last30Days + self.requestedDays = requestedDays + self.groups = groups + self.tokenActivity = tokenActivity + } + + init( + range: SpendDashboardRange, + requestedDays: Int, + groups: [CurrencyGroup], + tokenActivity: [TokenActivityPoint] = []) + { + self.range = range self.requestedDays = requestedDays self.groups = groups self.tokenActivity = tokenActivity @@ -119,8 +163,24 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: Calendar = .current, preferredCurrencyCode: String = "auto") -> Self { - let days = max(1, min(30, requestedDays)) + let range: SpendDashboardRange = requestedDays == 7 ? .last7Days : .last30Days + return self.build( + inputs: inputs, + range: range, + now: now, + calendar: calendar, + preferredCurrencyCode: preferredCurrencyCode) + } + + static func build( + inputs: [ProviderInput], + range: SpendDashboardRange, + now: Date, + calendar: Calendar = .current, + preferredCurrencyCode: String = "auto") -> Self + { let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) + let days = Self.requestedDays(range: range, inputs: inputs, now: now, calendar: calculationCalendar) let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( @@ -140,12 +200,11 @@ struct SpendDashboardModel: Equatable, Sendable { Self.buildCurrencyGroup( currencyCode: currencyCode, inputs: inputs, - days: days, - now: now, - calendar: calculationCalendar) + window: BuildWindow(days: days, range: range, now: now, calendar: calculationCalendar)) } .sorted { $0.currencyCode < $1.currencyCode } return Self( + range: range, requestedDays: days, groups: groups, tokenActivity: Self.tokenActivity( @@ -160,6 +219,13 @@ struct SpendDashboardModel: Equatable, Sendable { let costMultiplier: Double } + private struct BuildWindow { + let days: Int + let range: SpendDashboardRange + let now: Date + let calendar: Calendar + } + struct InputSummary { let input: ProviderInput let costMultiplier: Double @@ -211,17 +277,16 @@ struct SpendDashboardModel: Equatable, Sendable { private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], - days: Int, - now: Date, - calendar: Calendar) -> CurrencyGroup + window: BuildWindow) -> CurrencyGroup { - let bounds = Self.bounds(days: days, now: now, calendar: calendar) + let bounds = Self.bounds(days: window.days, now: window.now, calendar: window.calendar) let summaries = inputs.map { classified in Self.inputSummary( input: classified.input, costMultiplier: classified.costMultiplier, bounds: bounds, - calendar: calendar) + range: window.range, + calendar: window.calendar) } let providers = Self.providerRows(summaries) let modelSummaries = summaries.filter { summary in @@ -245,8 +310,10 @@ struct SpendDashboardModel: Equatable, Sendable { dailyPoints: dailyPoints, totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), totalCost: Self.completeCostSum(providers.map(\.totalCost)), - coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), - chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), + coveredDayCount: window.range == .allTime + ? Self.allTimeCommonCoverageDayCount(summaries: summaries, calendar: window.calendar) + : Self.commonCoverageDayCount(summaries: summaries, calendar: window.calendar), + chartDomain: Self.chartDomain(bounds: bounds, calendar: window.calendar), modelHistoryCompleteness: modelHistoryCompleteness) } @@ -254,12 +321,20 @@ struct SpendDashboardModel: Equatable, Sendable { input: ProviderInput, costMultiplier: Double, bounds: ClosedRange, + range: SpendDashboardRange, calendar: Calendar) -> InputSummary { - let coveredInterval = Self.coverageInterval( - input: input, - bounds: bounds, - displayCalendar: calendar) + let coveredInterval = if range == .allTime, let trackedCoverage = input.trackedCoverage { + Self.trackedCoverageInterval( + trackedCoverage, + bounds: bounds, + displayCalendar: calendar) + } else { + Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + } var entries: [WindowEntry] = [] var hasInvalidCostHistory = false var hasInvalidTokenHistory = false @@ -277,15 +352,24 @@ struct SpendDashboardModel: Equatable, Sendable { } entries.append(WindowEntry(day: day, entry: entry)) } - let coveredDayCount = Self.dayCount(in: coveredInterval, calendar: calendar) - let hasCompleteTokenHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let coveredDayCount = if range == .allTime, let trackedCoverage = input.trackedCoverage { + max(0, trackedCoverage.coveredDayCount) + } else { + Self.dayCount(in: coveredInterval, calendar: calendar) + } + // Ledger snapshots contain only explicitly covered buckets. Gaps remain absent and are + // disclosed by coveredDayCount, but they must not discard the proven days around them. + let isTrackedAllTimeHistory = range == .allTime && input.trackedCoverage != nil + let hasCompleteTokenHistory = isTrackedAllTimeHistory || + Self.hasCompleteTokenHistory(input, displayCalendar: calendar) let tokenAggregateIsConsistent = input.snapshot.last30DaysTokens == nil || hasCompleteTokenHistory let totalTokens = hasInvalidTokenHistory || !tokenAggregateIsConsistent ? nil : entries.isEmpty ? (coveredDayCount > 0 && hasCompleteTokenHistory ? 0 : nil) : Self.completeIntSum(entries.map { Self.nonnegative($0.entry.totalTokens) }) - let hasCompleteCostHistory = Self.hasCompleteCostHistory(input, displayCalendar: calendar) + let hasCompleteCostHistory = isTrackedAllTimeHistory || + Self.hasCompleteCostHistory(input, displayCalendar: calendar) let costAggregateIsConsistent = input.snapshot.last30DaysCostUSD == nil || hasCompleteCostHistory let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent let totalCost = invalidCostHistory @@ -325,7 +409,8 @@ struct SpendDashboardModel: Equatable, Sendable { displayName: entry.element.input.displayName, totalTokens: entry.element.totalTokens, totalCost: entry.element.totalCost, - coveredDayCount: entry.element.coveredDayCount) + coveredDayCount: entry.element.coveredDayCount, + coverageStart: entry.element.input.trackedCoverage?.start) } } @@ -552,6 +637,19 @@ struct SpendDashboardModel: Equatable, Sendable { return start...end } + private static func requestedDays( + range: SpendDashboardRange, + inputs: [ProviderInput], + now: Date, + calendar: Calendar) -> Int + { + if let days = range.dayCount { return days } + let end = calendar.startOfDay(for: now) + guard let start = inputs.compactMap(\.trackedCoverage?.start).min() else { return 1 } + let normalizedStart = calendar.startOfDay(for: min(start, end)) + return max(1, (calendar.dateComponents([.day], from: normalizedStart, to: end).day ?? 0) + 1) + } + private static func gregorianCalendar(timeZone: TimeZone) -> Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone @@ -576,6 +674,19 @@ struct SpendDashboardModel: Equatable, Sendable { return overlapStart...overlapEnd } + private static func trackedCoverageInterval( + _ coverage: TrackedCoverage, + bounds: ClosedRange, + displayCalendar: Calendar) -> ClosedRange? + { + let coverageStart = displayCalendar.startOfDay(for: coverage.start) + let coverageEnd = displayCalendar.startOfDay(for: coverage.end) + let overlapStart = max(bounds.lowerBound, coverageStart) + let overlapEnd = min(bounds.upperBound, coverageEnd) + guard overlapStart <= overlapEnd else { return nil } + return overlapStart...overlapEnd + } + private static func sourceCoverageInterval( input: ProviderInput, displayCalendar: Calendar) -> ClosedRange @@ -602,6 +713,21 @@ struct SpendDashboardModel: Equatable, Sendable { return Self.dayCount(in: intersection, calendar: calendar) } + private static func allTimeCommonCoverageDayCount( + summaries: [InputSummary], + calendar: Calendar) -> Int + { + guard let first = summaries.first else { return 0 } + guard summaries.count > 1 else { return first.coveredDayCount } + + var commonDays = Set(first.entries.map { calendar.startOfDay(for: $0.day) }) + for summary in summaries.dropFirst() { + commonDays.formIntersection(summary.entries.map { calendar.startOfDay(for: $0.day) }) + if commonDays.isEmpty { return 0 } + } + return commonDays.count + } + private static func dayCount(in interval: ClosedRange?, calendar: Calendar) -> Int { guard let interval else { return 0 } return (calendar.dateComponents([.day], from: interval.lowerBound, to: interval.upperBound).day ?? 0) + 1 diff --git a/Sources/CodexBar/SpendDashboardRange.swift b/Sources/CodexBar/SpendDashboardRange.swift new file mode 100644 index 0000000000..1c8981b235 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardRange.swift @@ -0,0 +1,19 @@ +import Foundation + +enum SpendDashboardRange: Int, CaseIterable, Sendable, Equatable { + case last7Days = 7 + case last30Days = 30 + case allTime = 0 + + var dayCount: Int? { + switch self { + case .last7Days: 7 + case .last30Days: 30 + case .allTime: nil + } + } + + static func storedValue(_ value: Int) -> Self { + Self(rawValue: value) ?? .last30Days + } +} diff --git a/Sources/CodexBar/SpendHistoryLedger.swift b/Sources/CodexBar/SpendHistoryLedger.swift new file mode 100644 index 0000000000..7c732abb48 --- /dev/null +++ b/Sources/CodexBar/SpendHistoryLedger.swift @@ -0,0 +1,457 @@ +import CodexBarCore +import Foundation + +/// Durable, account-scoped daily cost history for the spend dashboard. +/// +/// Provider snapshots are rolling windows. This ledger keeps only days for which a provider +/// established coverage, so an "All Time" view can distinguish tracked history from a guess. +actor SpendHistoryLedger { + typealias FileWriter = @Sendable (Data, URL) throws -> Void + typealias PermissionEnforcer = @Sendable (URL) throws -> Bool + + struct Snapshot: Sendable { + let input: SpendDashboardModel.ProviderInput + let coverageStart: Date + let coverageEnd: Date + let coveredDayCount: Int + let hasContinuousCoverage: Bool + } + + private struct Document: Codable { + let version: Int + var sources: [Source] + } + + private struct Source: Codable { + let sourceID: String + let provider: UsageProvider + var displayName: String + var modelProviderName: String + let ownershipFingerprint: String + let currencyCode: String + var days: [Day] + } + + private struct Day: Codable, Equatable { + let date: String + let inputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let requestCount: Int? + let cost: Double + let modelsUsed: [String]? + let modelBreakdowns: [Model] + } + + private struct Model: Codable, Equatable { + let modelName: String + let cost: Double? + let totalTokens: Int? + let requestCount: Int? + let standardCost: Double? + let priorityCost: Double? + let standardTokens: Int? + let priorityTokens: Int? + } + + private static let schemaVersion = 1 + private let fileURL: URL + private let fileManager: FileManager + private let fileWriter: FileWriter + private let permissionEnforcer: PermissionEnforcer + private var document: Document? + + init( + fileURL: URL = SpendHistoryLedger.defaultFileURL(), + fileManager: FileManager = .default, + fileWriter: @escaping FileWriter = { data, url in + try data.write(to: url, options: [.atomic]) + }, + permissionEnforcer: @escaping PermissionEnforcer = { url in + try SpendHistoryLedger.enforceOwnerOnlyPermissions(at: url) + }) + { + self.fileURL = fileURL + self.fileManager = fileManager + self.fileWriter = fileWriter + self.permissionEnforcer = permissionEnforcer + } + + /// Records complete provider windows and returns every matching tracked source. + /// Invalid or partial windows are ignored without deleting previously proven history. + func record( + inputs: [SpendDashboardModel.ProviderInput], + ownership: [String: String]) -> [Snapshot] + { + let durableDocument = self.load() + var candidateDocument = durableDocument + var changed = false + + for input in inputs { + guard let fingerprint = ownership[input.id], + let capturedDays = Self.completeDays(from: input) + else { continue } + let currencyCode = input.snapshot.currencyCode.uppercased() + let matchingIndex = candidateDocument.sources.firstIndex { + $0.sourceID == input.id && + $0.provider == input.provider && + $0.ownershipFingerprint == fingerprint && + $0.currencyCode == currencyCode + } + if let matchingIndex { + var source = candidateDocument.sources[matchingIndex] + let priorDays = Dictionary(uniqueKeysWithValues: source.days.map { ($0.date, $0) }) + var mergedDays = priorDays + for day in capturedDays { + mergedDays[day.date] = day + } + source.displayName = input.displayName + source.modelProviderName = input.modelProviderName + source.days = mergedDays.values.sorted { $0.date < $1.date } + changed = changed || source.days != candidateDocument.sources[matchingIndex].days || + source.displayName != candidateDocument.sources[matchingIndex].displayName || + source.modelProviderName != candidateDocument.sources[matchingIndex].modelProviderName + candidateDocument.sources[matchingIndex] = source + } else { + candidateDocument.sources.append(Source( + sourceID: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + ownershipFingerprint: fingerprint, + currencyCode: currencyCode, + days: capturedDays)) + changed = true + } + } + + if changed { + candidateDocument.sources.sort { + ($0.sourceID, $0.ownershipFingerprint, $0.currencyCode) < + ($1.sourceID, $1.ownershipFingerprint, $1.currencyCode) + } + guard self.persist(candidateDocument) else { + return Self.snapshots(document: durableDocument, inputs: inputs, ownership: ownership) + } + self.document = candidateDocument + } + return Self.snapshots(document: candidateDocument, inputs: inputs, ownership: ownership) + } + + func snapshots( + for inputs: [SpendDashboardModel.ProviderInput], + ownership: [String: String]) -> [Snapshot] + { + Self.snapshots( + document: self.load(), + inputs: inputs, + ownership: ownership) + } + + private func load() -> Document { + if let document = self.document { return document } + let permissionsAreSafe = Self.hasOwnerOnlyPermissions(at: self.fileURL) || + ((try? self.permissionEnforcer(self.fileURL)) == true && + Self.hasOwnerOnlyPermissions(at: self.fileURL)) + let decoded = permissionsAreSafe + ? try? JSONDecoder().decode(Document.self, from: Data(contentsOf: self.fileURL)) + : nil + let document = decoded?.version == Self.schemaVersion + ? decoded ?? Document(version: Self.schemaVersion, sources: []) + : Document(version: Self.schemaVersion, sources: []) + self.document = document + return document + } + + private func persist(_ document: Document) -> Bool { + let parentURL = self.fileURL.deletingLastPathComponent() + let candidateURL = parentURL + .appendingPathComponent(".\(self.fileURL.lastPathComponent).\(UUID().uuidString).tmp") + let backupName = ".\(self.fileURL.lastPathComponent).\(UUID().uuidString).backup" + let backupURL = parentURL.appendingPathComponent(backupName) + defer { try? self.fileManager.removeItem(at: candidateURL) } + defer { try? self.fileManager.removeItem(at: backupURL) } + do { + try self.fileManager.createDirectory( + at: parentURL, + withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try self.fileWriter(encoder.encode(document), candidateURL) + guard try self.permissionEnforcer(candidateURL) else { return false } + if self.fileManager.fileExists(atPath: self.fileURL.path) { + _ = try self.fileManager.replaceItemAt( + self.fileURL, + withItemAt: candidateURL, + backupItemName: backupName) + } else { + try self.fileManager.moveItem(at: candidateURL, to: self.fileURL) + } + guard Self.hasOwnerOnlyPermissions(at: self.fileURL) else { + try? self.fileManager.removeItem(at: self.fileURL) + if self.fileManager.fileExists(atPath: backupURL.path) { + try? self.fileManager.moveItem(at: backupURL, to: self.fileURL) + } + return false + } + return true + } catch { + // Best-effort local history must never make provider refresh fail. + return false + } + } + + private static func completeDays(from input: SpendDashboardModel.ProviderInput) -> [Day]? { + let snapshot = input.snapshot + guard snapshot.historyCoverageIsEstablished, + snapshot.historyDays > 0, + Self.validAggregate(snapshot.last30DaysCostUSD) + else { return nil } + let calendar = Self.calendar(for: input.provider) + let end = calendar.startOfDay(for: snapshot.updatedAt) + guard let start = calendar.date(byAdding: .day, value: -(snapshot.historyDays - 1), to: end) else { + return nil + } + var entriesByDate: [String: CostUsageDailyReport.Entry] = [:] + for entry in snapshot.daily { + guard let date = Self.day(entry.date, calendar: calendar) else { + guard SpendDashboardModel.hasProvenZeroCost(entry) else { return nil } + continue + } + guard date >= start, date <= end else { + guard SpendDashboardModel.hasProvenZeroCost(entry) else { return nil } + continue + } + guard Self.validCost(entry.costUSD) != nil else { return nil } + let key = Self.dayKey(date, calendar: calendar) + guard entriesByDate[key] == nil else { return nil } + entriesByDate[key] = entry + } + let observedTotal = entriesByDate.values.reduce(0.0) { $0 + ($1.costUSD ?? 0) } + guard observedTotal.isFinite, + let aggregate = snapshot.last30DaysCostUSD, + SpendDashboardModel.costsMatch(observedTotal, aggregate) + else { return nil } + + return (0.. Day? in + guard let date = calendar.date(byAdding: .day, value: offset, to: start) else { return nil } + let key = Self.dayKey(date, calendar: calendar) + return Self.dayRecord(date: key, entry: entriesByDate[key]) + } + } + + private static func snapshots( + document: Document, + inputs: [SpendDashboardModel.ProviderInput], + ownership: [String: String]) -> [Snapshot] + { + let currentInputs = Dictionary(uniqueKeysWithValues: inputs.map { ($0.id, $0) }) + return document.sources.compactMap { source in + guard ownership[source.sourceID] == source.ownershipFingerprint, + currentInputs[source.sourceID].map({ + $0.provider == source.provider && + $0.snapshot.currencyCode.uppercased() == source.currencyCode + }) != false, + let first = source.days.first, + let last = source.days.last + else { return nil } + let calendar = Self.calendar(for: source.provider) + guard let start = Self.day(first.date, calendar: calendar), + let end = Self.day(last.date, calendar: calendar) + else { return nil } + let span = (calendar.dateComponents([.day], from: start, to: end).day ?? 0) + 1 + let daily = source.days.map(Self.entry) + let totalCost = source.days.reduce(0.0) { $0 + $1.cost } + let totalTokens = Self.completeIntSum(source.days.map(\.totalTokens)) + let input = SpendDashboardModel.ProviderInput( + id: source.sourceID, + provider: source.provider, + displayName: source.displayName, + modelProviderName: source.modelProviderName, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + currencyCode: source.currencyCode, + historyDays: span, + // Every emitted bucket is proven by a complete provider window. The separate + // tracked coverage count remains authoritative when captures have calendar gaps. + historyCoverageIsEstablished: true, + historyLabel: "Tracked since \(first.date)", + daily: daily, + updatedAt: end), + trackedCoverage: SpendDashboardModel.TrackedCoverage( + start: start, + end: end, + coveredDayCount: source.days.count)) + return Snapshot( + input: input, + coverageStart: start, + coverageEnd: end, + coveredDayCount: source.days.count, + hasContinuousCoverage: source.days.count == span) + } + .sorted { $0.input.id < $1.input.id } + } + + private static func dayRecord(date: String, entry: CostUsageDailyReport.Entry?) -> Day { + guard let entry else { + return Day( + date: date, + inputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0, + cost: 0, + modelsUsed: nil, + modelBreakdowns: []) + } + return Day( + date: date, + inputTokens: entry.inputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + outputTokens: entry.outputTokens, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount, + cost: entry.costUSD ?? 0, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns?.map(self.modelRecord) ?? []) + } + + private static func modelRecord(_ model: CostUsageDailyReport.ModelBreakdown) -> Model { + Model( + modelName: model.modelName, + cost: model.costUSD, + totalTokens: model.totalTokens, + requestCount: model.requestCount, + standardCost: model.standardCostUSD, + priorityCost: model.priorityCostUSD, + standardTokens: model.standardTokens, + priorityTokens: model.priorityTokens) + } + + private static func entry(_ day: Day) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day.date, + inputTokens: day.inputTokens, + outputTokens: day.outputTokens, + cacheReadTokens: day.cacheReadTokens, + cacheCreationTokens: day.cacheCreationTokens, + totalTokens: day.totalTokens, + requestCount: day.requestCount, + costUSD: day.cost, + modelsUsed: day.modelsUsed, + modelBreakdowns: day.modelBreakdowns.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.modelName, + costUSD: $0.cost, + totalTokens: $0.totalTokens, + requestCount: $0.requestCount, + standardCostUSD: $0.standardCost, + priorityCostUSD: $0.priorityCost, + standardTokens: $0.standardTokens, + priorityTokens: $0.priorityTokens) + }) + } + + static func ownershipScopes( + sourceOwnershipFingerprints: [String], + codexAccountIdentities: [String]) -> [String: String] + { + var result: [String: String] = [:] + for value in sourceOwnershipFingerprints { + guard let separator = value.firstIndex(of: ":") else { continue } + let sourceID = String(value[.. Bool { + self.validCost(value) != nil + } + + private static func validCost(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + private static func completeIntSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + var total = 0 + for value in values.compactMap(\.self) { + let addition = total.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + total = addition.partialValue + } + return total + } + + private static func day(_ rawValue: String, calendar: Calendar) -> Date? { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + formatter.isLenient = false + guard let date = formatter.date(from: rawValue), formatter.string(from: date) == rawValue else { return nil } + return calendar.startOfDay(for: date) + } + + private static func dayKey(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + private static func calendar(for provider: UsageProvider) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + // Provider-specific by design: Mistral daily usage buckets are defined in UTC. + calendar.timeZone = provider == .mistral + ? TimeZone(secondsFromGMT: 0) ?? .gmt + : .current + return calendar + } + + private static func enforceOwnerOnlyPermissions(at url: URL) throws -> Bool { + #if os(macOS) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: url.path) + return self.hasOwnerOnlyPermissions(at: url) + #else + return true + #endif + } + + private static func hasOwnerOnlyPermissions(at url: URL) -> Bool { + #if os(macOS) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let permissions = attributes[.posixPermissions] as? NSNumber + else { return false } + return permissions.intValue & 0o777 == 0o600 + #else + return true + #endif + } + + nonisolated static func defaultFileURL() -> URL { + let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + return root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("spend-history.json", isDirectory: false) + } +} diff --git a/Tests/CodexBarTests/LifetimeSpendRuntimeProofTests.swift b/Tests/CodexBarTests/LifetimeSpendRuntimeProofTests.swift new file mode 100644 index 0000000000..2fbb69b289 --- /dev/null +++ b/Tests/CodexBarTests/LifetimeSpendRuntimeProofTests.swift @@ -0,0 +1,115 @@ +#if DEBUG +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct LifetimeSpendRuntimeProofTests { + @Test + func `proof mode is inert without its explicit root`() { + #expect(LifetimeSpendRuntimeProofConfiguration.resolve(environment: [:]) == .notRequested) + } + + @Test + func `proof mode refuses roots outside private tmp`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("not-the-approved-prefix-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let resolution = LifetimeSpendRuntimeProofConfiguration.resolve(environment: [ + LifetimeSpendRuntimeProofConfiguration.rootEnvironmentKey: root.path, + LifetimeSpendRuntimeProofConfiguration.phaseEnvironmentKey: "record", + ]) + #expect(resolution == .rejected) + } + + @Test + func `proof mode accepts only a private sentinel directory`() throws { + let root = URL(fileURLWithPath: "/private/tmp", isDirectory: true) + .appendingPathComponent("codexbar-lifetime-proof.\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: root.path) + let runID = UUID().uuidString + let sentinel = Data("{\"schemaVersion\":1,\"runID\":\"\(runID)\"}".utf8) + try sentinel.write( + to: root.appendingPathComponent(LifetimeSpendRuntimeProofConfiguration.sentinelFilename), + options: .atomic) + + let resolution = LifetimeSpendRuntimeProofConfiguration.resolve(environment: [ + LifetimeSpendRuntimeProofConfiguration.rootEnvironmentKey: root.path, + LifetimeSpendRuntimeProofConfiguration.phaseEnvironmentKey: "reload", + ]) + #expect(resolution == .accepted(.init(root: root.standardizedFileURL, phase: .reload, runID: runID))) + } + + @Test + func `proof mode refuses a symlink into its approved prefix`() throws { + let destination = URL(fileURLWithPath: "/private/tmp", isDirectory: true) + .appendingPathComponent("codexbar-lifetime-proof.\(UUID().uuidString)", isDirectory: true) + let link = URL(fileURLWithPath: "/private/tmp", isDirectory: true) + .appendingPathComponent("codexbar-lifetime-proof.\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: link) + try? FileManager.default.removeItem(at: destination) + } + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: destination.path) + let sentinel = Data("{\"schemaVersion\":1,\"runID\":\"\(UUID().uuidString)\"}".utf8) + try sentinel.write( + to: destination.appendingPathComponent(LifetimeSpendRuntimeProofConfiguration.sentinelFilename), + options: .atomic) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: destination) + let resolution = LifetimeSpendRuntimeProofConfiguration.resolve(environment: [ + LifetimeSpendRuntimeProofConfiguration.rootEnvironmentKey: link.path, + LifetimeSpendRuntimeProofConfiguration.phaseEnvironmentKey: "record", + ]) + #expect(resolution == .rejected) + } + + @Test + func `proof mode refuses a symlink sentinel`() throws { + let root = URL(fileURLWithPath: "/private/tmp", isDirectory: true) + .appendingPathComponent("codexbar-lifetime-proof.\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: root.path) + let target = root.appendingPathComponent("sentinel-target.json") + let sentinel = Data("{\"schemaVersion\":1,\"runID\":\"\(UUID().uuidString)\"}".utf8) + try sentinel.write(to: target, options: .atomic) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent(LifetimeSpendRuntimeProofConfiguration.sentinelFilename), + withDestinationURL: target) + + let resolution = LifetimeSpendRuntimeProofConfiguration.resolve(environment: [ + LifetimeSpendRuntimeProofConfiguration.rootEnvironmentKey: root.path, + LifetimeSpendRuntimeProofConfiguration.phaseEnvironmentKey: "record", + ]) + #expect(resolution == .rejected) + } + + @Test + func `proof mode accepts only owner read write ledger permissions`() throws { + let ledgerURL = URL(fileURLWithPath: "/private/tmp", isDirectory: true) + .appendingPathComponent("codexbar-lifetime-proof-ledger.\(UUID().uuidString).json") + try Data("{}".utf8).write(to: ledgerURL, options: .atomic) + defer { try? FileManager.default.removeItem(at: ledgerURL) } + + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: ledgerURL.path) + #expect(LifetimeSpendRuntimeProofConfiguration.verifiedLedgerPermissions(at: ledgerURL) == "0600") + + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o644))], + ofItemAtPath: ledgerURL.path) + #expect(LifetimeSpendRuntimeProofConfiguration.verifiedLedgerPermissions(at: ledgerURL) == nil) + } +} +#endif diff --git a/Tests/CodexBarTests/LifetimeSpendScreenshotRenderTests.swift b/Tests/CodexBarTests/LifetimeSpendScreenshotRenderTests.swift new file mode 100644 index 0000000000..8fcbe64cfc --- /dev/null +++ b/Tests/CodexBarTests/LifetimeSpendScreenshotRenderTests.swift @@ -0,0 +1,221 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +/// Opt-in documentation renderer for the All Time dashboard and its share card. +/// +/// Run with: +/// CODEXBAR_LIFETIME_PROOF_DIR=docs/screenshots/lifetime-spend-proof \ +/// swift test --filter LifetimeSpendScreenshotRenderTests +@MainActor +struct LifetimeSpendScreenshotRenderTests { + private static let dashboardSize = CGSize(width: 900, height: 1370) + private static let periodStart = Self.date(year: 2025, month: 8, day: 1) + private static let periodEnd = Self.date(year: 2026, month: 8, day: 12) + + @Test + func `all time production views render synthetic privacy safe proof`() throws { + let model = Self.dashboardModel + let dashboardData = try #require(Self.pngData( + for: Self.dashboardView(model: model), + size: Self.dashboardSize)) + let dashboardBitmap = try #require(NSBitmapImageRep(data: dashboardData)) + #expect(dashboardBitmap.pixelsWide == Int(Self.dashboardSize.width * 2)) + #expect(dashboardBitmap.pixelsHigh == Int(Self.dashboardSize.height * 2)) + + let payload = try #require(ShareStatsBuilder.make(model: model)) + let shareText = ShareStatsFormatting.text(payload) + #expect(shareText.contains("available history since")) + #expect(shareText.contains("377 covered days")) + for forbidden in ["@", "/Users/", "akshay", "example.com", "fixture", "00000000"] { + #expect(!shareText.localizedCaseInsensitiveContains(forbidden)) + } + + let shareData = try #require(ShareStatsRenderer.pngData(for: payload)) + let shareBitmap = try #require(NSBitmapImageRep(data: shareData)) + #expect(shareBitmap.pixelsWide == Int(ShareStatsCardView.size.width)) + #expect(shareBitmap.pixelsHigh == Int(ShareStatsCardView.size.height)) + + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_LIFETIME_PROOF_DIR"] else { return } + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try dashboardData.write( + to: directory.appendingPathComponent("all-time-dashboard.png"), + options: .atomic) + try shareData.write( + to: directory.appendingPathComponent("all-time-share-stats.png"), + options: .atomic) + } + + private static func dashboardView(model: SpendDashboardModel) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SpendDashboardHeader( + selectedRange: .allTime, + isRefreshing: false, + isCostTrackingEnabled: true, + selectRange: { _ in }, + refresh: {}) + ForEach(model.groups) { group in + SpendCurrencySection( + group: group, + range: model.range, + requestedDays: model.requestedDays) + } + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + Text("Local estimates · Available history starts when CodexBar begins tracking.") + Spacer() + Toggle("Track costs", isOn: .constant(true)) + .toggleStyle(.switch) + .controlSize(.small) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(24) + } + .frame(width: self.dashboardSize.width, height: self.dashboardSize.height) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, .dark) + } + + private static var dashboardModel: SpendDashboardModel { + let points: [SpendDashboardModel.DailyPoint] = [ + self.point( + sourceID: "codex-a", + provider: .codex, + name: "Codex", + date: self.date(year: 2025, month: 8, day: 1), + cost: 12), + self.point( + sourceID: "claude-a", + provider: .claude, + name: "Claude", + date: self.date(year: 2025, month: 9, day: 16), + cost: 18), + self.point( + sourceID: "codex-b", + provider: .codex, + name: "Codex", + date: self.date(year: 2025, month: 11, day: 4), + cost: 31), + self.point( + sourceID: "claude-b", + provider: .claude, + name: "Claude", + date: self.date(year: 2026, month: 2, day: 10), + cost: 27), + self.point( + sourceID: "codex-c", + provider: .codex, + name: "Codex", + date: self.date(year: 2026, month: 5, day: 22), + cost: 43), + self.point( + sourceID: "claude-c", + provider: .claude, + name: "Claude", + date: self.date(year: 2026, month: 8, day: 12), + cost: 46.15), + ] + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex:proof", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 230_000_000, + totalCost: 128.45, + coveredDayCount: 377, + coverageStart: self.periodStart), + SpendDashboardModel.ProviderRow( + id: "claude:proof", + rank: 2, + provider: .claude, + displayName: "Claude", + totalTokens: 19_000_000, + totalCost: 48.70, + coveredDayCount: 377, + coverageStart: self.periodStart), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4", + totalTokens: 230_000_000, + totalCost: 128.45), + SpendDashboardModel.ModelRow( + rank: 2, + provider: .claude, + providerName: "Claude", + modelName: "claude-sonnet-4", + totalTokens: 19_000_000, + totalCost: 48.70), + ], + dailyPoints: points, + totalTokens: 249_000_000, + totalCost: 177.15, + coveredDayCount: 377, + chartDomain: self.periodStart...self.periodEnd, + modelHistoryCompleteness: .complete) + return SpendDashboardModel( + range: .allTime, + requestedDays: 377, + groups: [group]) + } + + private static func point( + sourceID: String, + provider: UsageProvider, + name: String, + date: Date, + cost: Double) -> SpendDashboardModel.DailyPoint + { + SpendDashboardModel.DailyPoint( + sourceID: sourceID, + provider: provider, + providerName: name, + day: date, + cost: cost, + stackStart: 0, + stackEnd: cost) + } + + private static func date(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } + + private static func pngData(for rootView: some View, size: CGSize) -> Data? { + let view = NSHostingView(rootView: rootView) + view.appearance = NSAppearance(named: .darkAqua) + view.frame = CGRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + + let scale: CGFloat = 2 + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width * scale), + pixelsHigh: Int(size.height * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = size + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + view.displayIgnoringOpacity(view.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index e43bf62363..672d101c48 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2187,7 +2187,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 254, + line: 302, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2195,7 +2195,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 365, + line: 416, anchor: ".count { $0.provider == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2211,7 +2211,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared provider integration dispatches a capability owned by the provider descriptor or adapter."), AllowedProviderConstruct( path: "Sources/CodexBar/ShareStatsPayload.swift", - line: 163, + line: 165, anchor: "([\"codestral-\", \"devstral-\", \"magistral-\", \"mistral-\", \"mistral \", \"mistral.\", \"mixtral-\"], \"Mistral\"),", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, @@ -2340,7 +2340,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1172, + line: 1259, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2356,7 +2356,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 639, + line: 765, anchor: "guard provider == .mistral else { return displayCalendar }", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 64a874f785..29cfd5b2e5 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -5,6 +5,40 @@ import Testing @testable import CodexBar struct ShareStatsTests { + @Test + func `all time share describes available tracked history honestly`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [.init( + id: "codex:example", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 100, + totalCost: 4, + coveredDayCount: 42, + coverageStart: Self.date)], + models: [], + dailyPoints: [], + totalTokens: 100, + totalCost: 4, + coveredDayCount: 42, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let model = SpendDashboardModel( + range: .allTime, + requestedDays: 60, + groups: [group]) + let payload = try #require(ShareStatsBuilder.make(model: model)) + let text = ShareStatsFormatting.text(payload) + + #expect(payload.range == .allTime) + #expect(text.contains("available history since")) + #expect(text.contains("42 covered days")) + #expect(!text.contains("last 60 days")) + #expect(!text.contains("42/60")) + } + @Test func `descriptor share plan labels preserve the legacy central table`() throws { var fingerprint: UInt64 = 1_469_598_103_934_665_603 @@ -43,7 +77,7 @@ struct ShareStatsTests { let text = ShareStatsFormatting.text(payload) #expect(text.contains("GBP: £12.00 estimated · coverage 10/30 days")) - #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · 10/30 days")) + #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · coverage 10/30 days")) #expect(text.contains("USD: Spend unavailable · coverage 0/30 days")) #expect(text.contains("Cursor · Cursor Pro: Spend unavailable")) #expect(!text.contains("£12.00 +")) diff --git a/Tests/CodexBarTests/SpendDashboardAllTimeDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardAllTimeDateTruthTests.swift new file mode 100644 index 0000000000..bd430feb4a --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardAllTimeDateTruthTests.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardAllTimeDateTruthTests { + @Test + func `Mistral UTC bounds preserve first day in positive offset timezone`() throws { + var india = Calendar(identifier: .gregorian) + india.timeZone = try #require(TimeZone(identifier: "Asia/Kolkata")) + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let end = try #require(formatter.date(from: "2026-07-02T00:00:00Z")) + let now = try #require(formatter.date(from: "2026-07-02T12:00:00Z")) + let entries = [ + Self.entry(day: "2026-07-01", cost: 1, tokens: 10), + Self.entry(day: "2026-07-02", cost: 2, tokens: 20), + ] + let input = SpendDashboardModel.ProviderInput( + provider: .mistral, + displayName: "Mistral", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 30, + last30DaysCostUSD: 3, + currencyCode: "USD", + historyDays: 2, + historyCoverageIsEstablished: true, + daily: entries, + updatedAt: end), + trackedCoverage: .init(start: start, end: end, coveredDayCount: 2)) + + let group = try #require(SpendDashboardModel.build( + inputs: [input], + range: .allTime, + now: now, + calendar: india).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.providers.first?.coveredDayCount == 2) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + #expect(group.dailyPoints.map(\.day) == [ + india.startOfDay(for: start), + india.startOfDay(for: end), + ]) + } + + private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardAllTimeDisablementTests.swift b/Tests/CodexBarTests/SpendDashboardAllTimeDisablementTests.swift new file mode 100644 index 0000000000..06b1a4f57f --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardAllTimeDisablementTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardAllTimeDisablementTests { + @Test(arguments: [false, true]) + func `ledger rows clear immediately when tracking or every provider is disabled`( + disableTracking: Bool) async throws + { + let defaultsSuite = "SpendDashboardAllTimeDisablementTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsSuite)) + defaults.removePersistentDomain(forName: defaultsSuite) + defer { defaults.removePersistentDomain(forName: defaultsSuite) } + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardAllTimeDisablementTests-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let enabled = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:test-owner"]) + let input = Self.input() + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in Self.request(configuration: enabled, force: mode.forcesLoader) }, + loader: { _ in SpendDashboardLoadResult(inputs: [input], failedSourceIDs: []) }, + historyLedger: SpendHistoryLedger(fileURL: fileURL)) + + controller.update(configuration: enabled) + await Self.waitUntil { !controller.isRefreshing } + controller.selectRange(.allTime) + await Self.waitUntil { controller.model.groups.first?.totalCost == 4 } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: !disableTracking, + providerIDs: disableTracking ? enabled.providerIDs : [], + codexAccountIdentities: [], + sourceOwnershipFingerprints: [])) + + #expect(!controller.isRefreshing) + #expect(controller.model.groups.isEmpty) + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: self.fixtureNow, + force: force) + } + + private static func input() -> SpendDashboardModel.ProviderInput { + SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 4, + currencyCode: "USD", + historyDays: 1, + historyCoverageIsEstablished: true, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: 4, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: self.fixtureNow)) + } + + /// Noon UTC stays on July 15 in both CI's UTC zone and the local Pacific zone. + private static let fixtureNow = Date(timeIntervalSince1970: 1_784_116_800) + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<400 { + if condition() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + Issue.record("Timed out waiting for controller state") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift index e801e93ceb..28f5be6a0f 100644 --- a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift @@ -77,10 +77,15 @@ struct SpendDashboardCachedPresentationTests { } @Test - func `retained Codex totals render during refresh and clear only after convergence`() async { + func `retained Codex totals render during refresh and clear only after convergence`() async throws { + let defaultsSuite = "SpendDashboardCachedPresentationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsSuite)) + defaults.removePersistentDomain(forName: defaultsSuite) + defer { defaults.removePersistentDomain(forName: defaultsSuite) } let gate = SpendDashboardCachedLoaderGate() let configuration = Self.configuration(account: "account|cache") let controller = SpendDashboardController( + userDefaults: defaults, requestBuilder: { mode in Self.request(configuration: configuration, force: mode.forcesLoader) }, diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 98018e1821..b9d87b0b63 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -712,47 +712,6 @@ struct SpendDashboardControllerTests { #expect(await gate.pendingCount == 0) } - @Test - func `disablement cancels pending work and clears safely`() async { - let gate = SpendDashboardLoaderGate() - let controller = Self.controller(gate: gate) - controller.update(configuration: Self.configuration(account: "enabled")) - await Self.waitForPendingCount(1, gate: gate) - - controller.update(configuration: SpendDashboardConfiguration( - costUsageEnabled: false, - providerIDs: [UsageProvider.codex.rawValue], - codexAccountIdentities: ["enabled"])) - #expect(!controller.isRefreshing) - #expect(controller.model.groups.isEmpty) - - await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 99)], failedSourceIDs: [])) - await Task.yield() - #expect(controller.model.groups.isEmpty) - } - - @Test - func `range selection persists only supported windows`() throws { - let suite = "SpendDashboardControllerTests-days" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - defer { defaults.removePersistentDomain(forName: suite) } - let controller = SpendDashboardController( - userDefaults: defaults, - requestBuilder: { mode in - Self.request( - configuration: Self.configuration(account: "unused"), - force: mode.forcesLoader) - }) - - #expect(controller.selectedDays == 30) - controller.selectDays(7) - #expect(controller.selectedDays == 7) - #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) - controller.selectDays(9) - #expect(controller.selectedDays == 30) - } - private nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) private static func dashboardController( @@ -849,6 +808,49 @@ struct SpendDashboardControllerTests { snapshot: snapshot) } + private static func activityInput( + updatedAt: Date, + days: [(String, Int)]) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: days.reduce(0) { $0 + $1.1 }, + last30DaysCostUSD: Double(days.count), + currencyCode: "USD", + historyDays: days.count, + historyCoverageIsEstablished: true, + daily: days.map { day, tokens in + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil) + }, + updatedAt: updatedAt)) + } + + private static func activityTokens( + on day: String, + model: SpendDashboardModel, + calendar: Calendar) -> Int? + { + let parts = day.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3, + let date = calendar.date(from: DateComponents( + year: parts[0], + month: parts[1], + day: parts[2])) + else { return nil } + return model.tokenActivity.first { calendar.isDate($0.day, inSameDayAs: date) }?.totalTokens + } + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { for _ in 0..<1000 { if await gate.pendingCount == count { @@ -880,6 +882,125 @@ struct SpendDashboardControllerTests { } } +extension SpendDashboardControllerTests { + @Test + func `disablement cancels pending work and clears safely`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + controller.update(configuration: Self.configuration(account: "enabled")) + await Self.waitForPendingCount(1, gate: gate) + + controller.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["enabled"])) + #expect(!controller.isRefreshing) + #expect(controller.model.groups.isEmpty) + + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 99)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.isEmpty) + } + + @Test + func `range selection persists only supported windows`() throws { + let suite = "SpendDashboardControllerTests-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + Self.request( + configuration: Self.configuration(account: "unused"), + force: mode.forcesLoader) + }) + + #expect(controller.selectedDays == 30) + controller.selectDays(7) + #expect(controller.selectedDays == 7) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) + controller.selectDays(9) + #expect(controller.selectedDays == 30) + + controller.selectRange(.allTime) + #expect(controller.selectedRange == .allTime) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 0) + + let restored = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + Self.request( + configuration: Self.configuration(account: "unused"), + force: mode.forcesLoader) + }) + #expect(restored.selectedRange == .allTime) + } + + @Test + func `all time activity leaves uncaptured ledger gap unknown`() async throws { + let suite = "SpendDashboardControllerTests-activity-gap-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let ledgerURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(suite).json") + defer { try? FileManager.default.removeItem(at: ledgerURL) } + let calendar = Calendar.current + let firstEnd = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 2, + hour: 12))) + let secondEnd = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 1, + day: 6, + hour: 12))) + let results = SpendDashboardResultQueue(results: [ + .init(inputs: [Self.activityInput( + updatedAt: firstEnd, + days: [("2026-01-01", 10), ("2026-01-02", 20)])], failedSourceIDs: []), + .init(inputs: [Self.activityInput( + updatedAt: secondEnd, + days: [("2026-01-05", 50), ("2026-01-06", 60)])], failedSourceIDs: []), + ]) + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:test-owner"]) + let requestCount = SpendDashboardRequestCount() + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + let count = await requestCount.next() + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: count == 1 ? [] : [UsageProvider.claude.rawValue], + codexRequests: [], + now: count == 1 ? firstEnd : secondEnd, + force: mode.forcesLoader) + }, + loader: { _ in await results.next() }, + historyLedger: SpendHistoryLedger(fileURL: ledgerURL)) + + controller.update(configuration: configuration) + await Self.waitUntil { controller.model.groups.first?.coveredDayCount == 2 } + controller.refresh() + controller.selectRange(.allTime) + await Self.waitUntil { controller.model.groups.first?.coveredDayCount == 4 } + + #expect(controller.model.groups.first?.coveredDayCount == 4) + #expect(controller.model.groups.first?.totalCost == 4) + #expect(Self.activityTokens(on: "2026-01-03", model: controller.model, calendar: calendar) == nil) + #expect(Self.activityTokens(on: "2026-01-04", model: controller.model, calendar: calendar) == nil) + #expect(Self.activityTokens(on: "2026-01-05", model: controller.model, calendar: calendar) == 50) + #expect(Self.activityTokens(on: "2026-01-06", model: controller.model, calendar: calendar) == 60) + } +} + @MainActor struct SpendDashboardRequestTimeTests { @Test @@ -1210,6 +1331,27 @@ private actor SpendDashboardCapturedInputStore { } } +private actor SpendDashboardResultQueue { + private var results: [SpendDashboardLoadResult] + + init(results: [SpendDashboardLoadResult]) { + self.results = results + } + + func next() -> SpendDashboardLoadResult { + self.results.removeFirst() + } +} + +private actor SpendDashboardRequestCount { + private var count = 0 + + func next() -> Int { + self.count += 1 + return self.count + } +} + private actor SpendDashboardCodexLoadRecorder { private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 929239016e..3564d38df5 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -871,6 +871,73 @@ struct SpendDashboardModelTests { } extension SpendDashboardModelTests { + @Test + func `all time uses tracked start and exact covered day count`() throws { + let start = try #require(Self.calendar.date(byAdding: .day, value: -44, to: Self.now)) + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 2, + historyDays: 45, + daily: [Self.entry(day: "2026-07-16", cost: 2)], + updatedAt: Self.now), + trackedCoverage: .init(start: start, end: Self.now, coveredDayCount: 31)) + + let model = SpendDashboardModel.build( + inputs: [input], + range: .allTime, + now: Self.now, + calendar: Self.calendar) + let group = try #require(model.groups.first) + + #expect(model.range == .allTime) + #expect(model.requestedDays == 45) + #expect(group.coveredDayCount == 31) + #expect(group.providers.first?.coverageStart == start) + } + + @Test + func `all time group coverage counts only days covered by every provider`() throws { + let start = try #require(Self.calendar.date(byAdding: .day, value: -2, to: Self.now)) + let codex = SpendDashboardModel.ProviderInput( + id: "codex:fixture", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-14", cost: 1), + Self.entry(day: "2026-07-16", cost: 1), + ], + historyDays: 3), + trackedCoverage: .init(start: start, end: Self.now, coveredDayCount: 2)) + let claude = SpendDashboardModel.ProviderInput( + id: "claude:fixture", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-15", cost: 1), + Self.entry(day: "2026-07-16", cost: 1), + ], + historyDays: 3), + trackedCoverage: .init(start: start, end: Self.now, coveredDayCount: 2)) + + let group = try #require(SpendDashboardModel.build( + inputs: [codex, claude], + range: .allTime, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.map(\.coveredDayCount) == [2, 2]) + #expect(group.coveredDayCount == 1) + } + @Test func `partially attributed Codex history retains its priced model rows`() throws { let codex = SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/SpendHistoryLedgerTests.swift b/Tests/CodexBarTests/SpendHistoryLedgerTests.swift new file mode 100644 index 0000000000..87c82a3823 --- /dev/null +++ b/Tests/CodexBarTests/SpendHistoryLedgerTests.swift @@ -0,0 +1,393 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct SpendHistoryLedgerTests { + @Test + func `merges overlapping windows and persists explicit covered zero days`() async throws { + let fileURL = Self.tempFileURL() + let ledger = SpendHistoryLedger(fileURL: fileURL) + + let first = Self.input( + updatedAt: Self.date("2026-08-10"), + historyDays: 3, + aggregate: 5, + daily: [Self.entry("2026-08-08", cost: 2), Self.entry("2026-08-10", cost: 3)]) + let second = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 3, + aggregate: 10, + daily: [ + Self.entry("2026-08-10", cost: 3), + Self.entry("2026-08-11", cost: 4), + Self.entry("2026-08-12", cost: 3), + ]) + + _ = await ledger.record(inputs: [first], ownership: ["codex": "owner-a"]) + let snapshots = await ledger.record(inputs: [second], ownership: ["codex": "owner-a"]) + + let snapshot = try #require(snapshots.first) + #expect(snapshot.coveredDayCount == 5) + #expect(snapshot.hasContinuousCoverage) + #expect(snapshot.input.trackedCoverage?.coveredDayCount == 5) + #expect(snapshot.input.snapshot.historyDays == 5) + #expect(snapshot.input.snapshot.last30DaysCostUSD == 12) + #expect(snapshot.input.snapshot.daily.map(\.date) == [ + "2026-08-08", "2026-08-09", "2026-08-10", "2026-08-11", "2026-08-12", + ]) + #expect(snapshot.input.snapshot.daily[1].costUSD == 0) + #expect(snapshot.input.snapshot.daily[1].inputTokens == 0) + #expect(snapshot.input.snapshot.daily[1].outputTokens == 0) + #expect(snapshot.input.snapshot.daily[1].totalTokens == 0) + #expect(snapshot.input.snapshot.daily[1].requestCount == 0) + + let reloaded = SpendHistoryLedger(fileURL: fileURL) + let persisted = await reloaded.snapshots( + for: [], + ownership: ["codex": "owner-a"]) + #expect(persisted.first?.input.snapshot.last30DaysCostUSD == 12) + } + + @Test + func `isolates history when source ownership changes`() async { + let ledger = SpendHistoryLedger(fileURL: Self.tempFileURL()) + let oldInput = Self.input( + updatedAt: Self.date("2026-08-10"), + historyDays: 1, + aggregate: 7, + daily: [Self.entry("2026-08-10", cost: 7)]) + _ = await ledger.record(inputs: [oldInput], ownership: ["codex": "owner-a"]) + + let currentInput = SpendDashboardModel.ProviderInput( + id: "codex:account", + provider: .codex, + displayName: "Codex", + snapshot: oldInput.snapshot) + let hidden = await ledger.snapshots( + for: [currentInput], + ownership: ["codex:account": "owner-b"]) + #expect(hidden.isEmpty) + + let newSnapshots = await ledger.record( + inputs: [currentInput], + ownership: ["codex:account": "owner-b"]) + #expect(newSnapshots.count == 1) + #expect(newSnapshots.first?.input.snapshot.last30DaysCostUSD == 7) + } + + @Test + func `rejects partial or arithmetically inconsistent windows`() async { + let ledger = SpendHistoryLedger(fileURL: Self.tempFileURL()) + let partial = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 2, + aggregate: 9, + established: false, + daily: [Self.entry("2026-08-12", cost: 9)]) + let inconsistent = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 2, + aggregate: 9, + daily: [Self.entry("2026-08-12", cost: 4)]) + + let partialResult = await ledger.record( + inputs: [partial], + ownership: ["codex": "owner"]) + let inconsistentResult = await ledger.record( + inputs: [inconsistent], + ownership: ["codex": "owner"]) + #expect(partialResult.isEmpty) + #expect(inconsistentResult.isEmpty) + } + + @Test + func `preserves model breakdowns across launches`() async throws { + let fileURL = Self.tempFileURL() + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 2.5, + daily: [Self.entry("2026-08-12", cost: 2.5, model: "gpt-5")]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [input], + ownership: ["codex": "owner"]) + + let snapshots = await SpendHistoryLedger(fileURL: fileURL).snapshots( + for: [input], + ownership: ["codex": "owner"]) + let model = try #require(snapshots.first?.input.snapshot.daily.first?.modelBreakdowns?.first) + #expect(model.modelName == "gpt-5") + #expect(model.costUSD == 2.5) + } + + @Test + func `preserves unknown token counters for cost-only days across launches`() async throws { + let fileURL = Self.tempFileURL() + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 2.5, + daily: [Self.costOnlyEntry("2026-08-12", cost: 2.5, model: "gpt-5")]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [input], + ownership: ["codex": "owner"]) + + let snapshots = await SpendHistoryLedger(fileURL: fileURL).snapshots( + for: [input], + ownership: ["codex": "owner"]) + let snapshot = try #require(snapshots.first?.input.snapshot) + let day = try #require(snapshot.daily.first) + #expect(snapshot.last30DaysTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.cacheReadTokens == nil) + #expect(day.cacheCreationTokens == nil) + #expect(day.outputTokens == nil) + #expect(day.totalTokens == nil) + #expect(day.requestCount == nil) + #expect(day.modelBreakdowns?.first?.totalTokens == nil) + #expect(day.modelBreakdowns?.first?.requestCount == nil) + } + + @Test + func `recovers from a corrupt persisted document`() async throws { + let fileURL = Self.tempFileURL() + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: fileURL, options: [.atomic]) + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-12", cost: 1)]) + + let snapshots = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [input], + ownership: ["codex": "owner"]) + + #expect(snapshots.count == 1) + let data = try Data(contentsOf: fileURL) + #expect(String(bytes: data, encoding: .utf8)?.contains("\"version\" : 1") == true) + } + + @Test + func `failed persistence keeps and returns the prior durable history`() async throws { + struct WriteFailure: Error {} + + let fileURL = Self.tempFileURL() + let first = Self.input( + updatedAt: Self.date("2026-08-11"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-11", cost: 1)]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [first], + ownership: ["codex": "owner"]) + let durableData = try Data(contentsOf: fileURL) + + let second = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 2, + daily: [Self.entry("2026-08-12", cost: 2)]) + let failingLedger = SpendHistoryLedger( + fileURL: fileURL, + fileWriter: { _, _ in throw WriteFailure() }) + let returned = await failingLedger.record( + inputs: [second], + ownership: ["codex": "owner"]) + + #expect(returned.first?.input.snapshot.last30DaysCostUSD == 1) + #expect(returned.first?.input.snapshot.daily.map(\.date) == ["2026-08-11"]) + let retained = await failingLedger.snapshots( + for: [second], + ownership: ["codex": "owner"]) + #expect(retained.first?.input.snapshot.last30DaysCostUSD == 1) + #expect(try Data(contentsOf: fileURL) == durableData) + + let reloaded = await SpendHistoryLedger(fileURL: fileURL).snapshots( + for: [second], + ownership: ["codex": "owner"]) + #expect(reloaded.first?.input.snapshot.last30DaysCostUSD == 1) + } + + @Test + func `derives typed provider and codex ownership scopes`() { + let scopes = SpendHistoryLedger.ownershipScopes( + sourceOwnershipFingerprints: ["claude:config:scope:account"], + codexAccountIdentities: ["account:with:colons|cache-identity"]) + + #expect(scopes["claude"] == "claude:config:scope:account") + #expect(scopes["codex:account:with:colons"] == "account:with:colons|cache-identity") + } + + #if os(macOS) + @Test + func `persists history with owner-only permissions`() async throws { + let fileURL = Self.tempFileURL() + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-12", cost: 1)]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [input], + ownership: ["codex": "owner"]) + + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + } + + @Test + func `permission failure on first save leaves no unsafe ledger`() async { + let fileURL = Self.tempFileURL() + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-12", cost: 1)]) + let ledger = SpendHistoryLedger( + fileURL: fileURL, + permissionEnforcer: { _ in false }) + + let snapshots = await ledger.record( + inputs: [input], + ownership: ["codex": "owner"]) + + #expect(snapshots.isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + func `permission failure on update preserves verified durable bytes`() async throws { + let fileURL = Self.tempFileURL() + let first = Self.input( + updatedAt: Self.date("2026-08-11"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-11", cost: 1)]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [first], + ownership: ["codex": "owner"]) + let durableData = try Data(contentsOf: fileURL) + + let second = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 2, + daily: [Self.entry("2026-08-12", cost: 2)]) + let failingLedger = SpendHistoryLedger( + fileURL: fileURL, + permissionEnforcer: { _ in false }) + let returned = await failingLedger.record( + inputs: [second], + ownership: ["codex": "owner"]) + + #expect(returned.first?.input.snapshot.last30DaysCostUSD == 1) + #expect(try Data(contentsOf: fileURL) == durableData) + let reloaded = await SpendHistoryLedger(fileURL: fileURL).snapshots( + for: [second], + ownership: ["codex": "owner"]) + #expect(reloaded.first?.input.snapshot.last30DaysCostUSD == 1) + } + + @Test + func `secures a preexisting ledger with broadened permissions before loading`() async throws { + let fileURL = Self.tempFileURL() + let input = Self.input( + updatedAt: Self.date("2026-08-12"), + historyDays: 1, + aggregate: 1, + daily: [Self.entry("2026-08-12", cost: 1)]) + _ = await SpendHistoryLedger(fileURL: fileURL).record( + inputs: [input], + ownership: ["codex": "owner"]) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o644))], + ofItemAtPath: fileURL.path) + + let snapshots = await SpendHistoryLedger(fileURL: fileURL).snapshots( + for: [input], + ownership: ["codex": "owner"]) + + #expect(snapshots.first?.input.snapshot.last30DaysCostUSD == 1) + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + } + #endif + + private static func input( + updatedAt: Date, + historyDays: Int, + aggregate: Double, + established: Bool = true, + daily: [CostUsageDailyReport.Entry]) -> SpendDashboardModel.ProviderInput + { + let dailyTokens = daily.map(\.totalTokens) + return SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: "Codex", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: dailyTokens.allSatisfy { $0 != nil } + ? dailyTokens.compactMap(\.self).reduce(0, +) + : nil, + last30DaysCostUSD: aggregate, + historyDays: historyDays, + historyCoverageIsEstablished: established, + daily: daily, + updatedAt: updatedAt)) + } + + private static func entry( + _ day: String, + cost: Double, + model: String? = nil) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: 1, + outputTokens: 2, + totalTokens: 3, + costUSD: cost, + modelsUsed: model.map { [$0] }, + modelBreakdowns: model.map { + [CostUsageDailyReport.ModelBreakdown(modelName: $0, costUSD: cost, totalTokens: 3)] + }) + } + + private static func costOnlyEntry( + _ day: String, + cost: Double, + model: String? = nil) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: cost, + modelsUsed: model.map { [$0] }, + modelBreakdowns: model.map { + [CostUsageDailyReport.ModelBreakdown(modelName: $0, costUSD: cost)] + }) + } + + private static func date(_ day: String) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let parts = day.split(separator: "-").compactMap { Int($0) } + return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))! + } + + private static func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("SpendHistoryLedgerTests-\(UUID().uuidString)") + .appendingPathComponent("spend-history.json") + } +} diff --git a/docs/screenshots/lifetime-spend-proof/README.md b/docs/screenshots/lifetime-spend-proof/README.md new file mode 100644 index 0000000000..cd9b7f2455 --- /dev/null +++ b/docs/screenshots/lifetime-spend-proof/README.md @@ -0,0 +1,35 @@ +# Lifetime spend proof + +These two PNGs are privacy-safe packaged runtime proof for the Usage & Spend **All Time** range. + +- `all-time-dashboard.png` renders the production dashboard header and currency section after a real ledger reload. +- `all-time-share-stats.png` is the production image exported by `ShareStatsRenderer` from that reloaded model. +- `record-manifest.json` records the first packaged process, its 30-day ledger hash, and verified `0600` ledger mode. +- `reload-manifest.json` proves the second packaged process opened that exact ledger hash, retained `0600` mode, and + rendered 60 tracked days. + +All dates, provider labels, model labels, token counts, and estimated costs come from fixed synthetic Codex JSONL. The +proof script packages the debug CodexBar app and runs its binary twice in separate processes. The first process scans a +30-day synthetic window through `CostUsageFetcher` and records it with `SpendHistoryLedger`. The second scans a later +30-day window, reloads the same ledger, selects All Time, and emits the dashboard and share-card PNGs from the production +views. The reload manifest's `ledgerBeforeSHA256` exactly matches the record manifest's `ledgerAfterSHA256`. +Both version 2 manifests include `ledgerPermissions: "0600"`, recorded only after the final ledger path independently +passes the packaged runner's exact-mode check. + +The proof mode resolves before normal app initialization and is compiled out of release builds. It fails closed unless its +root is a private, current-user-owned, non-symlink directory matching `/private/tmp/codexbar-lifetime-proof.*` with a +regular sentinel file. It constructs no `SettingsStore`, `UsageStore`, browser importer, provider network client, or +Keychain service. The packaged processes receive an empty environment except for a minimal tool path, locale, isolated +home/temp directories, and the proof controls. + +Regenerate the complete two-process proof with the full Xcode toolchain: + +```sh +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + ./Scripts/verify_lifetime_spend_runtime_proof.sh +``` + +The script retains its output under a newly created private temporary directory and prints that path. Before reporting +success it requires exactly two PNGs, both version 2 manifests, an independent `0600` mode check after each packaged +process, matching manifest permission assertions, and a manifest privacy scan. The tracked manifests omit the temporary +root and run sentinel UUID; the screenshots show only Codex, `gpt-5.4`/GPT, and aggregate synthetic values. diff --git a/docs/screenshots/lifetime-spend-proof/all-time-dashboard.png b/docs/screenshots/lifetime-spend-proof/all-time-dashboard.png new file mode 100644 index 0000000000..01b5cf2778 Binary files /dev/null and b/docs/screenshots/lifetime-spend-proof/all-time-dashboard.png differ diff --git a/docs/screenshots/lifetime-spend-proof/all-time-share-stats.png b/docs/screenshots/lifetime-spend-proof/all-time-share-stats.png new file mode 100644 index 0000000000..14a5012b78 Binary files /dev/null and b/docs/screenshots/lifetime-spend-proof/all-time-share-stats.png differ diff --git a/docs/screenshots/lifetime-spend-proof/record-manifest.json b/docs/screenshots/lifetime-spend-proof/record-manifest.json new file mode 100644 index 0000000000..d12b554cc3 --- /dev/null +++ b/docs/screenshots/lifetime-spend-proof/record-manifest.json @@ -0,0 +1,18 @@ +{ + "allTimeCoveredDays" : 30, + "allTimeModelNames" : [ + "gpt-5.4" + ], + "allTimeProviderCount" : 1, + "bundleIdentifier" : "com.steipete.codexbar.debug", + "currentWindowDays" : 30, + "emittedPNGs" : [ + + ], + "executableSHA256" : "bdb2c5709bc791c01c954fa92a172da024a204a3974c0b126c1c4e805932b3d0", + "gitCommit" : "63dc8594d", + "ledgerAfterSHA256" : "e3e6595544de21a620a29de409babe498c45c02e3d9ce09f04947a5094dd151b", + "ledgerPermissions" : "0600", + "phase" : "record", + "schemaVersion" : 2 +} \ No newline at end of file diff --git a/docs/screenshots/lifetime-spend-proof/reload-manifest.json b/docs/screenshots/lifetime-spend-proof/reload-manifest.json new file mode 100644 index 0000000000..6b604ad65d --- /dev/null +++ b/docs/screenshots/lifetime-spend-proof/reload-manifest.json @@ -0,0 +1,20 @@ +{ + "allTimeCoveredDays" : 60, + "allTimeModelNames" : [ + "gpt-5.4" + ], + "allTimeProviderCount" : 1, + "bundleIdentifier" : "com.steipete.codexbar.debug", + "currentWindowDays" : 30, + "emittedPNGs" : [ + "all-time-dashboard.png", + "all-time-share-stats.png" + ], + "executableSHA256" : "bdb2c5709bc791c01c954fa92a172da024a204a3974c0b126c1c4e805932b3d0", + "gitCommit" : "63dc8594d", + "ledgerAfterSHA256" : "a136c1e39a84e5846e6bfe6ce4cc1081effac58ffcafa1967bcae98755c11881", + "ledgerBeforeSHA256" : "e3e6595544de21a620a29de409babe498c45c02e3d9ce09f04947a5094dd151b", + "ledgerPermissions" : "0600", + "phase" : "reload", + "schemaVersion" : 2 +} \ No newline at end of file