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
12 changes: 6 additions & 6 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.47.dmg)
[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.48.dmg)

也可以从 Release 页面查看所有版本:

Expand Down Expand Up @@ -55,7 +55,7 @@ TokenStep 适合这些人:
- 按客户端、按模型查看用量统计。
- 粗略估算 Token 消耗金额。
- 每日目标可设置,默认每天一个亿。
- 自动刷新,默认 5 分钟,兼顾及时性和常驻内存/电量占用
- 打开面板时按设置的新鲜度刷新;后台在接电时最低 15 分钟、电池或低电量模式下最低 30 分钟刷新,并跳过未变化的数据
- 开机启动,可在设置里关闭。
- 多种主题色,菜单栏、圆环、活动墙和按钮会一起变化。
- 一键截图分享当前页面。
Expand All @@ -66,7 +66,7 @@ TokenStep 适合这些人:

## 当前支持

- Codex:优先读取 Codex 本地 SQLite token 汇总,必要时回退 JSONL
- Codex:读取本地 JSONL 用量元数据并维护逐会话增量缓存;缓存异常时自动重建,必要时回退 Codex 本地 SQLite 汇总
- Claude Code:读取 `~/.claude/projects/**/*.jsonl` 里的 usage 元数据。
- CC Switch:实验支持,读取本机 `proxy_request_logs` 中成功且 token 数大于 0 的请求行。
- 额度显示:Codex 读取本机 Codex 账户限额;Claude Code 会在本机读取 Claude Code 钥匙串凭证,并请求 Anthropic usage 接口获取 5 小时 / 7 天剩余额度。
Expand All @@ -89,7 +89,7 @@ TokenStep 默认只做本地统计。

## 安装方式

1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.47.dmg)。
1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.1.48.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.47 \
TOKENSTEP_VERSION=0.1.48 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
./script/package_release.sh
```

签名 + Apple 公证:

```bash
TOKENSTEP_VERSION=0.1.47 \
TOKENSTEP_VERSION=0.1.48 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \
./script/package_release.sh --notarize
Expand Down
6 changes: 5 additions & 1 deletion TokenStepSwift/Sources/TokenStepHelper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ enum TokenStepHelper {
switch command {
case "collect":
let historyDays = arguments.first.flatMap(Int.init) ?? DataService.loadSettings().historyDays
try DataService.runCollector(historyDays: historyDays)
let outcome = try DataService.runCollector(
historyDays: historyDays,
force: arguments.contains("--force")
)
FileHandle.standardOutput.write(Data("\(outcome.rawValue)\n".utf8))
case "install":
try UpdateInstaller(arguments: arguments).run()
default:
Expand Down
116 changes: 109 additions & 7 deletions TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,33 @@ enum DataService {
try data.write(to: AppPaths.settingsJSON, options: .atomic)
}

static func runCollector(historyDays: Int = TokenStepSettings.defaults.historyDays) throws {
@discardableResult
static func runCollector(
historyDays: Int = TokenStepSettings.defaults.historyDays,
force: Bool = false
) throws -> CollectionRunOutcome {
defer { MemoryPressure.relieveAllocatorPressure() }
let settings = loadSettings()
let previousSnapshot = try? loadSnapshot()
let collectedSnapshot = UsageCollector.collect(
let beforeState = UsageCollector.collectionState(
historyDays: historyDays,
includeExperimentalAgentSources: settings.showExperimentalAgentSources
)
let existingCheckpoint = loadCollectionCheckpoint()
if CollectionCheckpointPolicy.shouldSkipCollection(
force: force,
hasSnapshot: previousSnapshot != nil,
checkpoint: existingCheckpoint,
state: beforeState,
now: Date()
) {
return .unchanged
}
let collectedSnapshot = UsageCollector.collect(
historyDays: historyDays,
includeExperimentalAgentSources: settings.showExperimentalAgentSources,
forceFullValidation: force || existingCheckpoint?.isFresh(at: Date()) != true
)
try validateRecalibrationCandidate(
collectedSnapshot,
previousSnapshot: previousSnapshot
Expand All @@ -60,6 +79,23 @@ enum DataService {
previousSnapshot: previousSnapshot
)
try persist(snapshot: snapshot)
let afterState = UsageCollector.collectionState(
historyDays: historyDays,
includeExperimentalAgentSources: settings.showExperimentalAgentSources
)
let sourceStateWasStable = CollectionCheckpointPolicy.shouldPersist(
beforeCollection: beforeState,
afterCollection: afterState
)
if sourceStateWasStable {
saveCollectionCheckpoint(
CollectionCheckpoint(
verifiedAt: Date(),
state: afterState
)
)
}
return sourceStateWasStable ? .updated : .updatedWhileSourcesChanged
}

private static func snapshotWithMigrationMetadata(
Expand Down Expand Up @@ -148,20 +184,45 @@ enum DataService {
FileManager.default.fileExists(atPath: AppPaths.usageRecalibrationNoticeMarker.path)
}

private static func loadCollectionCheckpoint() -> CollectionCheckpoint? {
guard let data = try? Data(contentsOf: AppPaths.collectionCheckpointJSON) else {
return nil
}
return try? JSONDecoder().decode(CollectionCheckpoint.self, from: data)
}

private static func saveCollectionCheckpoint(_ checkpoint: CollectionCheckpoint) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(checkpoint)
try FileManager.default.createDirectory(
at: AppPaths.collectionCheckpointJSON.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: AppPaths.collectionCheckpointJSON, options: .atomic)
} catch {
// A missing checkpoint only costs another safe collection.
}
}

static func acknowledgeUsageRecalibrationNotice() {
try? FileManager.default.removeItem(at: AppPaths.usageRecalibrationNoticeMarker)
}

static func runCollectorInHelper(historyDays: Int = TokenStepSettings.defaults.historyDays) throws {
static func runCollectorInHelper(
historyDays: Int = TokenStepSettings.defaults.historyDays,
force: Bool = false
) throws -> CollectionRunOutcome {
guard let helperURL = bundledHelperURL() else {
try runCollector(historyDays: historyDays)
return
return try runCollector(historyDays: historyDays, force: force)
}

let process = Process()
process.executableURL = helperURL
process.arguments = ["collect", "\(historyDays)"]
process.standardOutput = Pipe()
process.arguments = ["collect", "\(historyDays)"] + (force ? ["--force"] : [])
let standardOutput = Pipe()
process.standardOutput = standardOutput
let standardError = Pipe()
process.standardError = standardError

Expand Down Expand Up @@ -197,6 +258,10 @@ enum DataService {
userInfo: [NSLocalizedDescriptionKey: message?.isEmpty == false ? message! : "Token collector failed."]
)
}
let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return CollectionRunOutcome(rawValue: output ?? "") ?? .updated
}

static func bundledHelperURL() -> URL? {
Expand Down Expand Up @@ -238,3 +303,40 @@ enum DataService {
)
}
}

enum CollectionRunOutcome: String {
case updated
case unchanged
case updatedWhileSourcesChanged = "updated_source_changed"
}

struct CollectionCheckpoint: Codable, Equatable {
static let validationTTL: TimeInterval = 24 * 60 * 60

var verifiedAt: Date
var state: UsageCollectionState

func isFresh(at now: Date = Date()) -> Bool {
now.timeIntervalSince(verifiedAt) < Self.validationTTL
}
}

enum CollectionCheckpointPolicy {
static func shouldSkipCollection(
force: Bool,
hasSnapshot: Bool,
checkpoint: CollectionCheckpoint?,
state: UsageCollectionState,
now: Date
) -> Bool {
guard !force, hasSnapshot, let checkpoint else { return false }
return checkpoint.isFresh(at: now) && checkpoint.state == state
}

static func shouldPersist(
beforeCollection: UsageCollectionState,
afterCollection: UsageCollectionState
) -> Bool {
beforeCollection == afterCollection
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ enum AgentWorkRankService {
static let defaultClient = "all"
static let defaultRange = "today"
static let defaultUsageMode = "all"
static let cacheTTL: TimeInterval = 5 * 60
static let cacheTTL: TimeInterval = 30 * 60
static let leaderboardPageURL = URL(string: "https://www.zhenganhuo.com/token-rank")!
static let myPageURL = URL(string: "https://www.zhenganhuo.com/token-rank/me")!

Expand Down
Loading
Loading