diff --git a/CHANGELOG.md b/CHANGELOG.md index 64230ef..80eed20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)。 diff --git a/README.md b/README.md index bc4df85..de0897b 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.2.19.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.20.dmg) 也可以从 Release 页面查看所有版本: @@ -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)。 @@ -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。 @@ -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 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index d16c520..0b1bbd6 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -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? @@ -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) + } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 6060dbf..1cca0da 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -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", @@ -1208,6 +1210,8 @@ enum TokenStepLocalization { "榜单 %@": "榜單 %@", "照此速度会提前用完": "照此速度會提前用完", "%@额度会提前用完": "%@額度會提前用完", + "已重置": "已重置", + "已过重置时间,等待下一次读取额度": "已過重置時間,等待下一次讀取額度", "打开我的 Token Rank 主页": "打開我的 Token Rank 主頁", "在浏览器打开 Token Rank 榜单": "在瀏覽器打開 Token Rank 榜單", "已用 %@": "已用 %@", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift index 0cef5f1..b547655 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift @@ -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) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift index 28a50d9..d7cf807 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift @@ -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 { @@ -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 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift index 9bf2d1a..8561d54 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift @@ -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 { @@ -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)) } @@ -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) @@ -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) { @@ -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 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index fd9980c..2c8a183 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -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 { @@ -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 diff --git a/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift index 7d032e0..adbc9f7 100644 --- a/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift @@ -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( diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5051da2..6a841ea 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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-.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 "" "" +``` + +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. diff --git a/docs/RELEASE_NOTES_0.2.20.md b/docs/RELEASE_NOTES_0.2.20.md new file mode 100644 index 0000000..2b854bf --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.20.md @@ -0,0 +1,10 @@ +# TokenStep 0.2.20 + +面板里和时间有关的信息保持最新,修复几处细节问题。 + +- **额度到点重置**:额度窗口过了重置时间,改为显示「已重置」,不再显示旧的百分比;TokenStep 会在最近一次重置后约 30 秒自动重新读取额度,不用等到下一轮刷新。 +- **面板开着也会走时**:「17 分钟后」「1 分钟前」、额度条上的时间进度竖线和「会提前用完」的判断,面板一直开着时也会每分钟更新。 +- **刘海卡片**:在「…」菜单里选完操作后,如果鼠标停在卡片顶部,卡片不再意外收起。 +- **鼠标指针**:从榜单链接上关掉面板后,鼠标指针不再停留在手形。 + +Token、金额与额度的统计口径不变。 diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index ac50204..b69323d 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -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 diff --git a/script/prepare_release.sh b/script/prepare_release.sh new file mode 100755 index 0000000..a1d25dc --- /dev/null +++ b/script/prepare_release.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepares the release documents for a new version, the edits +# docs/RELEASE.md lists under "Publish to GitHub": +# +# - CHANGELOG.md gets the version's entry at the top. +# - README.md's "最新版本" section, DMG links and example version move to it. +# - script/build_swiftui_and_run.sh defaults to it. +# +# The release notes are written by hand. When docs/RELEASE_NOTES_.md +# does not exist yet, this creates a template and stops so it can be filled in. +# Nothing is committed. +# +# Usage: +# script/prepare_release.sh "" "" +# +# Example: +# script/prepare_release.sh 0.2.20 "修复额度与刘海卡片细节" "额度窗口过了重置时间显示「已重置」……" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ $# -ne 3 ]]; then + sed -n '15,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 + exit 2 +fi + +VERSION="$1" +TITLE="$2" +SUMMARY="$3" +NOTES="$ROOT_DIR/docs/RELEASE_NOTES_$VERSION.md" + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must look like 0.2.20, got: $VERSION" >&2 + exit 2 +fi + +if [[ ! -f "$NOTES" ]]; then + cat > "$NOTES" <&2 + exit 1 +fi + +python3 - "$ROOT_DIR" "$VERSION" "$TITLE" "$SUMMARY" <<'PY' +import re +import sys +from pathlib import Path + +root, version, title, summary = Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4] + + +def version_key(value): + return tuple(int(part) for part in value.split(".")) + + +def fail(message): + sys.exit(f"prepare_release: {message}") + + +notes_path = root / f"docs/RELEASE_NOTES_{version}.md" +if not notes_path.read_text().startswith(f"# TokenStep {version}\n"): + fail(f"{notes_path.name} must start with '# TokenStep {version}'") + +build_path = root / "script/build_swiftui_and_run.sh" +build = build_path.read_text() +match = re.search(r'VERSION="\$\{TOKENSTEP_VERSION:-([0-9.]+)\}"', build) +if not match: + fail("could not find the default VERSION in build_swiftui_and_run.sh") +current = match.group(1) +if version_key(version) <= version_key(current): + fail(f"{version} is not newer than the current {current}") + +link = f"详见 [{version} 发布说明](docs/RELEASE_NOTES_{version}.md)。" +paragraph = summary.rstrip() +if not paragraph.endswith(("。", ".", "!", "?")): + paragraph += "。" + +# CHANGELOG: new entry above the first version heading. +changelog_path = root / "CHANGELOG.md" +changelog = changelog_path.read_text() +if f"## {version} 更新" in changelog: + fail(f"CHANGELOG.md already has an entry for {version}") +first = re.search(r"^## \d+\.\d+\.\d+ ", changelog, re.M) +if not first: + fail("could not find a version heading in CHANGELOG.md") +entry = f"## {version} 更新:{title}\n\n{paragraph}{link}\n\n" +changelog = changelog[: first.start()] + entry + changelog[first.start():] + +# README: the latest-version section, DMG links and the packaging example. +readme_path = root / "README.md" +readme = readme_path.read_text() +readme, sections = re.subn( + rf"## 最新版本:{re.escape(current)}\n\n.*?\n\n", + f"## 最新版本:{version}\n\n{paragraph}{link}\n\n", + readme, + count=1, + flags=re.S, +) +if sections != 1: + fail(f"could not find '## 最新版本:{current}' in README.md") +dmg_links = readme.count(f"TokenStep-{current}.dmg") +if dmg_links == 0: + fail(f"README.md has no TokenStep-{current}.dmg links") +readme = readme.replace(f"TokenStep-{current}.dmg", f"TokenStep-{version}.dmg") +readme = readme.replace(f"TOKENSTEP_VERSION={current}", f"TOKENSTEP_VERSION={version}") + +build = build.replace(match.group(0), f'VERSION="${{TOKENSTEP_VERSION:-{version}}}"') + +changelog_path.write_text(changelog) +readme_path.write_text(readme) +build_path.write_text(build) +print(f"{current} -> {version}: CHANGELOG entry, README section and {dmg_links} DMG links, build default.") +PY + +"$ROOT_DIR/script/test_release_contract.sh" +git -C "$ROOT_DIR" status --short -- CHANGELOG.md README.md script/build_swiftui_and_run.sh "docs/RELEASE_NOTES_$VERSION.md"