From 2f50c67044ba7e1a9f1fa671e385e224493be2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Tue, 21 Jul 2026 13:24:30 +0800 Subject: [PATCH] Recalculate stale accounting on launch --- README.md | 8 +- .../TokenStepSwift/Services/DataService.swift | 16 +++- .../TokenStepSwift/Stores/AppState.swift | 66 ++++++++++---- .../Tests/Fixtures/AgentWorkCardRender.swift | 2 +- ...geRecalibrationMigrationFixtureCheck.swift | 43 +++++++-- .../UsageSnapshotRefreshPolicyTests.swift | 90 +++++++++++++++++++ script/build_swiftui_and_run.sh | 2 +- script/package_release.sh | 4 +- 8 files changed, 199 insertions(+), 32 deletions(-) create mode 100644 TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift diff --git a/README.md b/README.md index f27cd0e..4768019 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.44.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.45.dmg) 也可以从 Release 页面查看所有版本: @@ -89,7 +89,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.44.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.45.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -149,7 +149,7 @@ TokenStepSwift/dist/TokenStep.app Developer ID 签名: ```bash -TOKENSTEP_VERSION=0.1.44 \ +TOKENSTEP_VERSION=0.1.45 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ ./script/package_release.sh ``` @@ -157,7 +157,7 @@ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ 签名 + Apple 公证: ```bash -TOKENSTEP_VERSION=0.1.44 \ +TOKENSTEP_VERSION=0.1.45 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift index f54aaef..4775275 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift @@ -4,6 +4,7 @@ import Foundation enum DataService { private static let helperName = "TokenStepHelper" private static let helperTimeoutSeconds: TimeInterval = 120 + private static let legacyCodexAccountingRevision = 5 static func loadSnapshot() throws -> UsageSnapshot { let data = try Data(contentsOf: AppPaths.usageJSON) @@ -19,6 +20,17 @@ enum DataService { return normalize(settings) } + static func requiresImmediateCodexRecalibration(_ snapshot: UsageSnapshot) -> Bool { + guard let codex = snapshot.sources["Codex"], + (codex.records ?? 0) > 0 + else { + return false + } + + let storedRevision = codex.accountingRevision ?? legacyCodexAccountingRevision + return storedRevision < UsageCollector.codexAccountingRevision + } + static func saveSettings(_ settings: TokenStepSettings) throws { let normalized = normalize(settings) let encoder = JSONEncoder() @@ -59,7 +71,7 @@ enum DataService { let previousCodex = previousSnapshot?.sources["Codex"], (previousCodex.records ?? 0) > 0, let currentRevision = snapshot.sources["Codex"]?.accountingRevision { - let previousRevision = previousCodex.accountingRevision ?? 5 + let previousRevision = previousCodex.accountingRevision ?? legacyCodexAccountingRevision if previousRevision < currentRevision { snapshot.sources["Codex"]?.recalibratedFromRevision = previousRevision } @@ -77,7 +89,7 @@ enum DataService { return } - let previousRevision = previousCodex.accountingRevision ?? 5 + let previousRevision = previousCodex.accountingRevision ?? legacyCodexAccountingRevision let requiredRevision = UsageCollector.codexAccountingRevision guard previousRevision < requiredRevision else { return } let currentCodex = collectedSnapshot.sources["Codex"] diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index 2f5e4e5..20c7099 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -29,10 +29,10 @@ final class AppState: ObservableObject { init() { load() + refreshIfSnapshotIsStale() applyDefaultAutostartIfNeeded() configureTimer() refreshCodexQuota() - refreshIfSnapshotIsStale() scheduleDeferredUpdateCheck() } @@ -499,25 +499,23 @@ final class AppState: ObservableObject { } private func refreshIfSnapshotIsStale() { - if snapshot.daily.contains(where: { $0.totalTokens > 0 && $0.models.isEmpty }) { - refresh() - return - } - guard settings.refreshIntervalSeconds > 0 else { - if snapshot.generatedAt == nil { - refresh() - } + guard let reason = UsageSnapshotRefreshPolicy.reason( + snapshot: snapshot, + refreshIntervalSeconds: settings.refreshIntervalSeconds, + now: Date() + ) else { return } - guard let generatedAt = snapshot.generatedAt, - let generatedDate = Self.parseGeneratedAt(generatedAt) - else { - refresh() - return - } - if Date().timeIntervalSince(generatedDate) >= TimeInterval(settings.refreshIntervalSeconds) { - refresh() + + if reason == .accountingRevision { + let storedRevision = snapshot.sources["Codex"]?.accountingRevision + .map(String.init) ?? "legacy" + LifecycleLogger.log( + "Codex accounting revision \(storedRevision) is older than " + + "\(UsageCollector.codexAccountingRevision); starting immediate recalibration." + ) } + refresh() } private func scheduleDeferredUpdateCheck() { @@ -575,6 +573,40 @@ final class AppState: ObservableObject { ) try Data("applied\n".utf8).write(to: AppPaths.autostartDefaultMarker, options: .atomic) } +} + +enum UsageSnapshotRefreshReason: Equatable { + case accountingRevision + case missingModelBreakdown + case missingSnapshotTimestamp + case stale +} + +enum UsageSnapshotRefreshPolicy { + static func reason( + snapshot: UsageSnapshot, + refreshIntervalSeconds: Int, + now: Date + ) -> UsageSnapshotRefreshReason? { + if DataService.requiresImmediateCodexRecalibration(snapshot) { + return .accountingRevision + } + if snapshot.daily.contains(where: { $0.totalTokens > 0 && $0.models.isEmpty }) { + return .missingModelBreakdown + } + guard refreshIntervalSeconds > 0 else { + return snapshot.generatedAt == nil ? .missingSnapshotTimestamp : nil + } + guard let generatedAt = snapshot.generatedAt, + let generatedDate = parseGeneratedAt(generatedAt) + else { + return .missingSnapshotTimestamp + } + if now.timeIntervalSince(generatedDate) >= TimeInterval(refreshIntervalSeconds) { + return .stale + } + return nil + } private static func parseGeneratedAt(_ value: String) -> Date? { if let date = generatedAtISOWithFractional.date(from: value) { diff --git a/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift index edb6e61..52080d9 100644 --- a/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift +++ b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift @@ -136,7 +136,7 @@ struct AgentWorkCardRender { status: "ok", files: 665, records: 2_440, - accountingRevision: 6 + accountingRevision: UsageCollector.codexAccountingRevision ), "Hermes Agent": SourceInfo( status: "ok", diff --git a/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift index da335f0..4a59d2f 100644 --- a/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift @@ -7,8 +7,40 @@ struct UsageRecalibrationMigrationFixtureCheck { try? FileManager.default.removeItem(at: root) defer { try? FileManager.default.removeItem(at: root) } + let currentRevision = UsageCollector.codexAccountingRevision let legacy = snapshot(accountingRevision: nil, records: 1) - let current = snapshot(accountingRevision: 8, records: 2) + let previous = snapshot(accountingRevision: currentRevision - 1, records: 1) + let current = snapshot(accountingRevision: currentRevision, records: 2) + let future = snapshot(accountingRevision: currentRevision + 1, records: 1) + let emptyLegacy = snapshot(accountingRevision: nil, records: 0) + var missingCodex = current + missingCodex.sources.removeValue(forKey: "Codex") + + try expect( + DataService.requiresImmediateCodexRecalibration(legacy), + "legacy snapshots with Codex records should recalibrate immediately" + ) + try expect( + DataService.requiresImmediateCodexRecalibration(previous), + "older accounting revisions should recalibrate immediately" + ) + try expect( + !DataService.requiresImmediateCodexRecalibration(current), + "the current accounting revision should not recalibrate again" + ) + try expect( + !DataService.requiresImmediateCodexRecalibration(future), + "a future accounting revision should not be downgraded" + ) + try expect( + !DataService.requiresImmediateCodexRecalibration(emptyLegacy), + "an empty legacy source should not trigger a recalibration loop" + ) + try expect( + !DataService.requiresImmediateCodexRecalibration(missingCodex), + "a snapshot without Codex usage should not require Codex recalibration" + ) + let migrated = try DataService.persistSnapshotForMigrationTests( current, previousSnapshot: legacy @@ -22,8 +54,9 @@ struct UsageRecalibrationMigrationFixtureCheck { "successful legacy migration should create the pending notice marker" ) try expect( - try String(contentsOf: AppPaths.usageRecalibrationNoticeMarker, encoding: .utf8) == "8", - "pending marker should identify accounting revision 8" + try String(contentsOf: AppPaths.usageRecalibrationNoticeMarker, encoding: .utf8) + == "\(currentRevision)", + "pending marker should identify the current accounting revision" ) DataService.acknowledgeUsageRecalibrationNotice() @@ -39,7 +72,7 @@ struct UsageRecalibrationMigrationFixtureCheck { "a new installation must not see a migration notice" ) - let alreadyCurrent = snapshot(accountingRevision: 8, records: 1) + let alreadyCurrent = snapshot(accountingRevision: currentRevision, records: 1) _ = try DataService.persistSnapshotForMigrationTests(current, previousSnapshot: alreadyCurrent) try expect( !DataService.hasPendingUsageRecalibrationNotice, @@ -48,7 +81,7 @@ struct UsageRecalibrationMigrationFixtureCheck { try? FileManager.default.removeItem(at: root) _ = try DataService.persistSnapshotForMigrationTests(legacy, previousSnapshot: nil) - let failedRecalibration = snapshot(accountingRevision: 8, records: 0) + let failedRecalibration = snapshot(accountingRevision: currentRevision, records: 0) do { _ = try DataService.persistSnapshotForMigrationTests( failedRecalibration, diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift new file mode 100644 index 0000000..218794a --- /dev/null +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import TokenStepSwift + +final class UsageSnapshotRefreshPolicyTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_784_610_000) + + func testOlderAccountingRevisionRefreshesEvenWhenAutomaticRefreshIsDisabled() { + let snapshot = makeSnapshot( + accountingRevision: UsageCollector.codexAccountingRevision - 1, + records: 1 + ) + + XCTAssertEqual( + UsageSnapshotRefreshPolicy.reason( + snapshot: snapshot, + refreshIntervalSeconds: 0, + now: now + ), + .accountingRevision + ) + } + + func testLegacySnapshotWithoutRevisionRefreshesImmediately() { + let snapshot = makeSnapshot(accountingRevision: nil, records: 1) + + XCTAssertEqual( + UsageSnapshotRefreshPolicy.reason( + snapshot: snapshot, + refreshIntervalSeconds: 300, + now: now + ), + .accountingRevision + ) + } + + func testCurrentFreshSnapshotDoesNotRefresh() { + let snapshot = makeSnapshot( + accountingRevision: UsageCollector.codexAccountingRevision, + records: 1 + ) + + XCTAssertNil( + UsageSnapshotRefreshPolicy.reason( + snapshot: snapshot, + refreshIntervalSeconds: 300, + now: now + ) + ) + } + + func testEmptyLegacySnapshotDoesNotCreateARefreshLoop() { + let snapshot = makeSnapshot(accountingRevision: nil, records: 0) + + XCTAssertNil( + UsageSnapshotRefreshPolicy.reason( + snapshot: snapshot, + refreshIntervalSeconds: 0, + now: now + ) + ) + } + + private func makeSnapshot(accountingRevision: Int?, records: Int) -> UsageSnapshot { + UsageSnapshot( + generatedAt: ISO8601DateFormatter().string(from: now), + timezone: "Asia/Shanghai", + totals: UsageTotals(tokens: 100, cost: 0, activeDays: 1), + daily: [ + DailyUsage( + date: "2026-07-21", + tools: ["Codex": 100], + models: ["gpt-5.6-sol": 100], + totalTokens: 100, + cost: 0 + ) + ], + tools: [], + models: [], + sources: [ + "Codex": SourceInfo( + status: "ok", + files: records > 0 ? 1 : 0, + records: records, + accountingRevision: accountingRevision + ) + ] + ) + } +} diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index 77527a7..47eda99 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -21,7 +21,7 @@ RESOURCES="$CONTENTS/Resources" EXECUTABLE="$BUILD_DIR/$PRODUCT_NAME" HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" -VERSION="${TOKENSTEP_VERSION:-0.1.44}" +VERSION="${TOKENSTEP_VERSION:-0.1.45}" LAUNCH=true VERIFY=false diff --git a/script/package_release.sh b/script/package_release.sh index ec22d9c..afb2523 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -8,14 +8,14 @@ SWIFT_DIR="$ROOT_DIR/TokenStepSwift" BUILT_APP_BUNDLE="$SWIFT_DIR/dist/$APP_NAME.app" APP_BUNDLE="$BUILT_APP_BUNDLE" RELEASE_DIR="$ROOT_DIR/release" -VERSION="${TOKENSTEP_VERSION:-0.1.44}" +VERSION="${TOKENSTEP_VERSION:-0.1.45}" IDENTITY="${CODE_SIGN_IDENTITY:-}" NOTARIZE=false usage() { cat <<'USAGE' Usage: - TOKENSTEP_VERSION=0.1.44 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] + TOKENSTEP_VERSION=0.1.45 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] Notarization credentials, choose one: TOKENSTEP_NOTARY_PROFILE="notarytool-profile"