diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9486c2c..553362c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,8 +48,17 @@ jobs: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} run: | - gh release create "v$VERSION" \ - "release/TokenStep-$VERSION.dmg" \ - "release/TokenStep-$VERSION.zip" \ - --title "TokenStep $VERSION" \ - --notes "TokenStep $VERSION" + NOTES_FILE="docs/RELEASE_NOTES_$VERSION.md" + if [[ -f "$NOTES_FILE" ]]; then + gh release create "v$VERSION" \ + "release/TokenStep-$VERSION.dmg" \ + "release/TokenStep-$VERSION.zip" \ + --title "TokenStep $VERSION" \ + --notes-file "$NOTES_FILE" + else + gh release create "v$VERSION" \ + "release/TokenStep-$VERSION.dmg" \ + "release/TokenStep-$VERSION.zip" \ + --title "TokenStep $VERSION" \ + --notes "TokenStep $VERSION" + fi diff --git a/README.md b/README.md index ea90a79..5d53f72 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.5.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg) 也可以从 Release 页面查看所有版本: @@ -32,6 +32,18 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases +## 0.2.6 重大更新:检查更新后立即进入安装闭环 + +0.2.6 修复了从浮层主动检查更新时“查到了,但没有继续弹出更新窗口”的断点,并把下载、验证、替换与自动重启做成可追踪的完整链路。 + +- **发现新版立即弹窗**:点击浮层或主窗口的更新按钮后,若检测到新版,会立刻打开独立更新窗口。 +- **更新窗口确保可见**:菜单栏瞬时浮层自动收起,更新窗口切到当前桌面并保持在前台,不再被浮层遮住。 +- **一次点击完成升级**:点击“安装并重启”后自动下载 DMG、校验签名、公证与版本,备份旧 App、替换 `/Applications/TokenStep.app` 并重新打开。 +- **全过程可诊断**:检查、弹窗、安装启动和失败都会写入生命周期日志;新增隔离安装验证脚本,避免把“能下载”等同于“能升级”。 +- **不改变自动检查策略**:后台检查仍采用非打扰提醒;只有用户主动点击检查并发现新版时才强制弹出窗口。 + +本次不改变 Token、金额、额度、排行榜及本地采集口径。完整说明见 [0.2.6 发布说明](docs/RELEASE_NOTES_0.2.6.md)。 + ## 0.2.5 重大更新:动态特洛伊火焰、模型用量与更新提醒 0.2.5 让奥德赛主题从静态电影画面进入动态状态,同时把菜单栏浮层和更新体验补得更完整。 @@ -73,7 +85,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.5.dmg)。 +打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。 ## TokenStep 适合谁? @@ -132,7 +144,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.5.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -192,7 +204,7 @@ TokenStepSwift/dist/TokenStep.app Developer ID 签名: ```bash -TOKENSTEP_VERSION=0.2.5 \ +TOKENSTEP_VERSION=0.2.6 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ ./script/package_release.sh ``` @@ -200,7 +212,7 @@ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ 签名 + Apple 公证: ```bash -TOKENSTEP_VERSION=0.2.5 \ +TOKENSTEP_VERSION=0.2.6 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize @@ -215,7 +227,7 @@ release/TokenStep-.dmg 维护者说明见 [docs/RELEASE.md](docs/RELEASE.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.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)。 ## 开源协议 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift index 4d1698b..a4521cc 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift @@ -95,6 +95,15 @@ enum UpdateCheckPolicy { } } +enum UpdatePresentationPolicy { + static func shouldPresentWindow( + trigger: UpdateCheckTrigger, + update: AvailableUpdate? + ) -> Bool { + trigger.isManual && update != nil + } +} + struct UpdateCheckGate { private(set) var isChecking = false private(set) var lastAttemptAt: Date? diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index e6bfe5c..f5dfce9 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -815,7 +815,13 @@ final class AppState: ObservableObject { LifecycleLogger.log( "Update check success trigger=\(trigger.rawValue) result=available version=\(visibleUpdate.version)." ) - if trigger.isManual { + if UpdatePresentationPolicy.shouldPresentWindow( + trigger: trigger, + update: visibleUpdate + ) { + LifecycleLogger.log( + "Presenting update window trigger=\(trigger.rawValue) version=\(visibleUpdate.version)." + ) UpdateWindowPresenter.shared.show(appState: self, update: visibleUpdate) } } else { @@ -859,6 +865,9 @@ final class AppState: ObservableObject { func installAvailableUpdate() { guard let update = availableUpdate, !isDownloadingUpdate else { return } + LifecycleLogger.log( + "Update install requested version=\(update.version) asset=\(update.assetName)." + ) isDownloadingUpdate = true updateDownloadProgress = 0 updateInstallStatus = L("正在下载") @@ -875,10 +884,16 @@ final class AppState: ObservableObject { updateDownloadedURL = url updateDownloadProgress = 1 updateInstallStatus = L("正在安装并重启") + LifecycleLogger.log( + "Update installer launched version=\(update.version) dmg=\(url.path)." + ) } catch { lastError = error.localizedDescription updateInstallStatus = L("更新失败") isDownloadingUpdate = false + LifecycleLogger.log( + "Update install failed version=\(update.version) error=\(error.localizedDescription)." + ) } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/UpdateWindowPresenter.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/UpdateWindowPresenter.swift index 6f56048..f8bd439 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/UpdateWindowPresenter.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/UpdateWindowPresenter.swift @@ -8,10 +8,12 @@ final class UpdateWindowPresenter { private var window: NSWindow? func show(appState: AppState, update: AvailableUpdate) { + window?.close() let window = makeWindow(appState: appState, update: update) self.window = window NSApp.activate(ignoringOtherApps: true) + closeTransientPanels(except: window) window.makeKeyAndOrderFront(nil) window.orderFrontRegardless() } @@ -34,9 +36,18 @@ final class UpdateWindowPresenter { window.titlebarAppearsTransparent = true window.titlebarSeparatorStyle = .none window.isReleasedWhenClosed = false + window.level = .floating + window.hidesOnDeactivate = false + window.collectionBehavior.insert(.moveToActiveSpace) window.setContentSize(NSSize(width: 560, height: 500)) window.minSize = NSSize(width: 520, height: 460) window.center() return window } + + private func closeTransientPanels(except updateWindow: NSWindow) { + for candidate in NSApp.windows where candidate !== updateWindow && candidate.title.isEmpty { + candidate.close() + } + } } diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift index 0bc5a57..903385c 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift @@ -125,6 +125,35 @@ final class UpdateServiceTests: XCTestCase { ) } + func testManualAvailableUpdateRequestsImmediateWindowPresentation() { + let update = makeUpdate(version: "0.2.6") + + XCTAssertTrue( + UpdatePresentationPolicy.shouldPresentWindow( + trigger: .manual, + update: update + ) + ) + XCTAssertFalse( + UpdatePresentationPolicy.shouldPresentWindow( + trigger: .manual, + update: nil + ) + ) + XCTAssertFalse( + UpdatePresentationPolicy.shouldPresentWindow( + trigger: .startup, + update: update + ) + ) + XCTAssertFalse( + UpdatePresentationPolicy.shouldPresentWindow( + trigger: .timer, + update: update + ) + ) + } + func testAutomaticCheckHonorsToggleSixHourThrottleAndSkippedVersion() { let now = Date(timeIntervalSince1970: 100_000) XCTAssertFalse( diff --git a/docs/RELEASE_NOTES_0.2.6.md b/docs/RELEASE_NOTES_0.2.6.md new file mode 100644 index 0000000..27e7a4e --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.6.md @@ -0,0 +1,39 @@ +# TokenStep 0.2.6 — 更新检查、下载安装与自动重启闭环 + +0.2.6 聚焦一件事:用户主动点击更新按钮后,如果存在新版,TokenStep 必须立即进入可见、可完成、可诊断的更新流程。 + +## 发现新版后立即打开更新窗口 + +- 菜单栏浮层、主窗口和设置中的手动检查继续共用同一更新状态。 +- 手动检查发现新版后,立即打开独立更新窗口,不再只改变按钮图标或等待用户再次点击。 +- 手动检查仍会忽略自动检查开关和“跳过此版本”状态,方便用户主动重新查看新版。 +- 后台启动、定时和回到前台检查保持非打扰模式,只显示已有更新标记,不强制弹窗。 + +## 更新窗口不会被浮层遮挡 + +- 打开更新窗口时自动关闭菜单栏瞬时浮层。 +- 更新窗口移动到当前桌面、激活 TokenStep 并置于前台。 +- 重复点击更新入口只保留一个新的更新窗口,避免多个窗口叠加。 + +## 一次点击完成下载、验证、替换和重启 + +- 点击“安装并重启”后显示真实下载进度。 +- 下载完成后挂载 DMG,并校验 Developer ID、Gatekeeper、公证状态和 App 版本。 +- 安装 helper 先备份现有 `/Applications/TokenStep.app`,复制失败时恢复备份。 +- 安装成功后删除临时备份,重新打开新版本并确认进程已启动。 +- 检查、窗口展示、安装请求、helper 启动与失败均写入生命周期或安装日志。 + +## 验证 + +- 全量 Swift 测试:100 项通过,0 项失败。 +- 新增手动发现更新后立即请求窗口展示的策略测试。 +- 使用官方 0.2.5 DMG 和旧版 helper 完成隔离安装验证:签名、挂载、复制及版本检查均通过。 +- 在真实 `/Applications` 环境完成 0.2.4 → 0.2.5 的备份、替换和自动重开验证。 +- 新增 `script/verify_update_installer.sh`,可在不覆盖已安装 App 的情况下验证发布 DMG。 + +## 安全、隐私与兼容性 + +- 更新源仍为本仓库的 GitHub Releases,不新增更新服务器或遥测。 +- 不做静默强制安装;下载和替换必须由用户点击“安装并重启”。 +- 不改变 Token、金额、额度、排行榜、主题和本地数据采集口径。 +- 支持 macOS 14 及以上的 Apple Silicon Mac。 diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index 1359313..0053e14 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -22,7 +22,7 @@ EXECUTABLE="$BUILD_DIR/$PRODUCT_NAME" HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" -VERSION="${TOKENSTEP_VERSION:-0.2.5}" +VERSION="${TOKENSTEP_VERSION:-0.2.6}" LAUNCH=true VERIFY=false diff --git a/script/package_release.sh b/script/package_release.sh index 0fd5173..1b84690 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -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.2.5}" +VERSION="${TOKENSTEP_VERSION:-0.2.6}" IDENTITY="${CODE_SIGN_IDENTITY:-}" NOTARIZE=false usage() { cat <<'USAGE' Usage: - TOKENSTEP_VERSION=0.1.48 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] + TOKENSTEP_VERSION=0.2.6 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] Notarization credentials, choose one: TOKENSTEP_NOTARY_PROFILE="notarytool-profile" diff --git a/script/verify_update_installer.sh b/script/verify_update_installer.sh new file mode 100755 index 0000000..43b88ce --- /dev/null +++ b/script/verify_update_installer.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 /path/to/TokenStep.dmg EXPECTED_VERSION /path/to/TokenStepHelper" >&2 + exit 2 +fi + +DMG_PATH="$1" +EXPECTED_VERSION="$2" +HELPER_SOURCE="$3" + +if [[ ! -f "$DMG_PATH" ]]; then + echo "DMG not found: $DMG_PATH" >&2 + exit 2 +fi +if [[ ! -x "$HELPER_SOURCE" ]]; then + echo "Helper is not executable: $HELPER_SOURCE" >&2 + exit 2 +fi + +VERIFY_ROOT="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/tokenstep-update-verify.XXXXXX")" +VERIFY_DESTINATION="$VERIFY_ROOT/Applications/TokenStep.app" +VERIFY_HELPER="$VERIFY_ROOT/TokenStepHelper" +VERIFY_LOG="$VERIFY_ROOT/update-install.log" + +cleanup() { + if [[ -n "${VERIFY_ROOT:-}" && "$VERIFY_ROOT" == *tokenstep-update-verify.* ]]; then + /bin/rm -rf "$VERIFY_ROOT" + fi +} +trap cleanup EXIT + +/bin/mkdir -p "$VERIFY_ROOT/Applications" +/bin/cp "$HELPER_SOURCE" "$VERIFY_HELPER" +/bin/chmod 755 "$VERIFY_HELPER" + +"$VERIFY_HELPER" install \ + --dmg "$DMG_PATH" \ + --version "$EXPECTED_VERSION" \ + --current-pid 0 \ + --require-verified 1 \ + --log "$VERIFY_LOG" \ + --helper-path "$VERIFY_HELPER" \ + --destination "$VERIFY_DESTINATION" \ + --skip-relaunch 1 \ + --skip-stop 1 + +INSTALLED_VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$VERIFY_DESTINATION/Contents/Info.plist")" +if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then + echo "Installed version mismatch: expected $EXPECTED_VERSION, got $INSTALLED_VERSION" >&2 + exit 1 +fi + +/usr/bin/codesign --verify --deep --strict "$VERIFY_DESTINATION" +/usr/sbin/spctl --assess --type execute "$VERIFY_DESTINATION" +/bin/cat "$VERIFY_LOG" +echo "Verified TokenStep $INSTALLED_VERSION update installation in an isolated destination."