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

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

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

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

## 0.2.7 重大更新:引力边界黑洞主题包

0.2.7 新增第三套完整皮肤包「引力边界」。它不是给首页换一张壁纸,而是把近距事件视界、象牙白吸积盘和低速引力光流接进 TokenStep 的整套界面。

<p align="center">
<img src="docs/images/tokenstep-0.2.7-event-horizon-popover.jpg" alt="TokenStep 0.2.7 引力边界黑洞主题包浮层" width="900" />
</p>

- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。
- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。
- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、Token Island 和两类分享卡全部覆盖。
- **低成本动态光流**:吸积盘有缓慢相位漂移和轻微呼吸;窗口失焦、截图和 macOS“减少动态效果”状态会自动暂停,最高 24 fps。
- **原创应用内标记**:主题启用时,TokenStep 的应用内 Logo 会切换为事件视界与吸积盘标记。
- **更新检查更稳**:GitHub API 被限流时自动使用非 API 的最新 Release 通道;手动检查增加 10 秒防连点,并在双通道都失败时显示可理解的限流恢复时间。

主题使用原创生成背景和通用黑洞科学结构,不包含电影 Logo、演员、剧照、飞船或第三方水印。完整说明见 [0.2.7 发布说明](docs/RELEASE_NOTES_0.2.7.md) 与 [引力边界主题实现说明](docs/INTERSTELLAR_THEME_PACK_0.2.7.md)。

## 0.2.6 重大更新:检查更新后立即进入安装闭环

0.2.6 修复了从浮层主动检查更新时“查到了,但没有继续弹出更新窗口”的断点,并把下载、验证、替换与自动重启做成可追踪的完整链路。
Expand Down Expand Up @@ -85,7 +102,7 @@ TokenStep 第一次从“更换配色”升级为完整的**主题皮肤包系
- 新增奥德赛弓箭阶梯 Logo;用量采用骨金、冷金或余烬橙,绿色只保留给同步成功等状态反馈。
- 关闭排行榜后浮层会自动收短,多来源额度则使用紧凑布局完整展示。

打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。
打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.7.dmg)。

## TokenStep 适合谁?

Expand All @@ -112,7 +129,7 @@ TokenStep 适合这些人:
- 每日目标可设置,默认每天一个亿。
- 打开面板时按设置的新鲜度刷新;后台在接电时最低 15 分钟、电池或低电量模式下最低 30 分钟刷新,并跳过未变化的数据。
- 开机启动,可在设置里关闭。
- 主题皮肤包:可随时切回五种经典配色,也可使用奥德赛的导演剪辑、爱琴海冷雾、特洛伊火海与灰烬神像四个视觉篇章
- 主题皮肤包:可随时切换经典、奥德赛和引力边界;引力边界覆盖全界面并带低成本黑洞光流动效
- 一键截图分享当前页面。
- 一键生成「昨日 AI 节奏」分享卡,展示 24 小时使用波形、峰值时段和节奏标签。
- Codex / Claude Code 剩余额度可在设置中打开,默认关闭。
Expand Down Expand Up @@ -144,7 +161,7 @@ TokenStep 默认只做本地统计。

## 安装方式

1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。
1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.7.dmg)。
2. 打开 DMG。
3. 把 `TokenStep.app` 拖到「应用程序」。
4. 启动 TokenStep。
Expand Down Expand Up @@ -204,15 +221,15 @@ TokenStepSwift/dist/TokenStep.app
Developer ID 签名:

```bash
TOKENSTEP_VERSION=0.2.6 \
TOKENSTEP_VERSION=0.2.7 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
./script/package_release.sh
```

签名 + Apple 公证:

```bash
TOKENSTEP_VERSION=0.2.6 \
TOKENSTEP_VERSION=0.2.7 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \
./script/package_release.sh --notarize
Expand All @@ -227,7 +244,7 @@ release/TokenStep-<version>.dmg

维护者说明见 [docs/RELEASE.md](docs/RELEASE.md)。

0.2.6 发布说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.5 动效、模型用量与更新提醒说明见 [docs/RELEASE_NOTES_0.2.5.md](docs/RELEASE_NOTES_0.2.5.md);0.2.4 主题包实现与验收说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。
0.2.7 发布说明见 [docs/RELEASE_NOTES_0.2.7.md](docs/RELEASE_NOTES_0.2.7.md);引力边界实现说明见 [docs/INTERSTELLAR_THEME_PACK_0.2.7.md](docs/INTERSTELLAR_THEME_PACK_0.2.7.md);0.2.6 更新闭环说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.4 奥德赛主题包说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。

## 开源协议

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -858,13 +858,18 @@ struct TokenStepSettings: Codable {
}

var themePack: TokenStepThemePack {
theme == .voyage ? .odyssey : .classic
switch theme {
case .voyage: return .odyssey
case .eventHorizon: return .interstellar
default: return .classic
}
}

var activeThemeTitle: String {
switch themePack {
case .classic: return classicTheme.title
case .odyssey: return odysseyChapter.title
case .interstellar: return L("引力边界")
}
}

Expand Down Expand Up @@ -937,7 +942,7 @@ struct TokenStepSettings: Codable {
self.refreshIntervalSeconds = refreshIntervalSeconds
self.historyDays = historyDays
self.theme = theme
let fallbackClassicTheme = theme == .voyage ? TokenStepTheme.green : theme
let fallbackClassicTheme = TokenStepTheme.classicCases.contains(theme) ? theme : .green
let requestedClassicTheme = classicTheme ?? fallbackClassicTheme
self.classicTheme = TokenStepTheme.classicCases.contains(requestedClassicTheme)
? requestedClassicTheme
Expand Down
139 changes: 133 additions & 6 deletions TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ struct AvailableUpdate: Identifiable, Equatable {
var assetSize: Int

var formattedSize: String {
ByteCountFormatter.string(fromByteCount: Int64(assetSize), countStyle: .file)
guard assetSize > 0 else { return L("发布包") }
return ByteCountFormatter.string(fromByteCount: Int64(assetSize), countStyle: .file)
}

var noteLines: [String] {
Expand Down Expand Up @@ -61,6 +62,7 @@ enum UpdateCheckTrigger: String, Equatable {
enum UpdateCheckPolicy {
static let automaticInterval: TimeInterval = 6 * 60 * 60
static let timerTolerance: TimeInterval = 15 * 60
static let manualCooldown: TimeInterval = 10

static func shouldStart(
trigger: UpdateCheckTrigger,
Expand Down Expand Up @@ -107,6 +109,7 @@ enum UpdatePresentationPolicy {
struct UpdateCheckGate {
private(set) var isChecking = false
private(set) var lastAttemptAt: Date?
private(set) var lastManualAttemptAt: Date?

mutating func begin(
trigger: UpdateCheckTrigger,
Expand All @@ -115,6 +118,11 @@ struct UpdateCheckGate {
startupCheckPending: Bool = false
) -> Bool {
guard !isChecking else { return false }
if trigger.isManual,
let lastManualAttemptAt,
now.timeIntervalSince(lastManualAttemptAt) < UpdateCheckPolicy.manualCooldown {
return false
}
guard UpdateCheckPolicy.shouldStart(
trigger: trigger,
autoUpdateEnabled: autoUpdateEnabled,
Expand All @@ -126,6 +134,9 @@ struct UpdateCheckGate {
}
isChecking = true
lastAttemptAt = now
if trigger.isManual {
lastManualAttemptAt = now
}
return true
}

Expand All @@ -136,6 +147,8 @@ struct UpdateCheckGate {

enum UpdateService {
private static let latestReleaseURL = URL(string: "https://api.github.com/repos/Backtthefuture/TokenStep/releases/latest")!
private static let latestReleaseFallbackURL = URL(string: "https://github.com/Backtthefuture/TokenStep/releases/latest")!
private static let releaseDownloadBaseURL = URL(string: "https://github.com/Backtthefuture/TokenStep/releases/download/")!

static var currentVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
Expand All @@ -144,26 +157,132 @@ enum UpdateService {
static func checkForUpdates(
currentVersion: String = Self.currentVersion,
session: URLSession = .shared,
releaseURL: URL? = nil
releaseURL: URL? = nil,
fallbackURL: URL? = nil
) async throws -> UpdateCheckResult {
var request = URLRequest(url: releaseURL ?? latestReleaseURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 15
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("TokenStep/\(currentVersion)", forHTTPHeaderField: "User-Agent")

let (data, response) = try await session.data(for: request)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw UpdateError.checkFailed
}

if (200..<300).contains(http.statusCode) {
return try evaluateReleaseResponse(
data: data,
statusCode: http.statusCode,
currentVersion: currentVersion
)
}

let primaryError = responseError(from: http)
do {
return try await checkFallbackLatestRelease(
currentVersion: currentVersion,
session: session,
fallbackURL: fallbackURL
)
} catch {
throw primaryError
}
} catch let error as UpdateError {
throw error
} catch {
do {
return try await checkFallbackLatestRelease(
currentVersion: currentVersion,
session: session,
fallbackURL: fallbackURL
)
} catch {
throw UpdateError.checkFailed
}
}
}

private static func checkFallbackLatestRelease(
currentVersion: String,
session: URLSession,
fallbackURL: URL?
) async throws -> UpdateCheckResult {
var request = URLRequest(url: fallbackURL ?? latestReleaseFallbackURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 15
request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
request.setValue("TokenStep/\(currentVersion)", forHTTPHeaderField: "User-Agent")

let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw UpdateError.checkFailed
}

return try evaluateReleaseResponse(
data: data,
return try evaluateFallbackReleaseResponse(
responseURL: http.url,
statusCode: http.statusCode,
currentVersion: currentVersion
)
}

static func evaluateFallbackReleaseResponse(
responseURL: URL?,
statusCode: Int,
currentVersion: String
) throws -> UpdateCheckResult {
guard (200..<300).contains(statusCode),
let responseURL,
let tagName = releaseTag(from: responseURL),
let releaseVersion = Version(tagName.strippingVersionPrefix),
let installedVersion = Version(currentVersion)
else {
throw UpdateError.checkFailed
}

guard releaseVersion > installedVersion else { return .upToDate }
let version = tagName.strippingVersionPrefix
let assetName = "TokenStep-\(version).dmg"
let assetURL = releaseDownloadBaseURL
.appendingPathComponent(tagName, isDirectory: true)
.appendingPathComponent(assetName)

return .available(
AvailableUpdate(
version: version,
tagName: tagName,
title: "TokenStep \(version)",
notes: L("发现新的稳定版本,可直接下载并安装已签名公证的 DMG。"),
pageURL: responseURL,
assetURL: assetURL,
assetName: assetName,
assetSize: 0
)
)
}

private static func releaseTag(from url: URL) -> String? {
let marker = "/releases/tag/"
guard let range = url.path.range(of: marker) else { return nil }
let encoded = String(url.path[range.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !encoded.isEmpty, !encoded.contains("/") else { return nil }
return encoded.removingPercentEncoding ?? encoded
}

private static func responseError(from response: HTTPURLResponse) -> UpdateError {
let remaining = response.value(forHTTPHeaderField: "X-RateLimit-Remaining")
let isRateLimited = response.statusCode == 429
|| (response.statusCode == 403 && remaining == "0")
guard isRateLimited else { return .checkFailed }

let resetAt = response.value(forHTTPHeaderField: "X-RateLimit-Reset")
.flatMap(TimeInterval.init)
.map(Date.init(timeIntervalSince1970:))
return .rateLimited(resetAt: resetAt)
}

static func evaluateReleaseResponse(
data: Data,
statusCode: Int,
Expand Down Expand Up @@ -527,6 +646,7 @@ enum UpdateService {

enum UpdateError: LocalizedError, Equatable {
case checkFailed
case rateLimited(resetAt: Date?)
case missingDMG
case downloadFailed
case verificationFailed
Expand All @@ -536,6 +656,13 @@ enum UpdateError: LocalizedError, Equatable {
switch self {
case .checkFailed:
return L("检查更新失败,请稍后再试。")
case let .rateLimited(resetAt):
guard let resetAt else {
return L("GitHub 检查频率暂时受限,请稍后再试。")
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return LFormat("GitHub 检查频率暂时受限,预计 %@ 后恢复。", formatter.string(from: resetAt))
case .missingDMG:
return L("新版本没有可下载的 DMG。")
case .downloadFailed:
Expand Down
15 changes: 11 additions & 4 deletions TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,11 @@ final class AppState: ObservableObject {
}

func setTheme(_ theme: TokenStepTheme) {
if theme == .voyage {
settings.theme = .voyage
} else {
if TokenStepTheme.classicCases.contains(theme) {
settings.classicTheme = theme
settings.theme = theme
} else {
settings.theme = theme
}
TokenStepThemeRuntime.apply(
settings.theme,
Expand All @@ -437,7 +437,14 @@ final class AppState: ObservableObject {
}

func setThemePack(_ pack: TokenStepThemePack) {
settings.theme = pack == .odyssey ? .voyage : settings.classicTheme
switch pack {
case .classic:
settings.theme = settings.classicTheme
case .odyssey:
settings.theme = .voyage
case .interstellar:
settings.theme = .eventHorizon
}
TokenStepThemeRuntime.apply(
settings.theme,
odysseyChapter: settings.odysseyChapter
Expand Down
Loading
Loading