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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

TokenStep 各版本的更新内容,新版本在前。每个版本的完整说明在 [docs/RELEASE_NOTES_<版本号>.md](docs/)。

## 0.2.20 更新:额度到点重置,面板信息保持最新

额度窗口过了重置时间显示「已重置」并自动重新读取;面板一直开着时,倒计时、时间进度竖线和提前用完的提示也会每分钟更新;修复刘海卡片关闭菜单后意外收起、鼠标指针停在手形的问题。详见 [0.2.20 发布说明](docs/RELEASE_NOTES_0.2.20.md)。

## 0.2.19 更新:额度与排名更精简

额度条粗细统一,Cursor 模型额度也有时间进度竖线;排名只在前 10 名时显示前三名,改为显示离你最近的后一名;大数字的单位更紧凑,页脚更简洁。详见 [0.2.19 发布说明](docs/RELEASE_NOTES_0.2.19.md)。
Expand Down
10 changes: 5 additions & 5 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.2.19.dmg)
[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.20.dmg)

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

Expand All @@ -32,9 +32,9 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时

Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases

## 最新版本:0.2.19
## 最新版本:0.2.20

面板的额度和排名更精简:额度条粗细统一,排名只在前 10 名时显示前三名,并显示离你最近的后一名。0.2.16 起菜单栏面板改为一列紧凑面板,额度刷新更及时。详见 [0.2.19 发布说明](docs/RELEASE_NOTES_0.2.19.md)。
额度窗口过了重置时间显示「已重置」并自动重新读取;面板一直开着时,倒计时、时间进度竖线和提前用完的提示也会每分钟更新;修复刘海卡片关闭菜单后意外收起、鼠标指针停在手形的问题。详见 [0.2.20 发布说明](docs/RELEASE_NOTES_0.2.20.md)。

历次更新(主题包、引力动效、自动更新等)见 [CHANGELOG.md](CHANGELOG.md)。

Expand Down Expand Up @@ -95,7 +95,7 @@ TokenStep 默认只做本地统计。

## 安装方式

1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.19.dmg)。
1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.20.dmg)。
2. 打开 DMG。
3. 把 `TokenStep.app` 拖到「应用程序」。
4. 启动 TokenStep。
Expand Down Expand Up @@ -166,7 +166,7 @@ TokenStepSwift/dist/TokenStep.app
公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包:

```bash
TOKENSTEP_VERSION=0.2.19 \
TOKENSTEP_VERSION=0.2.20 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \
./script/package_release.sh --notarize
Expand Down
23 changes: 23 additions & 0 deletions TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class AppState: ObservableObject {
private var pendingRefreshAfterCurrent = false
private var pendingForcedRefresh = false
private var lastQuotaRefreshAttemptAt: Date?
private var quotaResetTimer: Timer?
private var lastCursorUsageRefreshAttemptAt: Date?
private var lastRankRefreshAttemptAt: Date?
private var lastAutomaticUsageRefreshAttemptAt: Date?
Expand Down Expand Up @@ -325,6 +326,28 @@ final class AppState: ObservableObject {
}
quotas = quotas.filter { providers.contains($0.key) }
isRefreshingCodexQuota = false
scheduleQuotaResetRefresh()
}
}

/// Checks quota again right after the soonest window resets, so a reset
/// window does not wait out the regular interval showing no reading.
private func scheduleQuotaResetRefresh(now: Date = Date()) {
quotaResetTimer?.invalidate()
quotaResetTimer = nil
let nextReset = quotas.values
.filter(\.isAvailable)
.flatMap(\.windows)
.compactMap(\.resetsAt)
.filter { $0 > now }
.min()
guard let nextReset else { return }
// A short grace period lets the provider roll the window over first.
let delay = nextReset.timeIntervalSince(now) + 30
quotaResetTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.refreshCodexQuota(force: true, bypassCache: true)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,8 @@ enum TokenStepLocalization {
"榜单 %@": "Board %@",
"照此速度会提前用完": "Runs out early at this pace",
"%@额度会提前用完": "%@ runs out early",
"已重置": "Reset",
"已过重置时间,等待下一次读取额度": "Past its reset time; waiting for the next quota check",
"打开我的 Token Rank 主页": "Open my Token Rank page",
"在浏览器打开 Token Rank 榜单": "Open the Token Rank board in the browser",
"已用 %@": "%@ used",
Expand Down Expand Up @@ -1208,6 +1210,8 @@ enum TokenStepLocalization {
"榜单 %@": "榜單 %@",
"照此速度会提前用完": "照此速度會提前用完",
"%@额度会提前用完": "%@額度會提前用完",
"已重置": "已重置",
"已过重置时间,等待下一次读取额度": "已過重置時間,等待下一次讀取額度",
"打开我的 Token Rank 主页": "打開我的 Token Rank 主頁",
"在浏览器打开 Token Rank 榜单": "在瀏覽器打開 Token Rank 榜單",
"已用 %@": "已用 %@",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ final class TokenIslandWindowPresenter {

private var isMouseInsidePopover: Bool {
guard let panel = popoverPanel else { return false }
let card = panel.frame.insetBy(dx: TokenIslandMetrics.expandedShadowMargin, dy: TokenIslandMetrics.expandedShadowMargin)
// The window has shadow margin on the sides and bottom only.
let margin = TokenIslandMetrics.expandedShadowMargin
let frame = panel.frame
let card = NSRect(
x: frame.minX + margin,
y: frame.minY + margin,
width: frame.width - margin * 2,
height: frame.height - margin
)
let mouse = NSEvent.mouseLocation
return card.contains(mouse) || (ringPanel?.frame.contains(mouse) ?? false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ enum CompactPopoverModel {
var elapsedFraction: Double?
/// At the current pace the window runs out before it resets.
var runsOutEarly: Bool
/// The reset time has passed since the reading, so its percentage is
/// stale until the next quota check.
var isReset = false
}

struct QuotaProviderRow: Equatable, Identifiable {
Expand Down Expand Up @@ -116,6 +119,18 @@ enum CompactPopoverModel {
}

static func windowRow(_ window: QuotaWindow, now: Date, calendar: Calendar) -> QuotaWindowRow {
if let resetsAt = window.resetsAt, resetsAt <= now {
return QuotaWindowRow(
kind: window.kind,
title: window.title,
usedPercent: 0,
level: .normal,
resetsAt: resetsAt,
elapsedFraction: nil,
runsOutEarly: false,
isReset: true
)
}
let used = min(max(100 - window.remainingPercent, 0), 100)
let elapsed = elapsedFraction(kind: window.kind, resetsAt: window.resetsAt, now: now, calendar: calendar)
let runsOutEarly: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ struct QuotaProviderIcon: View {
}
}

/// Shows the pointing hand over a clickable area. It sets the cursor rather
/// than pushing it, so a popover that closes under the mouse (and never sends
/// the hover end) cannot leave an unbalanced cursor stack behind.
private struct LinkCursor: ViewModifier {
var isEnabled: Bool

func body(content: Content) -> some View {
content.onContinuousHover { phase in
guard isEnabled else { return }
switch phase {
case .active:
NSCursor.pointingHand.set()
case .ended:
NSCursor.arrow.set()
}
}
}
}

// MARK: - Shared styling

enum CompactPopoverStyle {
Expand Down Expand Up @@ -300,6 +319,9 @@ struct CompactQuotaSection: View {

/// Explains the bar's tick: how much of the window's time has passed.
private func paceHelp(_ window: CompactPopoverModel.QuotaWindowRow) -> String {
if window.isReset {
return L("已过重置时间,等待下一次读取额度")
}
guard let elapsed = window.elapsedFraction else {
return LFormat("已用 %@", CompactPopoverStyle.percent(window.usedPercent))
}
Expand Down Expand Up @@ -342,12 +364,12 @@ struct CompactQuotaSection: View {
color: CompactPopoverStyle.color(for: window.level),
marker: window.elapsedFraction
)
Text(CompactPopoverStyle.percent(window.usedPercent))
Text(window.isReset ? "—" : CompactPopoverStyle.percent(window.usedPercent))
.font(.system(size: 12, weight: .bold))
.monospacedDigit()
.foregroundStyle(CompactPopoverStyle.textColor(for: window.level))
.frame(width: 38, alignment: .trailing)
Text(CompactPopoverStyle.resetText(window.resetsAt) ?? "")
Text(window.isReset ? L("已重置") : CompactPopoverStyle.resetText(window.resetsAt) ?? "")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
Expand Down Expand Up @@ -454,10 +476,7 @@ struct CompactRankSection: View {
}
.contentShape(Rectangle())
.onTapGesture { openMyPage?() }
.onHover { hovering in
guard openMyPage != nil else { return }
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
}
.modifier(LinkCursor(isEnabled: openMyPage != nil))
.help(openMyPage == nil ? "" : L("打开我的 Token Rank 主页"))
ForEach(section.podium) { entry in
HStack(spacing: 8) {
Expand Down Expand Up @@ -513,9 +532,7 @@ struct CompactRankSection: View {
if let openBoard {
Button(action: openBoard) { label.contentShape(Rectangle()) }
.buttonStyle(.plain)
.onHover { hovering in
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
}
.modifier(LinkCursor(isEnabled: true))
.help(L("在浏览器打开 Token Rank 榜单"))
} else {
label
Expand Down
55 changes: 32 additions & 23 deletions TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,14 @@ struct PopoverPanelView: View {
@State private var interstellarManualPulseTrigger = 0

var body: some View {
VStack(alignment: .leading, spacing: 0) {
let quotaSection = CompactPopoverModel.quotaSection(quotas: appState.visibleQuotas, now: Date())
if !quotaSection.isEmpty {
section {
CompactQuotaSection(section: quotaSection, updatedAt: quotaUpdatedAt)
}
sectionDivider
}
section {
CompactTokenSection(usage: appState.today, lap: appState.todayLap)
}
if let rank = rankSection, let board = appState.tokenRank {
sectionDivider
section {
CompactRankSection(
section: rank,
fetchedAt: board.fetchedAt,
openBoard: { appState.openTokenRankLeaderboardPage() },
openMyPage: { appState.openTokenRankUserPage() }
)
}
// Reset countdowns, "N minutes ago", pace markers and the run-out
// warning depend on the clock, so redraw every minute while open.
TimelineView(.everyMinute) { context in
VStack(alignment: .leading, spacing: 0) {
content(now: context.date)
notices
footer
}
notices
footer
}
.frame(width: CompactPopoverStyle.width)
.background {
Expand Down Expand Up @@ -78,6 +62,31 @@ struct PopoverPanelView: View {
}
}

@ViewBuilder
private func content(now: Date) -> some View {
let quotaSection = CompactPopoverModel.quotaSection(quotas: appState.visibleQuotas, now: now)
if !quotaSection.isEmpty {
section {
CompactQuotaSection(section: quotaSection, updatedAt: quotaUpdatedAt)
}
sectionDivider
}
section {
CompactTokenSection(usage: appState.today, lap: appState.todayLap)
}
if let rank = rankSection, let board = appState.tokenRank {
sectionDivider
section {
CompactRankSection(
section: rank,
fetchedAt: board.fetchedAt,
openBoard: { appState.openTokenRankLeaderboardPage() },
openMyPage: { appState.openTokenRankUserPage() }
)
}
}
}

/// Cinematic themes put each section on a translucent panel so text stays
/// readable over the artwork; the classic theme draws sections directly.
@ViewBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ struct CompactPopoverFixtureCheck {
let cursor = row(.cursorModels, used: 50, resetsIn: hours(24)).elapsedFraction ?? 0
try expect(cursor > 0.9 && cursor < 1, "cursor model window follows the monthly billing cycle: \(cursor)")
try expect(row(.fiveHour, used: 50, resetsIn: nil).elapsedFraction == nil, "no reset time has no marker")
let pastReset = row(.fiveHour, used: 71, resetsIn: -hours(1))
try expect(pastReset.isReset && pastReset.usedPercent == 0 && !pastReset.runsOutEarly && pastReset.elapsedFraction == nil,
"a window past its reset drops the stale percentage")
// Resets 2026-10-01 00:00 Shanghai; the window began 2026-09-01 00:00.
let reset = ISO8601DateFormatter().date(from: "2026-09-30T16:00:00Z")!
let monthly = CompactPopoverModel.windowRow(
Expand Down
8 changes: 8 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ Before the release commit, update the release documents (the Release workflow ch
- Add the version's summary at the top of `CHANGELOG.md`.
- Update the "最新版本" section and the DMG download links in `README.md` to `TokenStep-<version>.dmg`.

`script/prepare_release.sh` does all of this except writing the notes. The first run creates a notes template and stops. Fill it in, then run it again:

```bash
./script/prepare_release.sh 0.2.20 "<CHANGELOG title>" "<one-paragraph summary>"
```

It also moves the default build version and runs the release contract check. It does not commit.

1. Merge the release commit to `main` and wait for CI.
2. Run the repository's `Release` workflow from `main` with the exact version.
3. The workflow creates a draft and uploads the notarized DMG, ZIP, and checksum file.
Expand Down
10 changes: 10 additions & 0 deletions docs/RELEASE_NOTES_0.2.20.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# TokenStep 0.2.20

面板里和时间有关的信息保持最新,修复几处细节问题。

- **额度到点重置**:额度窗口过了重置时间,改为显示「已重置」,不再显示旧的百分比;TokenStep 会在最近一次重置后约 30 秒自动重新读取额度,不用等到下一轮刷新。
- **面板开着也会走时**:「17 分钟后」「1 分钟前」、额度条上的时间进度竖线和「会提前用完」的判断,面板一直开着时也会每分钟更新。
- **刘海卡片**:在「…」菜单里选完操作后,如果鼠标停在卡片顶部,卡片不再意外收起。
- **鼠标指针**:从榜单链接上关掉面板后,鼠标指针不再停留在手形。

Token、金额与额度的统计口径不变。
2 changes: 1 addition & 1 deletion script/build_swiftui_and_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME"
ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns"
ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey"
INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar"
VERSION="${TOKENSTEP_VERSION:-0.2.19}"
VERSION="${TOKENSTEP_VERSION:-0.2.20}"
LAUNCH=true
VERIFY=false

Expand Down
Loading
Loading