Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 页面查看所有版本:

Expand Down Expand Up @@ -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。
Expand Down Expand Up @@ -149,15 +149,15 @@ 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
```

签名 + 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
Expand Down
16 changes: 14 additions & 2 deletions TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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"]
Expand Down
66 changes: 49 additions & 17 deletions TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ final class AppState: ObservableObject {

init() {
load()
refreshIfSnapshotIsStale()
applyDefaultAutostartIfNeeded()
configureTimer()
refreshCodexQuota()
refreshIfSnapshotIsStale()
scheduleDeferredUpdateCheck()
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ struct AgentWorkCardRender {
status: "ok",
files: 665,
records: 2_440,
accountingRevision: 6
accountingRevision: UsageCollector.codexAccountingRevision
),
"Hermes Agent": SourceInfo(
status: "ok",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
]
)
}
}
2 changes: 1 addition & 1 deletion script/build_swiftui_and_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions script/package_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading