From 1c1e94a15fcb1dd17df543306783e3a1aa2fd8e5 Mon Sep 17 00:00:00 2001 From: ysicing Date: Wed, 30 Sep 2026 14:13:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(updates):=20=E6=8E=A5=E5=85=A5=20Sparkle?= =?UTF-8?q?=20=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8F=8C=E8=AF=AD=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/xstats-changelog/SKILL.md | 2 + .agents/skills/xstats-release/SKILL.md | 8 +- .../xstats-release/references/runbook.md | 13 +- AGENTS.md | 1 + ARCHITECTURE.md | 67 ++-- App/Info.plist | 12 + DEVELOPMENT.md | 36 ++- LICENSES/Sparkle-License.txt | 134 ++++++++ LICENSING.md | 2 + Packages/XStatsKit/Package.swift | 4 +- .../Sources/Localization/Resources/ar.tsv | 2 +- .../Sources/Localization/Resources/de.tsv | 2 +- .../Sources/Localization/Resources/es.tsv | 2 +- .../Sources/Localization/Resources/fr.tsv | 2 +- .../Sources/Localization/Resources/ja.tsv | 2 +- .../Sources/Localization/Resources/ko.tsv | 2 +- .../Localization/Resources/zh-Hant.tsv | 2 +- .../Sources/Localization/Translations.swift | 2 +- .../Sources/Updates/UpdateFeed.swift | 35 +- .../Sources/Updates/UpdateInstaller.swift | 196 +---------- .../XStatsUI/AppKit/AppController.swift | 9 +- .../XStatsUI/Settings/UpdateViews.swift | 13 +- .../Sources/XStatsUI/State/AppSettings.swift | 31 +- .../XStatsUI/State/SparkleInstaller.swift | 303 ++++++++++++++++++ .../XStatsUI/State/UpdateController.swift | 250 ++++++--------- .../DownloadCancellationTests.swift | 72 ----- .../Tests/UpdatesTests/UpdateFeedTests.swift | 153 ++------- .../XStatsUITests/SparkleInstallerTests.swift | 184 +++++++++++ .../Tests/XStatsUITests/XStatsUITests.swift | 20 -- ReleaseNotes.json | 23 ++ Taskfile.yml | 5 +- ThirdPartyNotices.md | 6 + project.yml | 8 + scripts/arm64_release_test.sh | 5 + scripts/github_release_notes.py | 39 +++ scripts/github_release_notes_test.py | 86 +++++ scripts/publish_api.py | 26 +- scripts/publish_api_test.py | 44 ++- scripts/publish_release.sh | 22 +- scripts/release.sh | 17 +- scripts/release_all.sh | 1 + scripts/release_all_test.sh | 3 +- scripts/release_provenance.py | 8 + scripts/release_provenance_test.py | 14 + scripts/sign_sparkle.sh | 50 +++ scripts/sparkle_appcast.py | 186 +++++++++++ scripts/sparkle_appcast_test.py | 148 +++++++++ server/api/main.go | 93 +++++- server/api/sparkle_test.go | 142 ++++++++ 49 files changed, 1838 insertions(+), 649 deletions(-) create mode 100644 LICENSES/Sparkle-License.txt create mode 100644 Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift delete mode 100644 Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift create mode 100644 ReleaseNotes.json create mode 100644 scripts/github_release_notes.py create mode 100644 scripts/github_release_notes_test.py create mode 100644 scripts/sign_sparkle.sh create mode 100644 scripts/sparkle_appcast.py create mode 100644 scripts/sparkle_appcast_test.py create mode 100644 server/api/sparkle_test.go diff --git a/.agents/skills/xstats-changelog/SKILL.md b/.agents/skills/xstats-changelog/SKILL.md index 6c56628..138367d 100644 --- a/.agents/skills/xstats-changelog/SKILL.md +++ b/.agents/skills/xstats-changelog/SKILL.md @@ -21,6 +21,8 @@ Write a short, user-facing summary for the next macOS app release. `CHANGELOG.md - Summarize externally visible outcomes, not implementation steps or every commit. Group by `### 新增`, `### 改进`, `### 修复`, and `### 移除` as applicable; omit empty groups. Use `移除` only for an actual removed capability, not a renamed or temporarily unavailable one. - Keep each item concise and factual. Merge related commits into one benefit or behavior change; retain important compatibility, data-handling, and user-action changes. Exclude internal-only refactors, CI, build, and release mechanics unless they directly affect users. - Draft under `## 未发布` during development. For an authorized release with a determined version, make the first `##` heading `## X.Y.Z · YYYY-MM-DD` and keep older versions unchanged. Do not leave `## 未发布` above the formal heading: release scripts parse the first level-two heading as the version. +- For a new release, update `ReleaseNotes.json` with the same version and generated Chinese `sourceNotes`, then write matching English summaries in `translations.en`. Both Chinese app languages show the Chinese source; all other languages show English. Translate only the current release; keep the same item order and count, and do not use live machine translation in the client. Verify wording against the Chinese source. +- GitHub Release bodies contain both Chinese and English: keep the full Chinese section and reuse `translations.en` for English. Include user-action and compatibility details in the English notes, even when the Chinese source summary was shortened. Preview with `python3 scripts/github_release_notes.py `; do not rewrite historical releases without authorization. - Review the result against the diff and the existing draft for omissions, duplication, or unsupported claims. Do not rewrite already published history as part of preparing a new release. ## Validate and hand off diff --git a/.agents/skills/xstats-release/SKILL.md b/.agents/skills/xstats-release/SKILL.md index 89669dd..66347a4 100644 --- a/.agents/skills/xstats-release/SKILL.md +++ b/.agents/skills/xstats-release/SKILL.md @@ -23,11 +23,15 @@ Before acting, read the repository `AGENTS.md`, the release section of `DEVELOPM ## Release invariants - Public versions are semver from the first formal `CHANGELOG.md` heading; date-style versions are historical test data. -- Release artifacts contain only arm64 binaries. +- Release artifacts contain only arm64 binaries, including the embedded Sparkle framework and its installer/XPC executables, all re-signed with the app's Developer ID team. +- Sparkle archive and versioned XML feed signatures use the existing Ed25519 keychain account `work.12306.xstats.sparkle` (or an explicitly selected `SPARKLE_KEY_ACCOUNT`). The public key must match `SUPublicEDKey`; never silently generate or rotate a release key. Client installs use Sparkle's official trust chain; same-Team, notarization and Gatekeeper checks are mandatory release gates. - Public distribution uses `Developer ID Application`, hardened runtime, secure timestamps, notarization, stapling, and Gatekeeper acceptance. `Apple Development` and `SKIP_NOTARIZE=1` are not public-release substitutes. - The notarization keychain profile is `XStats` unless the user explicitly selects another valid profile. - Upload through the MinIO alias `c-ip` to `c-ip/oss/apps/macOS/XStats`; public downloads use `https://c.ysicing.net/oss/apps/macOS/XStats/`. -- Publish the same appcast to the global and China release APIs. The manifest must remain last: packages first, then GitHub Release, then update APIs. +- Before publishing the first Sparkle client, deploy both regional APIs with the fixed `/api/v1/update/appcast.xml` endpoint. `python3 scripts/publish_api.py --check-sparkle` must confirm 400 + no-store without generating installation statistics. The endpoint preserves signed versioned XML bytes and switches through the existing release manifest; do not upload a mutable, long-TTL CDN appcast. +- `ReleaseNotes.json` must match the current version and generated Chinese summaries, with one English translation. The signed XML contains standard Chinese/English descriptions and both application language fields; Chinese app languages show Chinese and all others show English. Preserve the legacy Chinese JSON `notes`. The provenance verifier binds translations to their build-time hash. +- GitHub Release creation and edits use a bilingual body generated by `scripts/github_release_notes.py`: full Chinese changelog section followed by English notes from `translations.en`. Review both, including upgrade instructions, before publishing; do not update historical releases as part of a new release. +- Publish the same appcast to the global and China release APIs. The manifest must remain last: DMG, ZIP and signed versioned XML first with public hash readback, then GitHub Release, then the unchanged JSON update APIs. - The Homebrew target is `ysicing/homebrew-tap`, installed as `ysicing/tap/xstats`. - `dist/release-provenance.json` binds artifacts to the build-time commit, version, build number, and version-file hashes. Do not bypass it or rebuild provenance manually. - A release builds and verifies artifacts without quitting, replacing, or launching the XStats installed on the current machine. Do not run `install_local.sh` as part of `task release` or `task release-all`; local installation is a separate, explicit request. diff --git a/.agents/skills/xstats-release/references/runbook.md b/.agents/skills/xstats-release/references/runbook.md index 8d04152..6751d94 100644 --- a/.agents/skills/xstats-release/references/runbook.md +++ b/.agents/skills/xstats-release/references/runbook.md @@ -41,6 +41,8 @@ For the one-command path, `XSTATS_RELEASE_TOKEN` must already be available throu ## 2. Prepare version metadata - Use `xstats-changelog` to consolidate the release notes, then replace the first `## 未发布` heading with `## X.Y.Z · YYYY-MM-DD`. +- Update `ReleaseNotes.json` for that version: match `sourceNotes` to the Chinese summaries and prepare `translations.en` in the same item order. Generation rejects stale or incomplete English notes; changing them after the build invalidates provenance. +- Preview `python3 scripts/github_release_notes.py `: GitHub Release includes the full Chinese section plus English summaries. Ensure English retains upgrade instructions and compatibility details. Publication generates the same bilingual text for both creation and reruns. - Update the release badge in all four README files. - Do not manually change `project.yml`; `scripts/version.sh release`, invoked by `task release`, writes the semver and increments the build number once. - Do not commit these metadata changes yet. They are allowed inputs to the provenance preflight. @@ -71,6 +73,7 @@ Expected files: ```text dist/XStats-X.Y.Z-AppleSilicon.dmg dist/XStats-X.Y.Z-AppleSilicon.zip +dist/XStats-X.Y.Z-AppleSilicon.xml dist/appcast.json dist/xstats.rb dist/release-provenance.json @@ -88,7 +91,9 @@ xcrun stapler validate dist/XStats-X.Y.Z-AppleSilicon.dmg spctl -a -t open --context context:primary-signature -vv dist/XStats-X.Y.Z-AppleSilicon.dmg ``` -All architectures must be `arm64`; both app and DMG must report notarized Developer ID acceptance. Validate the appcast JSON, artifact sizes, and SHA-256 values. +All architectures must be `arm64`; both app and DMG must report notarized Developer ID acceptance. Validate the appcast JSON, artifact sizes, and SHA-256 values. Include Sparkle framework, Autoupdate, Updater and both XPC services in the arm64, same-Team, timestamp and hardened-runtime audit. Verify the signed XML and ZIP using `python3 scripts/sparkle_appcast.py dist/appcast.json dist/XStats-X.Y.Z-AppleSilicon.zip --verify`. A missing Ed25519 keychain account is a release blocker, never a reason to generate a replacement key. + +For the Sparkle transition, additionally verify a signed/notarized old client upgrading to the first Sparkle client, followed by a second upgrade using Sparkle, including retry/cancel, write-permission authorization, Widget and Helper recovery. Fixture-only installation tests do not establish this public-version transition. ## 4. Commit release metadata @@ -132,7 +137,9 @@ TAP=ysicing/homebrew-tap Its required order is: 1. Verify provenance, clean worktree, and `HEAD == origin/main`. -2. Upload DMG and ZIP to `c-ip`. +Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. Both regional fixed XML endpoints must already be deployed and reject a missing installation ID with 400 + no-store. Old JSON POST clients and publication fields remain unchanged. The API serves the current manifest's immutable signed XML without rewriting its signature or forwarding the installation ID to the CDN. + +2. Verify Ed25519 signatures, then upload DMG, ZIP and versioned XML to `c-ip`. 3. Download each public `c.ysicing.net` URL and compare SHA-256. 4. Create or update `vX.Y.Z` GitHub Release, refusing to move an existing mismatched tag. 5. Publish the appcast to both regional APIs. @@ -147,7 +154,7 @@ Prove all of the following from live state: - `git status -sb` is clean and local `main` matches `origin/main`. - The release tag resolves to the intended release metadata commit. - GitHub Release is neither draft nor prerelease and contains the notarized DMG with the expected digest. -- Both public object URLs return bytes matching the local DMG and ZIP SHA-256 values. +- All three public object URLs return bytes matching local DMG, ZIP and signed XML SHA-256 values; the JSON ZIP URL maps to the corresponding immutable XML. - Both regional update-check endpoints return the released version/build and `c.ysicing.net` URLs. Reuse one fixed validation installation ID instead of creating many statistics rows. - The remote Homebrew cask matches `dist/xstats.rb`; `brew info --cask ysicing/tap/xstats` shows the released version and arm64/macOS requirements. - Check the release commit and any immediate release-workflow fix commits' GitHub Actions status once. Record each visible run's link and actual result; if queued, running, or not yet listed, report CI as pending or unobserved without waiting or polling. Investigate a run that has already completed with failure. diff --git a/AGENTS.md b/AGENTS.md index 701f531..32ce465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - macOS 客户端使用 Swift 6、AppKit 与 SwiftUI,包含 WidgetKit 扩展;目标为 macOS 14+、Apple Silicon(arm64)。 - `project.yml` 由 XcodeGen 生成 Xcode 工程;可测试模块位于 Swift Package `Packages/XStatsKit`,构建任务定义在 `Taskfile.yml`。 - 系统采集使用 IOKit / SMC;特权操作通过 `SMAppService` 注册的 XPC 辅助工具完成。本机历史和 AI 统计使用 SQLite,偏好设置使用 UserDefaults,凭据使用 Keychain。 +- 在线更新使用 Sparkle 2.10.0 统一管理检查调度、跳过版本与安装,沿用 XStats 更新界面及系统通知开关;不得并存独立检查定时器或自动下载/安装。固定 XML API 合并安装统计并原样返回已签名清单,旧 JSON 协议继续兼容。发布前必须先部署双区域 XML 入口,验证同团队签名、公证与 Gatekeeper,并先上传已签名的版本 XML 和安装包再发布 JSON 清单。 - 日历使用 Tyme4Swift;更新检查服务位于 `server/api`,使用 Go、Fiber v3、GORM 和 SQLite。依赖版本以 `Packages/XStatsKit/Package.swift` 和 `server/api/go.mod` 为准,构建与发布步骤见 [DEVELOPMENT.md](DEVELOPMENT.md)。 ## 架构与代码质量 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 921d508..2090bfa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -355,34 +355,63 @@ ambiguous matches remain unknown. Disconnected cached devices never retain a cha ## Online updates -`UpdateController` posts the current version and a hashed installation ID according to the user's update-check schedule. -China-region locales prefer `https://x-stats.china.12306.work/api/v1/update/check`; other locales prefer -`https://xstats-apps.12306.work/api/v1/update/check`. Failures fall back serially to the other endpoint, -and the first success stops further requests so one check is not reported twice. Requests use the stable -`XStats/ (macOS )` User-Agent format for regional routing and diagnostics. +The current release's Chinese summaries come from `CHANGELOG.md`; `ReleaseNotes.json` stores the +matching source summaries and one English translation. The XML publisher validates their version +and content, emits Chinese/English `description` nodes and signed `xstats:notes-zh-Hans` and +`xstats:notes-en` fields, and binds the translation file to release provenance. The custom UI shows +Chinese summaries for both Simplified and Traditional Chinese app languages, and English for all +other languages, without another request. Legacy feeds without English notes keep their original +description. The legacy JSON protocol continues to expose Chinese `notes` and the same ZIP metadata. +GitHub Release notes retain the full Chinese changelog section and append the same English release +summaries, including user actions and compatibility notes. Publication validates both sections before +uploading artifacts and uses the same bilingual body for release creation and edits. + +`UpdateController` owns one long-lived Sparkle 2.10.0 updater and custom user driver. Sparkle +schedules checks, stores skipped builds, verifies signed feeds/archives and installs only after explicit +confirmation. China-region locales prefer `https://x-stats.china.12306.work/api/v1/update/appcast.xml`; +other locales prefer `https://xstats-apps.12306.work/api/v1/update/appcast.xml`. A failed feed check retries +the other region once, serially; archive or installation failures never restart installation through a +fallback. Each GET includes the current version and hashed installation ID, so no separate telemetry +POST or second check timer is needed. System profiling and automatic download/install stay disabled. Existing installations retain the hash of their saved 32-byte random value, which remains in local `UserDefaults` and is excluded from WebDAV settings sync. New installations without a saved value hash the Mac serial number read through IOKit. If the serial is unavailable, they generate and save a random value with `SecRandomCopyBytes`. None of these paths invokes Keychain authorization, and the raw serial is never sent. Clearing preferences on an older installation can produce a new ID; historical rows are -not merged. The response has the same release manifest fields previously read from the static appcast (version, -date, notes taken from `CHANGELOG.md` by `scripts/appcast.py`, zip URL, sha256 and size). An -update is installed only after: sha256 matches, the zip holds exactly one `.app`, its bundle ID and -version match, `SecStaticCodeCheckValidity` passes with a requirement pinned to the running app's -team, and `spctl --assess` accepts it (notarized). The old bundle is renamed into a same-volume -temporary folder, the new one moved into place (restored on failure; an administrator prompt is -used when the folder is not writable), and a detached shell waits for the process to exit before -reopening the app with `open -n`. LaunchServices can retain the old instance after its PID disappears; -requesting a new instance prevents the relaunch from being routed to that terminated process. -Downloads use URLSession's asynchronous download API with a task-level progress delegate. The system -coordinates task creation and cancellation, avoiding a task being created on an already invalidated -session. Cancellation is checked before verification and before replacement starts; replacement itself -must finish or roll back once begun. +not merged. The API selects the versioned sibling XML of the current JSON manifest's ZIP, with +`Cache-Control: no-store`, a ten-second upstream deadline and a 256 KiB read limit. It returns the +original signed bytes without holding a signing key. Publishing the existing manifest switches the +fixed feed immediately, while ZIP/XML CDN objects remain immutable. Old clients retain the JSON POST +protocol and existing installer. + +`SURequireSignedFeed` and `SUVerifyUpdateBeforeExtraction` require signed feeds and archive verification +before extraction. Inline notes, release date, manual DMG and full changelog links are part of the +signed XML. The driver checks the build, display version, URL, size and minimum OS against the user's +confirmed release before starting a new install or skip check. Sparkle owns the skip record through its +public user-choice callback; legacy skips and newly requested offline skips are held locally only until +the matching signed item can be passed through Sparkle's public skip reply. +Manual checks can still reveal skipped versions. Notification Center preferences, permission handling +and successful-submission deduplication remain in XStats; background discoveries never open a window. +A thin policy layer preserves launch-only success/retry semantics, quiet mode and calendar-month +intervals; Sparkle supplies the only check timer. Policy changes take effect through Observation. +The client no longer downloads the package twice to run a separate same-Team/Gatekeeper preflight. +Instead, release scripts enforce the expected Developer ID team, arm64, hardened runtime and secure +timestamps for the app, Widget, helper and every Sparkle executable, then notarize, staple and require +Gatekeeper acceptance before packaging. Public releases must keep these gates intact. + +Downloads expose progress in the existing update window with redraws coalesced to half-percent steps. +Checks and downloads can be cancelled; extraction/replacement must finish once started. Widget +processes belonging to this exact installation path are stopped before installation; other installations +are untouched. The controller owns the updater; its user driver keeps only a weak updater reference, +so failures, retries and controller teardown do not create a retain cycle. +Versioned ZIP and XML objects are uploaded and read back before either regional JSON API is published; +old clients continue using the unchanged JSON response and their existing installer for the transition. After an update the old helper may still be running; the app unregisters an outdated helper, re-registers the bundled version, and verifies the protocol before privileged calls resume. `server/api` is one Go program. Fiber exposes `POST /api/v1/update/check`, authenticated -`PUT /api/v1/releases/current`, and the aggregate `GET /stats` dashboard. GORM uses +`PUT /api/v1/releases/current`, the fixed signed `GET /api/v1/update/appcast.xml`, and the aggregate +`GET /stats` dashboard. GORM uses `github.com/libtnb/sqlite` with WAL and one database connection so concurrent checks cannot compete for SQLite's single writer. Each installation row stores only the SHA-256 installation ID, current version, first/last check times and check count; request IPs and monitoring data are not persisted. The release diff --git a/App/Info.plist b/App/Info.plist index 3b65a90..9a3a273 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -44,5 +44,17 @@ © 2026 XStats NSRemindersFullAccessUsageDescription 在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。 + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + + SUEnableSystemProfiling + + SUPublicEDKey + j/I8zd8BXz4oPldnhocEySXTMmXPxKMyBRwoA4ewLIk= + SURequireSignedFeed + + SUVerifyUpdateBeforeExtraction + diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ebc68ad..dac89d7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,7 +52,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 镜像发布到 `ghcr.io//xstats-server`,标签格式为清洗后的 `<分支>-<完整提交哈希>`。 构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 -有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。 +有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。 桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。 菜单栏日历提供“日期与星期”“日期与农历”“农历”“自定义”四种显示方式,自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。 @@ -105,7 +105,8 @@ CI 不参与发版:它只在 push 到 `main` 和 PR 时跑测试与构建校 正式分发需要 Apple Developer Program 的 **Developer ID Application** 证书、对应私钥, 以及 `notarytool` 公证凭据。发布脚本会构建 Apple Silicon 版本,检查签名团队、 -安全时间戳、Hardened Runtime、公证票据和 Gatekeeper,然后生成 DMG、在线升级包和 Homebrew cask。 +安全时间戳、Hardened Runtime、公证票据和 Gatekeeper,然后生成 DMG、在线升级包、Sparkle XML 和 Homebrew cask。 +Sparkle 框架及内置安装器/XPC 逐层裁剪为 arm64 并重签,不能只签框架外层。 先将公证凭据保存到钥匙串: @@ -115,6 +116,13 @@ xcrun notarytool store-credentials XStats \ --team-id YOUR_TEAM_ID ``` +在线更新使用 Sparkle 2.10.0。更新包和同名 `.xml` 清单由 Ed25519 私钥签名,公钥固定在 +`project.yml` 的 `SUPublicEDKey`。私钥存放在登录钥匙串账户 `work.12306.xstats.sparkle`, +发布脚本只读取已有账户并比对公钥,不生成或轮换密钥。更换发布机器前通过安全方式迁移并备份 +该私钥;不要写入仓库、CI 日志或聊天。可用 `SPARKLE_KEY_ACCOUNT` 选择已存在的账户, +`SPARKLE_TOOLS_DIR` 指向官方 `sign_update` / `generate_keys` 工具目录。 +客户端使用 Sparkle 官方签名信任链;同团队签名、公证和 Gatekeeper 在发布阶段强制检查。 + 然后发布: ```bash @@ -129,7 +137,7 @@ NOTARY_PROFILE=XStats task release 生成的包不应公开分发。 `release.sh` 开始时只允许版本元数据存在未提交改动,任何源码、脚本或新增文件都会中止构建; -随后在 `dist/release-provenance.json` 记录构建前提交、版本、构建号及版本文件哈希。 +随后在 `dist/release-provenance.json` 记录构建前提交、版本、构建号及版本文件和更新摘要译文哈希。 `publish_release.sh` 会验证最终提交只改了允许的版本元数据,且工作区、版本文件与构建记录一致, 避免安装包与 Git tag 对应源码不一致。 @@ -150,7 +158,7 @@ task release-all 打包完成后按顺序执行: ```bash -# 1. 检查 dist/ 产物:dmg、zip、appcast.json、xstats.rb +# 1. 检查 dist/ 产物:dmg、zip、同名 xml、appcast.json、xstats.rb ls dist/ # 2. 提交并推送版本改动——publish_release.sh 会校验 HEAD 与 origin/main 一致, @@ -164,7 +172,7 @@ git push ``` 发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。 -安装包放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;GitHub Release 只挂 dmg, +安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端原样代理版本文件,合并统计并禁止缓存。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg, 作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新, `scripts/sync_changelog.py` 不处理徽章。 @@ -181,7 +189,7 @@ git push - `Packages/XStatsKit/Sources/Metrics`:CPU、内存、网络、GPU、磁盘、电池、进程和传感器采集。 - `Packages/XStatsKit/Sources/AIUsage`:本机 Codex / Claude Code 会话日志的只读扫描、Token 统计与增量检查点;订阅额度在 AI 模块启用后自动查询,“显示本地用量”只控制日志扫描与本地 Token 展示。 - `Packages/XStatsKit/Sources/Cleaner`:清理规则、安全守卫、扫描和执行。 -- `Packages/XStatsKit/Sources/Updates`:版本清单、下载校验和应用替换。 +- `Packages/XStatsKit/Sources/Updates`:版本清单、安装能力与 Widget 升级协调;`XStatsUI/State/SparkleInstaller.swift` 将 Sparkle 检查、跳过和安装回调接入现有升级界面。 - `Packages/XStatsKit/Sources/HelperShared`:应用与辅助工具共用的 XPC 协议和维护命令。 - `Packages/XStatsKit/Sources/WebDAVSync`:WebDAV 同步和钥匙串密码存储。 - `Packages/XStatsKit/Sources/WidgetData`:主应用与桌面小组件共享的无凭据展示摘要;AI 额度沿用模块总开关,本地用量开关单独传给 Widget,旧摘要缺少新字段时沿用原有总开关。 @@ -190,6 +198,22 @@ git push - `Packages/XStatsKit/Sources/XStatsUI/Rest`:本机休息计时、幕布、Mini HUD 与合成声音。 - `Packages/XStatsKit/Tests`:Swift 单元测试。 +更新引擎:应用启动 10 秒后创建唯一的 Sparkle updater,由 Sparkle 调度检查、持久化跳过版本并负责下载、签名验证、安装与重启;XStats 不再设置每小时的独立检查 timer。薄策略层保留“启动时”成功后停止、失败每小时重试、“运行时静默”、每日/每周和自然月间隔;变更或恢复设置会更新 Sparkle 调度。自动下载、自动安装和系统分析保持关闭,只有点击安装才下载。新签名 XML 同步携带摘要、发布日期、DMG、完整更新日志及 ZIP 元数据;新客户端不再取未签名 JSON 作为检查结果。旧跳过记录与暂时离线的跳过操作在匹配签名版本时通过公开 skip 回调迁移(点击后立即静默,不要求网络成功);手动检查始终可查看跳过的版本。统计 ID、通知设置及去重、手动下载与设置备份保留,检查失败只串行尝试一个备用区域。 + +Sparkle 2.10 的配置 setter 即使值相同也会触发 KVO/reset,且 reset 会把更新源 URL 变化视为立即检查的理由(包括关闭自动检查时)。因此仅在配置实际变化时写入;区域回退结束后保留最后检查的 URL,下一轮在 `mayPerformUpdateCheck` 中复位到首选区域。修改此流程时必须验证双区域均失败后不连续请求,以及从不/启动策略不产生额外唤醒。 + +更新摘要只提供中英文:以 `CHANGELOG.md` 当前版本为中文主稿,`ReleaseNotes.json` 保存该版本的 `version`、生成后的中文 `sourceNotes` 及英文译文(`translations.en`)。每次发版同步修改英文译文,条目数量须与中文摘要一致;脚本会拒绝版本错配、过期源文案、缺失或空译文。不会联网自动翻译或回填历史日志。`sourceNotes` 可用 `scripts/appcast.py` 生成的 `dist/appcast.json` 的 `notes` 核对。 + +GitHub Release 正文同时包含 `中文` 与 `English` 两段:中文保留 `CHANGELOG.md` 对应版本的完整正文、分类、链接和升级提示,英文复用 `translations.en`,译文必须涵盖用户操作及兼容性注意事项。`scripts/github_release_notes.py <版本>` 可在发布前只读预览正文;发布脚本在上传前生成并校验内容,创建或重跑编辑 Release 时使用同一份双语正文,不自动修改历史 Release。 + +签名 XML 同时包含带 `xml:lang` 的 Sparkle 中英文标准描述和 `xstats:notes-zh-Hans`、`xstats:notes-en` 文本字段。后者保留 Sparkle 按系统偏好筛选前的中英文原文,更新窗口按当前应用语言即时显示:简体中文和繁体中文均显示中文,其余语言统一显示英文。摘要内容使用从左到右布局,界面按钮和标题仍跟随应用语言。全部摘要随整份 XML 签名,语言切换不新增请求;旧清单没有英文摘要时保留原摘要。旧 JSON 的 `notes` 继续保留中文;完整历史日志链接仍指向中文 CHANGELOG。 + +`ReleaseNotes.json` 属于允许的发布元数据;来源记录保存其构建时哈希,构建后任何译文变动都会阻止发布,必须重新执行构建和清单签名流程。 + +旧客户端兼容约束:保留 `POST /api/v1/update/check` 及其 JSON 顶层字段和类型(`build` 为字符串、`size` 为整数),`url` 始终指向同一份完整 ZIP,`sha256` 与 `size` 必须匹配该 ZIP;不能把旧接口改成 XML 或改指向 XML。ZIP 继续使用 `ditto --keepParent` 保留唯一顶层 `XStats.app`,所有嵌套代码使用原 Developer ID 团队签名并完成公证、装订。XML 获取失败不得影响 JSON 返回;发布包和签名 XML 全部就绪后才切换现有 JSON 发布清单。后端回归测试覆盖 XML 缺失、异常、超大和 HTTPS 降级时的旧 JSON 返回,发布脚本测试核对双区域接收到的完整 JSON 清单。 + +首个迁移版仍由旧 JSON 客户端安装,后续版本使用 Sparkle。发布验收需覆盖已签名且公证的旧版 → 迁移版 → 后续版、取消/失败重试、无写权限授权、Widget 与特权工具恢复;本地替身的连续升级测试不能代替公开版本的完整迁移验收。先部署双区域的固定 XML 入口,再发布首个 Sparkle 客户端;不要绕过 `publish_api.py --check-sparkle` 门禁。 + 更新提醒:通知设置中的“新版本更新”默认开启,沿用自动更新检查调度;后台发现未跳过的新版本时发送系统通知,不主动打开升级窗口。“运行时静默”与“从不”继续保持静默,手动检查仍显示更新窗口。仅在通知成功提交后记录最近提醒版本,跨重启去重;权限拒绝或发送失败不消耗提醒机会。点击通知打开更新窗口,重启后尚无清单时重新检查。通知偏好参与设置备份,提醒记录仅留在本机。 菜单栏绘图:每个状态项仅保存上一轮绘图读数,相关数值未变化时复用图像;历史风格继续比较历史数据,设置、语言、外观与布局变化必须使缓存失效。悬停提示独立更新。设置页实时预览在主窗口关闭或最小化时停止观察采样数据,快照渲染不受影响。验证需包含后台 CPU 对照,以及风格、充电状态和历史曲线的刷新边界。 diff --git a/LICENSES/Sparkle-License.txt b/LICENSES/Sparkle-License.txt new file mode 100644 index 0000000..0956949 --- /dev/null +++ b/LICENSES/Sparkle-License.txt @@ -0,0 +1,134 @@ +Copyright (c) 2006-2013 Andy Matuschak. +Copyright (c) 2009-2013 Elgato Systems GmbH. +Copyright (c) 2011-2014 Kornel Lesiński. +Copyright (c) 2015-2017 Mayur Pawashe. +Copyright (c) 2014 C.W. Betts. +Copyright (c) 2014 Petroules Corporation. +Copyright (c) 2014 Big Nerd Ranch. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================= +EXTERNAL LICENSES +================= + +bspatch.c and bsdiff.c, from bsdiff 4.3 : + +Copyright 2003-2005 Colin Percival +All rights reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +-- + +sais.c and sais.h, from sais-lite (2010/08/07) : + +The sais-lite copyright is as follows: + +Copyright (c) 2008-2010 Yuta Mori All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +-- + +Portable C implementation of Ed25519, from https://github.com/orlp/ed25519 + +Copyright (c) 2015 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In no event will the +authors be held liable for any damages arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, including commercial +applications, and to alter it and redistribute it freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim that you wrote the + original software. If you use this software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be misrepresented as + being the original software. + +3. This notice may not be removed or altered from any source distribution. + +-- + +SUSignatureVerifier.m: + +Copyright (c) 2011 Mark Hamlin. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSING.md b/LICENSING.md index 4a3c867..18306c5 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -27,3 +27,5 @@ XStats 本次确认的版权所有者署名为 **ysicing**,当前新增代码 4. 除明确标识的上游或第三方内容外,提交到本项目的新增代码和修改按 AGPL-3.0-or-later 提供;不要新增与此相冲突的默认 MIT 声明。 对于含 MIT 上游代码及 AGPL 修改的混合文件,保留上游版权并追加 `XStats modifications Copyright (C) 2026 ysicing`,使用 `AGPL-3.0-or-later AND MIT` 标识并说明各部分的许可范围。具体文件头示例见 [AGENTS.md](AGENTS.md)。 + +`ReleaseNotes.json` 中自行编写的更新摘要译文采用 AGPL-3.0-or-later。该 JSON 文件不支持注释,许可在本文件中记录。 diff --git a/Packages/XStatsKit/Package.swift b/Packages/XStatsKit/Package.swift index 9b5566c..ecbeb74 100644 --- a/Packages/XStatsKit/Package.swift +++ b/Packages/XStatsKit/Package.swift @@ -19,6 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/6tail/tyme4swift.git", exact: "1.5.0"), + .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.10.0"), ], targets: [ .target(name: "Localization", resources: [.process("Resources")]), @@ -31,7 +32,8 @@ let package = Package( .target(name: "WebDAVSync", dependencies: ["Localization"]), .target(name: "WidgetData"), .target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync", "WidgetData", - .product(name: "Tyme4Swift", package: "tyme4swift")], + .product(name: "Tyme4Swift", package: "tyme4swift"), + .product(name: "Sparkle", package: "Sparkle")], resources: [.copy("Resources/Flags"), .copy("Resources/Logos"), .copy("Resources/Legal")]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 66c8989..061cacd 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 正在查找残留… جارٍ البحث عن البقايا… 正在查询… جارٍ الاستعلام… 正在核对启动盘的文件系统结构,通常几秒到几十秒 جارٍ التحقق من بنية نظام ملفات قرص بدء التشغيل؛ يستغرق عادةً من ثوانٍ إلى دقيقة -正在核对校验值、开发者签名与 Apple 公证… جارٍ التحقق من المجموع الاختباري وتوقيع المطوّر وتوثيق Apple… +正在验证更新包与开发者签名… جارٍ التحقق من حزمة التحديث وتوقيع المطوّر… 正在检查… جارٍ الفحص… 正在检查更新 جارٍ البحث عن تحديثات 正在检测出口与分流… جارٍ فحص الخروج والتوجيه… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index a20388d..d3114ff 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py 正在查找残留… Restdateien werden gesucht … 正在查询… Wird abgefragt … 正在核对启动盘的文件系统结构,通常几秒到几十秒 Die Dateisystemstruktur des Startvolumes wird geprüft; dies dauert meist wenige Sekunden bis eine Minute -正在核对校验值、开发者签名与 Apple 公证… Prüfsumme, Entwicklersignatur und Apple-Notarisierung werden geprüft … +正在验证更新包与开发者签名… Updatepaket und Entwicklersignatur werden überprüft… 正在检查… Wird geprüft … 正在检查更新 Updates werden gesucht 正在检测出口与分流… Ausgänge und Routing werden geprüft … diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 0d04de7..c4e11ef 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes 正在查找残留… Buscando restos… 正在查询… Consultando… 正在核对启动盘的文件系统结构,通常几秒到几十秒 Verificando el sistema de archivos del disco de arranque; suele tardar entre unos segundos y un minuto -正在核对校验值、开发者签名与 Apple 公证… Verificando suma de verificación, firma del desarrollador y certificación de Apple… +正在验证更新包与开发者签名… Verificando el paquete de actualización y la firma del desarrollador… 正在检查… Comprobando… 正在检查更新 Buscando actualizaciones 正在检测出口与分流… Comprobando salida y enrutamiento… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 34d5c59..fa8ee97 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P 正在查找残留… Recherche de résidus… 正在查询… Recherche… 正在核对启动盘的文件系统结构,通常几秒到几十秒 Vérification de la structure du système de fichiers du disque de démarrage ; cela prend généralement quelques secondes à une minute -正在核对校验值、开发者签名与 Apple 公证… Vérification de la somme de contrôle, de la signature du développeur et de la notarisation Apple… +正在验证更新包与开发者签名… Vérification du paquet de mise à jour et de la signature du développeur… 正在检查… Vérification… 正在检查更新 Recherche de mises à jour 正在检测出口与分流… Vérification des sorties et du routage… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 01cbbb7..c4103df 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 正在查找残留… 残留項目を検索中… 正在查询… 検索中… 正在核对启动盘的文件系统结构,通常几秒到几十秒 起動ディスクのファイルシステムを確認しています。通常、数秒から1分ほどかかります -正在核对校验值、开发者签名与 Apple 公证… チェックサム、開発者署名、Apple公証を確認中… +正在验证更新包与开发者签名… 更新パッケージと開発者の署名を検証中… 正在检查… 確認中… 正在检查更新 アップデートを確認中 正在检测出口与分流… 出口と振り分けを確認中… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index b49d6e6..c032441 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -832,7 +832,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 正在查找残留… 잔여 항목 검색 중… 正在查询… 조회 중… 正在核对启动盘的文件系统结构,通常几秒到几十秒 시동 디스크의 파일 시스템 구조를 확인합니다. 보통 몇 초에서 1분 정도 걸립니다 -正在核对校验值、开发者签名与 Apple 公证… 체크섬, 개발자 서명, Apple 공증 확인 중… +正在验证更新包与开发者签名… 업데이트 패키지와 개발자 서명을 확인하는 중… 正在检查… 확인 중… 正在检查更新 업데이트 확인 중 正在检测出口与分流… 출구 및 라우팅 확인 중… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 24b6410..0bc701e 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -833,7 +833,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 正在查找残留… 正在查找殘留… 正在查询… 正在查詢… 正在核对启动盘的文件系统结构,通常几秒到几十秒 正在核對啓動盤的文件系統結構,通常幾秒到幾十秒 -正在核对校验值、开发者签名与 Apple 公证… 正在核對校驗值、開發者簽名與 Apple 公證… +正在验证更新包与开发者签名… 正在驗證更新套件與開發者簽章… 正在检查… 正在檢查… 正在检查更新 正在檢查更新 正在检测出口与分流… 正在檢測出口與分流… diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index b8180aa..b577fa8 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -949,7 +949,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 正在查找残留… Finding leftovers… 正在查询… Looking up… 正在核对启动盘的文件系统结构,通常几秒到几十秒 Verifying the startup disk's file system structure; this usually takes seconds to a minute -正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization… +正在验证更新包与开发者签名… Verifying update archive and developer signature… 正在检查… Checking… 正在检查更新 Checking for updates 正在检测出口与分流… Checking egress and routing… diff --git a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift index bffadcd..90a11c8 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift @@ -14,12 +14,15 @@ public struct UpdateRelease: Codable, Sendable, Equatable { public let dmg: URL? /// 最近更新的摘要,每条一句 public let notes: [String] + /// 已签名 XML 中的英文摘要;旧清单没有该字段时继续使用 notes。 + public let englishNotes: [String]? public let changelog: URL? /// Intel 版安装包。顶层的 url / sha256 / size / dmg 是 Apple 芯片版:0.3.0 只有 Apple 芯片版且只认顶层字段 public let intel: UpdateAsset? public init(version: String, build: String, date: String, minimumSystem: String, url: URL, sha256: String, - size: Int64, dmg: URL?, notes: [String], changelog: URL?, intel: UpdateAsset? = nil) { + size: Int64, dmg: URL?, notes: [String], changelog: URL?, intel: UpdateAsset? = nil, + englishNotes: [String]? = nil) { self.version = version self.build = build self.date = date @@ -29,9 +32,17 @@ public struct UpdateRelease: Codable, Sendable, Equatable { self.size = size self.dmg = dmg self.notes = notes + self.englishNotes = englishNotes self.changelog = changelog self.intel = intel } + + /// 中文语言统一显示中文,其余语言显示英文;旧清单没有英文时保留原摘要。 + public func notes(for languageCode: String) -> [String] { + if languageCode == "zh" || languageCode.hasPrefix("zh-") { return notes } + guard let englishNotes, !englishNotes.isEmpty else { return notes } + return englishNotes + } } public struct UpdateAsset: Codable, Sendable, Equatable { @@ -75,6 +86,13 @@ public enum UpdateArchitecture: Sendable, Equatable { } public enum UpdateFeed { + /// 固定 API 入口原样返回当前发布的签名 XML,同时记录检查;旧 JSON POST 协议保持可用。 + public static func sparkleURLs(prefersChina: Bool) -> [URL] { + checkURLs(prefersChina: prefersChina).map { + $0.deletingLastPathComponent().appendingPathComponent("appcast.xml") + } + } + private static let globalURL = URL(string: "https://xstats-apps.12306.work/api/v1/update/check")! private static let chinaURL = URL(string: "https://x-stats.china.12306.work/api/v1/update/check")! @@ -87,6 +105,14 @@ public enum UpdateFeed { prefersChina ? [chinaURL, globalURL] : [globalURL, chinaURL] } + /// 区域 API 沿用相同的应用与系统版本标识,不启用 Sparkle 系统分析。 + public static func userAgent(currentVersion: String, + operatingSystemVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> String { + var components = [operatingSystemVersion.majorVersion, operatingSystemVersion.minorVersion] + if operatingSystemVersion.patchVersion > 0 { components.append(operatingSystemVersion.patchVersion) } + return "XStats/\(currentVersion) (macOS \(components.map(String.init).joined(separator: ".")))" + } + public static func checkRequest( url: URL, currentVersion: String, @@ -106,10 +132,7 @@ public enum UpdateFeed { var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - var systemComponents = [operatingSystemVersion.majorVersion, operatingSystemVersion.minorVersion] - if operatingSystemVersion.patchVersion > 0 { systemComponents.append(operatingSystemVersion.patchVersion) } - let systemVersion = systemComponents.map(String.init).joined(separator: ".") - request.setValue("XStats/\(currentVersion) (macOS \(systemVersion))", forHTTPHeaderField: "User-Agent") + request.setValue(userAgent(currentVersion: currentVersion, operatingSystemVersion: operatingSystemVersion), forHTTPHeaderField: "User-Agent") request.httpBody = try JSONEncoder().encode(Body(currentVersion: currentVersion, installationID: installationID)) return request } @@ -131,7 +154,7 @@ public enum UpdateFeed { return UpdateRelease(version: release.version, build: release.build, date: release.date, minimumSystem: release.minimumSystem, url: intel.url, sha256: intel.sha256, size: intel.size, dmg: intel.dmg, notes: release.notes, changelog: release.changelog, - intel: intel) + intel: intel, englishNotes: release.englishNotes) } } diff --git a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift index 3d6f822..f1d5afc 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift @@ -1,4 +1,6 @@ -import CryptoKit +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + import Darwin import Foundation import Localization @@ -6,99 +8,20 @@ import Security public enum UpdateError: Error, LocalizedError, Equatable { case download(String) - case checksumMismatch - case archive(String) case invalidBundle(String) - case signature(String) - case gatekeeper case install(String) public var errorDescription: String? { switch self { case .download(let reason): tr("下载失败:\(reason)") - case .checksumMismatch: tr("安装包校验值与版本清单不一致,已放弃安装") - case .archive(let reason): tr("解压失败:\(reason)") case .invalidBundle(let reason): tr("安装包内容不正确:\(reason)") - case .signature(let reason): tr("签名校验未通过:\(reason)") - case .gatekeeper: tr("新版本未通过 Apple 公证检查,已放弃安装") case .install(let reason): tr("替换应用失败:\(reason)") } } } -/// 在线升级的各个步骤:下载 → 校验 sha256 → 解压 → 校验包名 / 版本 / 签名团队 / 公证 → 原地替换 → 重启 +/// 本机安装能力与 Widget 升级协调;下载、验证、替换和重启由 Sparkle 负责。 public enum UpdateInstaller { - // MARK: 下载 - - public static func download(_ url: URL, into directory: URL, - progress: @escaping @Sendable (Double) -> Void) async throws -> URL { - let destination = directory.appendingPathComponent(url.lastPathComponent) - try await Downloader(destination: destination, progress: progress).run(url) - return destination - } - - public static func sha256(of file: URL) throws -> String { - let handle = try FileHandle(forReadingFrom: file) - defer { try? handle.close() } - var hasher = SHA256() - while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { - hasher.update(data: chunk) - } - return hasher.finalize().map { String(format: "%02x", $0) }.joined() - } - - // MARK: 解压 - - /// ditto 解压,保留包内符号链接与扩展属性;返回解出的 .app - public static func extractApp(from archive: URL, into directory: URL) throws -> URL { - let output = directory.appendingPathComponent("extracted", isDirectory: true) - try? FileManager.default.removeItem(at: output) - try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - let result = run("/usr/bin/ditto", ["-x", "-k", archive.path, output.path]) - guard result.status == 0 else { throw UpdateError.archive(result.output) } - let apps = (try? FileManager.default.contentsOfDirectory(at: output, includingPropertiesForKeys: nil))? - .filter { $0.pathExtension == "app" } ?? [] - guard apps.count == 1, let app = apps.first else { throw UpdateError.archive(tr("压缩包里应当只有一个 .app")) } - return app - } - - // MARK: 校验 - - /// 包名、版本号、签名团队必须与预期一致,并通过严格签名校验与 Gatekeeper(公证)检查 - public static func verify(_ app: URL, bundleIdentifier: String, version: String, teamIdentifier: String, - architecture: UpdateArchitecture = .current) throws { - guard let bundle = Bundle(url: app) else { throw UpdateError.invalidBundle(tr("无法读取")) } - guard bundle.bundleIdentifier == bundleIdentifier else { - throw UpdateError.invalidBundle(tr("包名是 \(bundle.bundleIdentifier ?? tr("空"))")) - } - let bundleVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - guard bundleVersion == version else { - throw UpdateError.invalidBundle(tr("版本是 \(bundleVersion ?? tr("空")),清单写的是 \(version)")) - } - // Apple 芯片版与 Intel 版分开发布,装错了应用根本打不开 - guard bundle.executableArchitectures?.contains(where: { $0.intValue == architecture.executableArchitecture }) == true else { - throw UpdateError.invalidBundle(tr("安装包不支持这台 Mac 的芯片")) - } - - var staticCode: SecStaticCode? - guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else { - throw UpdateError.signature(tr("无法读取签名")) - } - let requirementText = "anchor apple generic and identifier \"\(bundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\"" - var requirement: SecRequirement? - guard SecRequirementCreateWithString(requirementText as CFString, [], &requirement) == errSecSuccess else { - throw UpdateError.signature(tr("无法创建签名要求")) - } - let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate) - let status = SecStaticCodeCheckValidity(code, flags, requirement) - guard status == errSecSuccess else { - throw UpdateError.signature(tr("不是团队 \(teamIdentifier) 签名的完整应用(\(status))")) - } - - let assessment = run("/usr/sbin/spctl", ["--assess", "--type", "execute", "-vv", app.path]) - guard assessment.status == 0, assessment.output.contains("accepted") else { throw UpdateError.gatekeeper } - } - /// 应用自身的签名团队;开发构建(未签名或临时签名)返回 nil public static func teamIdentifier(of app: URL) -> String? { var staticCode: SecStaticCode? @@ -109,28 +32,6 @@ public enum UpdateInstaller { return dictionary[kSecCodeInfoTeamIdentifier as String] as? String } - // MARK: 替换 - - /// 旧版先改名为备份,再把新版移到原位置;移动失败时还原旧版。两者在同一卷上,改名是原子的。 - public static func replace(_ current: URL, with candidate: URL, backupDirectory: URL) throws { - try stopWidgetExtension(in: current) - let manager = FileManager.default - let backup = backupDirectory.appendingPathComponent("previous-\(current.lastPathComponent)") - try? manager.removeItem(at: backup) - do { - try manager.moveItem(at: current, to: backup) - } catch { - throw UpdateError.install(error.localizedDescription) - } - do { - try manager.moveItem(at: candidate, to: current) - } catch { - try? manager.moveItem(at: backup, to: current) - throw UpdateError.install(error.localizedDescription) - } - try? manager.removeItem(at: backup) - } - /// 仅结束属于这份应用的旧小组件进程,避免替换后 WidgetKit 继续向旧进程索取组件清单。 public static func stopWidgetExtension(in app: URL) throws { let executable = app.appendingPathComponent("Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget").path @@ -160,93 +61,4 @@ public enum UpdateInstaller { } } } - - /// 当前用户能否直接改写应用所在目录(管理员账户对 /Applications 通常可以) - public static func canReplaceInPlace(_ app: URL) -> Bool { - let parent = app.deletingLastPathComponent().path - return FileManager.default.isWritableFile(atPath: parent) && FileManager.default.isWritableFile(atPath: app.path) - } - - /// 等当前进程退出后重新打开应用。路径与 PID 作为位置参数传入,不拼进脚本 - public static func relaunch(_ app: URL, afterExitOf pid: Int32 = ProcessInfo.processInfo.processIdentifier) throws { - try makeRelaunchProcess(app, afterExitOf: pid).run() - } - - /// 构造独立的退出后启动进程;可替换 opener 以测试等待时序与参数传递。 - static func makeRelaunchProcess(_ app: URL, afterExitOf pid: Int32, - opener: URL = URL(fileURLWithPath: "/usr/bin/open")) -> Process { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - // PID 消失后 LaunchServices 仍可能保留旧实例记录;-n 明确启动新版,避免激活已退出的旧进程。 - process.arguments = ["-c", "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; exec \"$3\" -n \"$2\"", - "xstats-relaunch", String(pid), app.path, opener.path] - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - return process - } - - // MARK: 工具 - - @discardableResult - static func run(_ executable: String, _ arguments: [String]) -> (status: Int32, output: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - try process.run() - } catch { - return (-1, error.localizedDescription) - } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return (process.terminationStatus, String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) - } -} - -/// 带进度的下载:交由系统异步 API 协调任务创建和取消,完成后移到目标位置。 -final class Downloader: NSObject, URLSessionDownloadDelegate, Sendable { - private let destination: URL - private let progress: @Sendable (Double) -> Void - - init(destination: URL, progress: @escaping @Sendable (Double) -> Void) { - self.destination = destination - self.progress = progress - } - - func run(_ url: URL, configuration: URLSessionConfiguration = .ephemeral) async throws { - try Task.checkCancellation() - configuration.timeoutIntervalForRequest = 60 - configuration.timeoutIntervalForResource = 15 * 60 - let session = URLSession(configuration: configuration) - defer { session.finishTasksAndInvalidate() } - do { - // 原来的取消回调可能先 invalidate Session,再创建任务,触发无法捕获的原生异常。 - // 系统 API 自行协调这个时序,任务级 delegate 仍负责进度反馈。 - let (location, response) = try await session.download(from: url, delegate: self) - try Task.checkCancellation() - if let response = response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) { - throw UpdateError.download(tr("服务器返回 \(response.statusCode)")) - } - try? FileManager.default.removeItem(at: destination) - try FileManager.default.moveItem(at: location, to: destination) - try Task.checkCancellation() - } catch { - if Task.isCancelled { throw CancellationError() } - throw error as? UpdateError ?? UpdateError.download(error.localizedDescription) - } - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, - totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - guard totalBytesExpectedToWrite > 0 else { return } - progress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - // 协议要求实现此方法;临时文件由系统 async 返回值交给 run 统一移动。 - } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index cea98ed..bf07ad3 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -26,7 +26,6 @@ public final class AppController: NSObject, NSApplicationDelegate { private var rest: RestController { model.rest } private var restWindows: RestWindowController! private var restMenuBar: RestMenuBarController! - private var updateTimer: Timer? private var widgetTimer: Timer? private let hotKeys = HotKeyCenter() private var workspaceObservers: [NSObjectProtocol] = [] @@ -261,7 +260,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } } - /// 启动 10 秒后按策略检查,运行中每小时判断周期是否到期。 + /// 启动 10 秒后启动 Sparkle,由它统一安排后续检查。 private func startUpdateChecks() { model.updates.onPrompt = { [weak self] in self?.menuBar.dismissPopovers() @@ -281,12 +280,8 @@ public final class AppController: NSObject, NSApplicationDelegate { } } DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in - self?.model.updates.checkIfNeeded() + self?.model.updates.start() } - updateTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.model.updates.checkIfNeeded() } - } - updateTimer?.tolerance = 5 * 60 } /// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/UpdateViews.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/UpdateViews.swift index 173a0c1..9547023 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/UpdateViews.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/UpdateViews.swift @@ -10,6 +10,7 @@ import Updates /// 新版本信息:版本号、日期、体积与更新摘要,升级提示窗口与“关于”页共用 struct ReleaseSummary: View { + @Environment(AppModel.self) private var model let release: UpdateRelease let currentVersion: String /// 升级提示窗口里限制摘要区高度,超出时滚动;关于页里完整展开 @@ -23,6 +24,7 @@ struct ReleaseSummary: View { Text(tr("更新内容")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary) notes } + .environment(\.layoutDirection, model.settings.language.resolved.isRightToLeft ? .rightToLeft : .leftToRight) } private var header: some View { @@ -43,13 +45,16 @@ struct ReleaseSummary: View { @ViewBuilder private var notes: some View { let list = VStack(alignment: .leading, spacing: DS.Space.s2) { - ForEach(Array(release.notes.enumerated()), id: \.offset) { _, note in + ForEach(Array(release.notes(for: model.settings.language.resolved.languageCode).enumerated()), id: \.offset) { _, note in HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1 + DS.Space.s1 / 2, height: DS.Space.s1 + DS.Space.s1 / 2) .alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + DS.Space.s1 } Text(note).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary) .fixedSize(horizontal: false, vertical: true) } + // 更新摘要只使用中英文,在阿拉伯语界面中也沿用从左到右的阅读方向。 + .frame(maxWidth: .infinity, alignment: .leading) + .environment(\.layoutDirection, .leftToRight) } if let changelog = release.changelog { Button { NSWorkspace.shared.open(changelog) } label: { @@ -90,8 +95,10 @@ struct UpdateProgress: View { } ProgressTrack(fraction: fraction) } + case .checking: + InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("正在检查更新")) case .verifying: - InfoBanner(icon: "checkmark.shield", text: tr("正在核对校验值、开发者签名与 Apple 公证…")) + InfoBanner(icon: "checkmark.shield", text: tr("正在验证更新包与开发者签名…")) case .installing: InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("正在安装,完成后 XStats 会自动重启。")) case .failed(let message): @@ -136,7 +143,7 @@ struct UpdatePromptView: View { .keyboardShortcut(.defaultAction) } } - .disabled(updates.phase == .verifying || updates.phase == .installing) + .disabled(updates.isBusy && !updates.isDownloading) } .padding(.horizontal, DS.Space.s6) .padding(.bottom, DS.Space.s6) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 2b0c950..ca4084f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -429,22 +429,31 @@ public enum UpdateCheckSchedule: String, CaseIterable, Identifiable, Sendable { var promptsForUpdates: Bool { self != .quietRuntime && self != .never } - /// `launchedAt` 为本次运行的启动时刻。“启动时”策略在本次运行成功检查前一直待办, - /// 开机登录时网络常未就绪,首次失败后由每小时的定时器继续重试。 + /// 返回交给 Sparkle 的下次检查间隔;从不和本次启动已检查过时不注册计时器。 + func sparkleInterval(lastChecked: Date?, now: Date, launchedAt: Date, retry: Bool = false) -> TimeInterval? { + guard let next = nextCheckDate(lastChecked: lastChecked, launchedAt: launchedAt) else { return nil } + return retry ? 60 * 60 : max(60 * 60, next.timeIntervalSince(now)) + } + + /// 开机网络未就绪时,启动检查在本次成功之前始终待办,由 Sparkle 每小时重试。 func shouldCheck(lastChecked: Date?, now: Date, launchedAt: Date) -> Bool { + guard let next = nextCheckDate(lastChecked: lastChecked, launchedAt: launchedAt) else { return false } + return now >= next + } + + /// 到期判断和 Sparkle 调度共用同一日期规则,避免自然月与启动策略产生两套语义。 + private func nextCheckDate(lastChecked: Date?, launchedAt: Date) -> Date? { switch self { - case .never: return false - case .atLaunch: return lastChecked.map { $0 < launchedAt } ?? true + case .never: return nil + case .atLaunch: return lastChecked.map { $0 >= launchedAt } == true ? nil : .distantPast case .quietRuntime, .daily, .weekly, .monthly: - guard let lastChecked else { return true } - let next: Date + guard let lastChecked else { return .distantPast } switch self { - case .quietRuntime, .daily: next = lastChecked.addingTimeInterval(24 * 60 * 60) - case .weekly: next = lastChecked.addingTimeInterval(7 * 24 * 60 * 60) - case .monthly: next = Calendar.current.date(byAdding: .month, value: 1, to: lastChecked) ?? lastChecked - case .atLaunch, .never: return false + case .quietRuntime, .daily: return lastChecked.addingTimeInterval(24 * 60 * 60) + case .weekly: return lastChecked.addingTimeInterval(7 * 24 * 60 * 60) + case .monthly: return Calendar.current.date(byAdding: .month, value: 1, to: lastChecked) ?? lastChecked + case .atLaunch, .never: return nil } - return now >= next } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift new file mode 100644 index 0000000..4142fa9 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift @@ -0,0 +1,303 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Localization +import Sparkle +import Updates + +/// Sparkle 的单一更新流程;复用 XStats 界面和系统通知,不自动下载或安装。 +@MainActor +final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate { + enum Action { case install(UpdateRelease), skip(UpdateRelease) } + + private let bundle: Bundle + private let onPhase: (UpdateController.Phase) -> Void + private let onRelaunch: () -> Void + var onRelease: (UpdateRelease, Bool) -> Void = { _, _ in } + var onChecked: () -> Void = {} + var onNoUpdate: () -> Void = {} + var onCycleFinished: (Bool) -> Void = { _ in } + var legacySkippedVersion: () -> String? = { nil } + var onLegacySkipMigrated: () -> Void = {} + + // 控制器强持有 updater,Sparkle 强持有 userDriver;此处只能弱引用,避免循环持有。 + private weak var updater: SPUUpdater? + private var action: Action? + private var cancellation: (() -> Void)? + private var cancelled = false + private var loadedFeed = false + private var userInitiated = false + private var endpointIndex = 0 + private var retryingFeed = false + private let endpoints: [URL] + private var expectedLength: UInt64 = 0 + private var receivedLength: UInt64 = 0 + private var lastProgress: Double = 0 + + var isRunning: Bool { updater?.sessionInProgress == true } + + init(bundle: Bundle = .main, + endpoints: [URL] = UpdateFeed.sparkleURLs(prefersChina: UpdateFeed.prefersChinaEndpoint()), + onPhase: @escaping (UpdateController.Phase) -> Void, onRelaunch: @escaping () -> Void) { + self.bundle = bundle + self.endpoints = endpoints + self.onPhase = onPhase + self.onRelaunch = onRelaunch + } + + /// 调用方保留返回值。整个应用只创建一个 updater,不在每次检查或重试时创建 XPC 引擎。 + func start() throws -> SPUUpdater { + if let updater { return updater } + let updater = SPUUpdater(hostBundle: bundle, applicationBundle: bundle, userDriver: self, delegate: self) + self.updater = updater + updater.automaticallyChecksForUpdates = false + updater.automaticallyDownloadsUpdates = false + updater.sendsSystemProfile = false + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + updater.userAgentString = UpdateFeed.userAgent(currentVersion: version) + try updater.start() + return updater + } + + func check(userInitiated: Bool, action: Action? = nil) { + guard !isRunning else { return } + self.action = action + cancelled = false + loadedFeed = false + endpointIndex = 0 + retryingFeed = false + self.userInitiated = userInitiated + if userInitiated || action != nil { updater?.checkForUpdates() } + else { updater?.checkForUpdatesInBackground() } + } + + func cancel() { + guard let cancellation else { return } + cancelled = true + self.cancellation = nil + cancellation() + onPhase(.available) + } + + func feedURLString(for updater: SPUUpdater) -> String? { + endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex].absoluteString : nil + } + + @objc(feedParametersForUpdater:sendingSystemProfile:) + func feedParameters(for updater: SPUUpdater, sendingSystemProfile: Bool) -> [[String: String]] { + guard let identifier = try? InstallationIdentity().hashedID() else { return [] } + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + return [["key": "installation_id", "value": identifier], ["key": "current_version", "value": version]] + } + + @objc(updater:mayPerformUpdateCheck:error:) + func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { + // 自动调度进入新周期时复位;区域重试保留本周期的用户意图和确认版本。 + if !retryingFeed { + endpointIndex = 0 + cancelled = false + loadedFeed = false + if updateCheck == .updatesInBackground { userInitiated = false; action = nil } + } + retryingFeed = false + onPhase(.checking) + } + + @objc(updaterShouldPromptForPermissionToCheckForUpdates:) + func updaterShouldPromptForPermissionToCheck(forUpdates updater: SPUUpdater) -> Bool { false } + + @objc(updater:shouldDownloadReleaseNotesForUpdate:) + func updater(_ updater: SPUUpdater, shouldDownloadReleaseNotesForUpdate updateItem: SUAppcastItem) -> Bool { false } + + @objc(updater:didFinishLoadingAppcast:) + func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { + loadedFeed = true + onChecked() + } + + @objc(updaterDidNotFindUpdate:error:) + func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + guard !cancelled else { return } + let info = (error as NSError).userInfo + if let reason = info[SPUNoUpdateFoundReasonKey] as? NSNumber, + reason.int32Value == SPUNoUpdateFoundReason.systemIsTooOld.rawValue, + let item = info[SPULatestAppcastItemFoundKey] as? SUAppcastItem, + let release = try? Self.release(from: item) { + onRelease(release, userInitiated) + onPhase(.available) + } else { + onNoUpdate() + let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" + let latest = info[SPULatestAppcastItemFoundKey] as? SUAppcastItem + // “没有可提醒的更新”还可能是用户跳过了新版,不能误显示“已是最新版本”。 + onPhase(latest.map { UpdateFeed.isNewer($0.versionString, than: build) } == true ? .idle : .upToDate) + } + } + + @objc(updater:shouldProceedWithUpdate:updateCheck:error:) + func updater(_ updater: SPUUpdater, shouldProceedWithUpdate updateItem: SUAppcastItem, + updateCheck: SPUUpdateCheck) throws { + let candidate = try Self.release(from: updateItem) + // 检查与用户点击之间可能已发布下一版;安装/跳过只能作用于刚确认的那个包。 + if let action { + let expected: UpdateRelease + switch action { case .install(let release), .skip(let release): expected = release } + guard candidate.build == expected.build, candidate.version == expected.version, + candidate.url == expected.url, candidate.size == expected.size, + candidate.minimumSystem == expected.minimumSystem else { + throw UpdateError.invalidBundle(tr("版本清单格式不正确")) + } + } + } + + static func release(from item: SUAppcastItem) throws -> UpdateRelease { + guard let url = item.fileURL, url.scheme == "https", url.pathExtension == "zip", + item.contentLength > 0, item.contentLength <= UInt64(Int64.max), + let build = UInt64(item.versionString), build > 0, + item.installationType == "application", !item.isInformationOnlyUpdate else { + throw UpdateError.invalidBundle(tr("版本清单格式不正确")) + } + // 这些扩展字段和摘要都来自已验证签名的 XML,不能另从未签名 JSON 补入安装元数据。 + let properties = item.propertiesDictionary + let dmg = (properties["xstats:dmg"] as? String).flatMap(URL.init(string:)).flatMap { $0.scheme == "https" ? $0 : nil } + // Sparkle 会按系统偏好筛选 description,应用语言所需的中英文保留在签名扩展字段中。 + let notes = ((properties["xstats:notes-zh-Hans"] as? String) ?? item.itemDescription)? + .components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? [] + let englishNotes = (properties["xstats:notes-en"] as? String)? + .components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + return UpdateRelease(version: item.displayVersionString, build: item.versionString, + date: properties["xstats:date"] as? String ?? "", + minimumSystem: item.minimumSystemVersion ?? "14.0", url: url, + sha256: properties["xstats:sha256"] as? String ?? "", size: Int64(item.contentLength), + dmg: dmg, notes: notes, changelog: item.fullReleaseNotesURL, + englishNotes: englishNotes) + } + + @objc(updaterWillRelaunchApplication:) + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { onRelaunch() } + + @objc(updater:didFinishUpdateCycleForUpdateCheck:error:) + func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) { + cancellation = nil + // 只在尚未收到有效签名 feed 时串行尝试备用区域;下载/安装失败绝不重启安装。 + if error != nil, !loadedFeed, !cancelled, endpointIndex == 0, endpoints.count > 1 { + endpointIndex = 1 + retryingFeed = true + if userInitiated || action != nil { updater.checkForUpdates() } + else { updater.checkForUpdatesInBackground() } + return + } + if case .skip? = action { + // 跳过点击已在本机排队;网络失败不能撤销选择或转为安装错误。 + onPhase(.available) + } else if let error, !cancelled { + let failure = error as NSError + if failure.domain != SUSparkleErrorDomain || failure.code != SUError.noUpdateError.rawValue { + onPhase(userInitiated || action != nil ? .failed(error.localizedDescription) : .idle) + } + } + // 结束后必须保留本轮最后检查的地址。Sparkle 的配置 reset 会把 URL 变化 + // 解释成用户更换更新源,立即检查(即使自动检查关闭)。下一轮真正开始时再复位。 + retryingFeed = false + action = nil + onCycleFinished(loadedFeed) + } + + func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) { + reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false)) + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { self.cancellation = cancellation } + + func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, + reply: @escaping (SPUUserUpdateChoice) -> Void) { + cancellation = nil + guard !cancelled, let release = try? Self.release(from: appcastItem) else { reply(.dismiss); return } + // Sparkle 也可能恢复已验证的缓存条目;后续安装失败不能作为更新源失败重试。 + loadedFeed = true + if let action { + switch action { + case .install: reply(.install) + case .skip: + reply(.skip) + if release.version == legacySkippedVersion() { onLegacySkipMigrated() } + } + } else if !userInitiated, release.version == legacySkippedVersion() { + // 兼容记录和离线排队只有展示版本号;遇到签名条目后通过公开 reply 交给 Sparkle。 + reply(.skip) + onLegacySkipMigrated() + // 跳过新版只结束提醒流程,当前安装仍是旧版。 + onPhase(.idle) + } else { + onPhase(.available) + onRelease(release, userInitiated) + // 后台通知无需长时间悬挂一个更新会话;安装按钮会重新核验同一份签名元数据。 + reply(.dismiss) + } + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {} + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {} + func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { acknowledgement() } + func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { acknowledgement() } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + loadedFeed = true + self.cancellation = cancellation + expectedLength = 0 + receivedLength = 0 + lastProgress = 0 + if cancelled { self.cancellation = nil; cancellation(); return } + onPhase(.downloading(0)) + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + expectedLength = expectedContentLength + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + guard !cancelled, expectedLength > 0 else { return } + let addition = receivedLength.addingReportingOverflow(length) + receivedLength = addition.overflow ? expectedLength : min(expectedLength, addition.partialValue) + let progress = Double(receivedLength) / Double(expectedLength) + // 网络块可能很小,最多每半个百分点重绘一次进度,完成时补齐最后一帧。 + guard progress > lastProgress, progress == 1 || progress - lastProgress >= 0.005 else { return } + lastProgress = progress + onPhase(.downloading(progress)) + } + + func showDownloadDidStartExtractingUpdate() { + cancellation = nil + if !cancelled { onPhase(.verifying) } + } + + func showExtractionReceivedProgress(_ progress: Double) {} + + func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + guard !cancelled else { reply(.skip); return } + onPhase(.installing) + let app = bundle.bundleURL + Task { + do { + // Widget 继续使用旧可执行文件会使升级后的组件刷新失败,仅停止本安装路径的进程。 + try await Task.detached(priority: .utility) { try UpdateInstaller.stopWidgetExtension(in: app) }.value + reply(.install) + } catch { + onPhase(.failed(error.localizedDescription)) + reply(.skip) + } + } + } + + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, + retryTerminatingApplication: @escaping () -> Void) { + if !cancelled { onPhase(.installing) } + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + acknowledgement() + } + + func dismissUpdateInstallation() { cancellation = nil } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index 26e693f..373a352 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -2,9 +2,10 @@ import AppKit import Foundation import Localization import Observation +import Sparkle import Updates -/// 在线升级:检查官网版本清单,发现新版时提示摘要,一键下载、校验、原地替换并重启 +/// 在线升级:Sparkle 管理检查、跳过和安装,XStats 提供现有界面与系统通知。 @MainActor @Observable public final class UpdateController { @@ -32,14 +33,14 @@ public final class UpdateController { @ObservationIgnored var onPrompt: () -> Void = {} /// 后台发现新版本时请求系统通知;成功提交后才记录去重状态。 @ObservationIgnored var onUpdateAvailable: (String) async -> Bool = { _ in false } - /// 安装完成后退出应用,等进程结束再打开新版 - @ObservationIgnored var terminate: () -> Void = { NSApp.terminate(nil) } @ObservationIgnored private let settings: AppSettings @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private var task: Task? + @ObservationIgnored private var updater: SPUUpdater? + @ObservationIgnored private var sparkleInstaller: SparkleInstaller? @ObservationIgnored private let launchedAt: Date @ObservationIgnored private var isNotifying = false + @ObservationIgnored private var started = false init(settings: AppSettings, defaults: UserDefaults = .standard, launchedAt: Date = Date()) { self.settings = settings @@ -47,6 +48,11 @@ public final class UpdateController { self.launchedAt = launchedAt lastChecked = defaults.object(forKey: Keys.lastChecked) as? Date skippedVersion = defaults.string(forKey: Keys.skippedVersion) + // Sparkle 公开记录键:首轮迁移继承旧成功时间,避免“刚检查过”被当成首次检查。 + // 只初始化一次,之后不覆盖 Sparkle 自己管理的检查时间。 + if defaults.object(forKey: "SULastCheckTime") == nil, let lastChecked { + defaults.set(lastChecked, forKey: "SULastCheckTime") + } } var currentVersion: String { @@ -60,9 +66,14 @@ public final class UpdateController { } } - /// 开发构建没有 Developer ID 签名,无法校验新版的签名团队,只能手动安装 + var isDownloading: Bool { + if case .downloading = phase { return true } + return false + } + + /// 未签名或临时签名的开发构建不执行在线安装,只提供手动下载 var installBlockedReason: String? { - // 更新源暂不迁移,但安装包仍必须匹配 XStats 身份,不能放宽为接受旧产品。 + // 仅为当前产品启用安装器,防止开发/旧产品构建误用发布更新源。 guard let bundleID = Bundle.main.bundleIdentifier, bundleID == "work.12306.xstats.app" else { return tr("开发构建不支持在线升级") } guard UpdateInstaller.teamIdentifier(of: Bundle.main.bundleURL) != nil else { return tr("开发构建不支持在线升级,请下载安装包") } return nil @@ -70,38 +81,83 @@ public final class UpdateController { // MARK: 检查 - /// 启动时或运行期间按用户选择的策略检查;失败后由后续调度重试。 + /// 启动后创建唯一的 Sparkle 调度器;不另设 XStats 轮询定时器。 + func start() { + guard !started else { return } + do { + try ensureUpdater() + started = true + applySchedule(initial: true) + observeSchedule() + checkIfNeeded() + } catch { + phase = .failed(error.localizedDescription) + } + } + + private func ensureUpdater() throws { + guard updater == nil else { return } + let driver = SparkleInstaller(onPhase: { [weak self] in self?.phase = $0 }, + onRelaunch: { [weak self] in self?.skippedVersion = nil }) + driver.onChecked = { [weak self] in self?.lastChecked = Date() } + driver.onNoUpdate = { [weak self] in self?.release = nil } + driver.onRelease = { [weak self] release, manual in + guard let self else { return } + self.release = release + Task { [weak self] in + await self?.announceUpdate(version: release.version, userInitiated: manual, + suppressPrompt: !(self?.settings.updateCheckSchedule.promptsForUpdates ?? false)) + } + } + driver.legacySkippedVersion = { [weak self] in self?.skippedVersion } + driver.onLegacySkipMigrated = { [weak self] in self?.skippedVersion = nil } + driver.onCycleFinished = { [weak self] success in self?.applySchedule(retry: !success) } + sparkleInstaller = driver + updater = try driver.start() + } + + private func observeSchedule() { + withObservationTracking { + _ = settings.updateCheckSchedule + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.applySchedule(initial: true) + self?.observeSchedule() + self?.checkIfNeeded() + } + } + } + + /// Sparkle 的 timer 负责唤醒,薄策略层只保留启动检查、成功后间隔和自然月语义。 + private func applySchedule(initial: Bool = false, retry: Bool = false) { + guard let updater else { return } + let now = Date() + guard let interval = settings.updateCheckSchedule.sparkleInterval(lastChecked: lastChecked, now: now, + launchedAt: launchedAt, retry: retry) else { + if updater.automaticallyChecksForUpdates { updater.automaticallyChecksForUpdates = false } + return + } + // 首次配置会按 Sparkle 的上次尝试时间减去 elapsed;补偿该值,保持旧成功记录的到期日。 + let elapsed = initial ? max(0, now.timeIntervalSince(updater.lastUpdateCheckDate ?? now)) : 0 + let nextInterval = max(60 * 60, interval + elapsed) + // Sparkle 的 setter 会触发 KVO/reset,即使值相同;无变化时不要重新安排周期。 + if abs(updater.updateCheckInterval - nextInterval) >= 1 { updater.updateCheckInterval = nextInterval } + if !updater.automaticallyChecksForUpdates { updater.automaticallyChecksForUpdates = true } + } + func checkIfNeeded() { let schedule = settings.updateCheckSchedule guard !isBusy, schedule.shouldCheck(lastChecked: lastChecked, now: Date(), launchedAt: launchedAt) else { return } - check(userInitiated: false, suppressPrompt: !schedule.promptsForUpdates) + check(userInitiated: false) } - func check(userInitiated: Bool, suppressPrompt: Bool = false) { - guard !isBusy else { return } - phase = .checking - task = Task { - let result = await Self.fetch(currentVersion: currentVersion) - switch result { - case .success(let feed): - // 只有拿到清单才算检查过:登录时网络常常还没连上,失败后由每小时的定时器重试,而不是等一整天 - lastChecked = Date() - // 清单里没有这台 Mac 芯片的安装包时当作没有新版本 - guard let latest = UpdateFeed.release(feed, for: .current), - UpdateFeed.isNewer(latest.version, than: currentVersion) else { - release = nil - phase = .upToDate - return - } - release = latest - phase = .available - Log.update.notice("发现新版本 \(latest.version, privacy: .public)") - await announceUpdate(version: latest.version, userInitiated: userInitiated, suppressPrompt: suppressPrompt) - case .failure(let error): - Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)") - // 自动检查失败不打扰用户,只在关于页里显示 - phase = userInitiated ? .failed(error.localizedDescription) : (release == nil ? .idle : .available) - } + func check(userInitiated: Bool) { + guard !isBusy, sparkleInstaller?.isRunning != true else { return } + do { + try ensureUpdater() + sparkleInstaller?.check(userInitiated: userInitiated) + } catch { + phase = userInitiated ? .failed(error.localizedDescription) : .idle } } @@ -120,48 +176,6 @@ public final class UpdateController { if await onUpdateAvailable(version) { defaults.set(version, forKey: Keys.notifiedVersion) } } - private static func fetch(currentVersion: String) async -> Result { - do { - let installationID = try InstallationIdentity().hashedID() - return await fetch( - currentVersion: currentVersion, - installationID: installationID, - prefersChina: UpdateFeed.prefersChinaEndpoint() - ) { try await URLSession.shared.data(for: $0) } - } catch { - return .failure(.download(error.localizedDescription)) - } - } - - static func fetch( - currentVersion: String, - installationID: String, - prefersChina: Bool, - send: (URLRequest) async throws -> (Data, URLResponse) - ) async -> Result { - var lastError = UpdateError.download(tr("没有可用的更新服务")) - for endpoint in UpdateFeed.checkURLs(prefersChina: prefersChina) { - do { - let request = try UpdateFeed.checkRequest( - url: endpoint, currentVersion: currentVersion, installationID: installationID - ) - let (data, response) = try await send(request) - if let http = response as? HTTPURLResponse, http.statusCode != 200 { - lastError = .download(tr("服务器返回 \(http.statusCode)")) - continue - } - guard let release = UpdateFeed.parse(data) else { - lastError = .download(tr("版本清单格式不正确")) - continue - } - return .success(release) - } catch { - lastError = .download(error.localizedDescription) - } - } - return .failure(lastError) - } - /// 截图用:直接放入一个示例版本 func showPreview(_ release: UpdateRelease) { self.release = release @@ -169,13 +183,21 @@ public final class UpdateController { } func skipCurrentRelease() { - skippedVersion = release?.version + guard let release, !isBusy else { return } + // 点击后立即生效,即使此时离线;签名条目重新可达时再交给 Sparkle 持久化。 + skippedVersion = release.version + do { + try ensureUpdater() + sparkleInstaller?.check(userInitiated: false, action: .skip(release)) + } catch { + phase = .failed(error.localizedDescription) + } } // MARK: 安装 func install() { - guard let release, !isBusy else { return } + guard let release, !isBusy, sparkleInstaller?.isRunning != true else { return } if let reason = installBlockedReason { phase = .failed(reason) return @@ -184,61 +206,15 @@ public final class UpdateController { phase = .failed(tr("新版本需要 macOS \(release.minimumSystem) 或更高版本")) return } - let app = Bundle.main.bundleURL - guard let team = UpdateInstaller.teamIdentifier(of: app) else { return } - let bundleID = Bundle.main.bundleIdentifier ?? "" - - phase = .downloading(0) - task = Task { - do { - // 临时目录与应用在同一卷上,替换时是原子改名 - let work = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, - appropriateFor: app, create: true) - defer { try? FileManager.default.removeItem(at: work) } - - let archive = try await UpdateInstaller.download(release.url, into: work) { fraction in - Task { @MainActor in - guard case .downloading = self.phase else { return } - self.phase = .downloading(fraction) - } - } - try Task.checkCancellation() - phase = .verifying - let candidate = try await Task.detached { - guard try UpdateInstaller.sha256(of: archive) == release.sha256.lowercased() else { - throw UpdateError.checksumMismatch - } - let candidate = try UpdateInstaller.extractApp(from: archive, into: work) - try UpdateInstaller.verify(candidate, bundleIdentifier: bundleID, version: release.version, teamIdentifier: team) - return candidate - }.value - - // 替换开始前兑现取消请求;一旦开始替换,必须完整执行或回滚。 - try Task.checkCancellation() - phase = .installing - if UpdateInstaller.canReplaceInPlace(app) { - try await Task.detached { try UpdateInstaller.replace(app, with: candidate, backupDirectory: work) }.value - } else { - try await Task.detached { try UpdateInstaller.stopWidgetExtension(in: app) }.value - try await replaceWithAdministratorPrompt(app, with: candidate) - } - skippedVersion = nil - Log.update.notice("已安装 \(release.version, privacy: .public),重新启动") - try? FileManager.default.removeItem(at: work) - try UpdateInstaller.relaunch(app) - terminate() - } catch { - // 取消下载时 URLSession 抛出的是 URLError,按任务是否被取消判断 - if !Task.isCancelled { Log.update.error("安装更新失败:\(error.localizedDescription, privacy: .public)") } - phase = Task.isCancelled ? .available - : .failed((error as? LocalizedError)?.errorDescription ?? error.localizedDescription) - } + do { + try ensureUpdater() + sparkleInstaller?.check(userInitiated: false, action: .install(release)) + } catch { + phase = .failed(error.localizedDescription) } } - func cancel() { - task?.cancel() - } + func cancel() { sparkleInstaller?.cancel() } /// 手动下载地址:拿到清单时用其中的 DMG,否则回到 GitHub Releases——那里始终挂着最新版 dmg var manualDownloadURL: URL { @@ -249,22 +225,6 @@ public final class UpdateController { NSWorkspace.shared.open(manualDownloadURL) } - /// 当前账户不能改写应用目录时(非管理员或应用放在受保护的位置),请求一次管理员授权完成替换 - private func replaceWithAdministratorPrompt(_ app: URL, with candidate: URL) async throws { - let quote = { (path: String) in "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } - let backup = candidate.deletingLastPathComponent().appendingPathComponent("previous-\(app.lastPathComponent)") - let (a, b, c) = (quote(app.path), quote(backup.path), quote(candidate.path)) - // 旧版改名备份 → 新版移入;移入失败时还原旧版并以失败退出;成功后沿用旧版的属主 - let shell = "/bin/mv -f \(a) \(b) && { /bin/mv -f \(c) \(a) || { /bin/mv -f \(b) \(a); exit 1; }; }" - + " && /usr/sbin/chown -R \"$(/usr/bin/stat -f %u:%g \(b))\" \(a)" - if let error = await MaintenanceController.runWithAdministratorPrompt(shell: shell, prompt: tr("XStats 需要授权以安装新版本。")) { - throw UpdateError.install(error) - } - guard FileManager.default.fileExists(atPath: app.appendingPathComponent("Contents/Info.plist").path) else { - throw UpdateError.install(tr("替换后未找到应用")) - } - } - private enum Keys { static let lastChecked = "updateLastChecked" static let skippedVersion = "updateSkippedVersion" diff --git a/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift deleted file mode 100644 index 35ccb48..0000000 --- a/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2026 ysicing -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Foundation -import Testing -@testable import Updates - -struct DownloadCancellationTests { - @Test func successfulDownloadMovesTemporaryFileAndRejectsHTTPFailure() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadResponse-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [DownloadResponseProtocol.self] - let destination = root.appendingPathComponent("update.zip") - try await Downloader(destination: destination, progress: { _ in }) - .run(URL(string: "https://download.test/success")!, configuration: configuration) - #expect(try Data(contentsOf: destination) == Data("verified payload".utf8)) - try FileManager.default.removeItem(at: destination) - await #expect(throws: UpdateError.self) { - try await Downloader(destination: destination, progress: { _ in }) - .run(URL(string: "https://download.test/failure")!, configuration: configuration) - } - #expect(!FileManager.default.fileExists(atPath: destination.path)) - } - - @Test func cancelledBeforeStartingDoesNotCreateASessionTask() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadCancellation-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let task = Task { - withUnsafeCurrentTask { $0?.cancel() } - return try await UpdateInstaller.download(URL(string: "http://127.0.0.1:9/update.zip")!, into: root) { _ in } - } - await #expect(throws: CancellationError.self) { try await task.value } - #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("update.zip").path)) - } - - @Test func cancellationRacingWithStartupAlwaysCompletes() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadStartup-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - for _ in 0..<30 { - let task = Task { - try await UpdateInstaller.download(URL(string: "http://127.0.0.1:9/update.zip")!, into: root) { _ in } - } - await Task.yield() - task.cancel() - do { - _ = try await task.value - Issue.record("已取消的下载不应成功") - } catch is CancellationError { - } catch let error as UpdateError { - // 本机关闭端口可在取消送达前先返回连接错误。 - if case .download = error {} else { Issue.record("非预期错误:\(error)") } - } - } - } -} - -private final class DownloadResponseProtocol: URLProtocol, @unchecked Sendable { - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - override func startLoading() { - let response = HTTPURLResponse(url: request.url!, statusCode: request.url!.path == "/success" ? 200 : 503, - httpVersion: "HTTP/1.1", headerFields: ["Content-Length": "16"])! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("verified payload".utf8)) - client?.urlProtocolDidFinishLoading(self) - } - override func stopLoading() {} -} diff --git a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift index 5fc5209..3a17e7b 100644 --- a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift +++ b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift @@ -52,12 +52,31 @@ import Testing let release = try #require(UpdateFeed.parse(Data(good.utf8))) #expect(release.version == "0.3.0") #expect(release.notes == ["新增在线升级"]) + #expect(release.englishNotes == nil) + #expect(release.notes(for: "en") == release.notes) let insecure = good.replacingOccurrences(of: "https://getopenstats.com/download/OpenStats-0.3.0.zip", with: "http://example.com/x.zip") #expect(UpdateFeed.parse(Data(insecure.utf8)) == nil) let badHash = good.replacingOccurrences(of: String(repeating: "a", count: 64), with: "abc") #expect(UpdateFeed.parse(Data(badHash.utf8)) == nil) } + @Test func chineseLanguagesUseChineseAndOtherLanguagesUseEnglish() throws { + let json = #"{"version":"1.0.0","build":"200","date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/update.zip","sha256":"\#(String(repeating: "a", count: 64))","size":1000,"notes":["source"],"englishNotes":["English"]}"# + let release = try #require(UpdateFeed.parse(Data(json.utf8))) + for code in ["zh", "zh-Hans", "zh-Hant", "zh-CN", "zh-TW"] { + #expect(release.notes(for: code) == ["source"]) + } + for code in ["en", "ja", "ko", "de", "es", "fr", "ar", "unsupported"] { + #expect(release.notes(for: code) == ["English"]) + } + let withoutEnglish = json.replacingOccurrences(of: #","englishNotes":["English"]"#, with: "") + let chineseFallback = try #require(UpdateFeed.parse(Data(withoutEnglish.utf8))) + #expect(chineseFallback.notes(for: "de") == ["source"]) + let emptyEnglish = json.replacingOccurrences(of: #""englishNotes":["English"]"#, with: #""englishNotes":[]"#) + #expect(try #require(UpdateFeed.parse(Data(emptyEnglish.utf8))).notes(for: "de") == ["source"]) + } + + @Test func picksInstallerForChip() throws { let hash = String(repeating: "a", count: 64), intelHash = String(repeating: "b", count: 64) let base = #"{"version":"0.3.1","build":"51","date":"2026-09-16","minimumSystem":"14.0","url":"https://getopenstats.com/download/OpenStats-0.3.1-AppleSilicon.zip","sha256":"\#(hash)","size":1,"dmg":null,"notes":[],"changelog":null"# @@ -81,80 +100,17 @@ import Testing return url } - private func expectInvalidBundle(_ operation: () throws -> Void) { - do { - try operation() - Issue.record("Expected UpdateError.invalidBundle") - } catch let error as UpdateError { - if case .invalidBundle = error { return } - Issue.record("Unexpected update error: \(error)") - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func hashesFiles() throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let file = dir.appendingPathComponent("a.txt") - try Data("abc".utf8).write(to: file) - #expect(try UpdateInstaller.sha256(of: file) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") - } - - @Test func relaunchWaitsForExitAndRequestsANewInstance() async throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let opener = dir.appendingPathComponent("fake opener ' quoted") - try "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\n" - .write(to: opener, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: opener.path) - let recorded = URL(fileURLWithPath: opener.path + ".args") - let app = dir.appendingPathComponent("XStats $(touch injected) ' \" 空格.app") - let original = Process() - original.executableURL = URL(fileURLWithPath: "/bin/sleep") - original.arguments = ["30"] - try original.run() - let relaunch = UpdateInstaller.makeRelaunchProcess(app, afterExitOf: original.processIdentifier, opener: opener) - relaunch.currentDirectoryURL = dir - defer { - if original.isRunning { original.terminate() } - if relaunch.isRunning { relaunch.terminate() } - } - try relaunch.run() - try await Task.sleep(for: .milliseconds(350)) - try #require(original.isRunning && relaunch.isRunning) - #expect(!FileManager.default.fileExists(atPath: recorded.path), "旧进程退出前不能启动新版") - original.terminate() - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while relaunch.isRunning && ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(20)) - } - try #require(!relaunch.isRunning, "旧进程退出后应完成启动命令") - #expect(relaunch.terminationStatus == 0) - let arguments = try String(contentsOf: recorded, encoding: .utf8).split(separator: "\n").map(String.init) - #expect(arguments == ["-n", app.path], "不能让 LaunchServices 复用尚未清理的旧实例记录") - #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent("injected").path)) - } - - @Test func replacesAndKeepsOldOnFailure() throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let current = dir.appendingPathComponent("App.app") - let candidate = dir.appendingPathComponent("New.app") - try FileManager.default.createDirectory(at: current, withIntermediateDirectories: true) - try Data("old".utf8).write(to: current.appendingPathComponent("v")) - try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true) - try Data("new".utf8).write(to: candidate.appendingPathComponent("v")) - - try UpdateInstaller.replace(current, with: candidate, backupDirectory: dir) - #expect(String(decoding: try Data(contentsOf: current.appendingPathComponent("v")), as: UTF8.self) == "new") - #expect(!FileManager.default.fileExists(atPath: candidate.path)) - - // 新版不存在时移动失败,旧版应还原到原位置 - #expect(throws: UpdateError.self) { - try UpdateInstaller.replace(current, with: dir.appendingPathComponent("Missing.app"), backupDirectory: dir) - } - #expect(String(decoding: try Data(contentsOf: current.appendingPathComponent("v")), as: UTF8.self) == "new") + private func run(_ executable: String, _ arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { try process.run() } catch { return (-1, error.localizedDescription) } + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(decoding: output, as: UTF8.self)) } /// CI 不能可靠运行改名后的 Apple 平台二进制;编译普通替身,保留真实的进程路径检查。 @@ -164,24 +120,22 @@ import Testing defer { try? FileManager.default.removeItem(at: source) } try "#include \nint main(void) { sleep(30); return 0; }\n" .write(to: source, atomically: true, encoding: .utf8) - let compile = UpdateInstaller.run("/usr/bin/xcrun", ["clang", "-x", "c", source.path, "-o", url.path]) + let compile = run("/usr/bin/xcrun", ["clang", "-x", "c", source.path, "-o", url.path]) try #require(compile.status == 0, "测试替身编译失败:\(compile.output)") - let sign = UpdateInstaller.run("/usr/bin/codesign", ["--force", "--sign", "-", url.path]) + let sign = run("/usr/bin/codesign", ["--force", "--sign", "-", url.path]) try #require(sign.status == 0, "测试替身签名失败:\(sign.output)") } - @Test func replacingAppStopsItsOldWidgetExtension() throws { + @Test func stopsOnlyThisInstallationsWidgetExtension() throws { let dir = try scratch() defer { try? FileManager.default.removeItem(at: dir) } let current = dir.appendingPathComponent("XStats.app") - let candidate = dir.appendingPathComponent("New.app") let executable = current.appendingPathComponent("Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget") let otherExecutable = dir.appendingPathComponent("Other.app/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget") try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) try makeSleepingExecutable(at: executable) try FileManager.default.createDirectory(at: otherExecutable.deletingLastPathComponent(), withIntermediateDirectories: true) try makeSleepingExecutable(at: otherExecutable) - try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true) let process = Process() process.executableURL = executable @@ -204,48 +158,9 @@ import Testing let pathLength = proc_pidpath(process.processIdentifier, &runningPath, UInt32(runningPath.count)) #expect(pathLength > 0) - try UpdateInstaller.replace(current, with: candidate, backupDirectory: dir) + try UpdateInstaller.stopWidgetExtension(in: current) #expect(!process.isRunning, "替换应用前应退出旧版小组件扩展") #expect(otherProcess.isRunning, "不得退出其他应用的同名进程") } - @Test func extractsSingleApp() throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let app = dir.appendingPathComponent("src/Demo.app/Contents") - try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) - let zip = dir.appendingPathComponent("Demo.zip") - #expect(UpdateInstaller.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", dir.appendingPathComponent("src/Demo.app").path, zip.path]).status == 0) - let extracted = try UpdateInstaller.extractApp(from: zip, into: dir) - #expect(extracted.lastPathComponent == "Demo.app") - } - - @Test func rejectsUnsignedBundle() throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let app = dir.appendingPathComponent("Demo.app") - try FileManager.default.createDirectory(at: app.appendingPathComponent("Contents"), withIntermediateDirectories: true) - let plist: [String: Any] = ["CFBundleIdentifier": "work.12306.xstats.app", "CFBundleShortVersionString": "9.9.9", "CFBundlePackageType": "APPL"] - try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - .write(to: app.appendingPathComponent("Contents/Info.plist")) - #expect(throws: UpdateError.self) { - try UpdateInstaller.verify(app, bundleIdentifier: "work.12306.xstats.app", version: "9.9.9", teamIdentifier: "ABCDE12345") - } - expectInvalidBundle { - try UpdateInstaller.verify(app, bundleIdentifier: "work.12306.xstats.app", version: "1.0.0", teamIdentifier: "ABCDE12345") - } - } - - @Test func rejectsPreviousProductIdentity() throws { - let dir = try scratch() - defer { try? FileManager.default.removeItem(at: dir) } - let app = dir.appendingPathComponent("OpenStats.app") - try FileManager.default.createDirectory(at: app.appendingPathComponent("Contents"), withIntermediateDirectories: true) - let plist = ["CFBundleIdentifier": "com.openstats.app", "CFBundlePackageType": "APPL"] - try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - .write(to: app.appendingPathComponent("Contents/Info.plist")) - expectInvalidBundle { - try UpdateInstaller.verify(app, bundleIdentifier: "work.12306.xstats.app", version: "0.6.1", teamIdentifier: "ABCDE12345") - } - } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift new file mode 100644 index 0000000..f920a0a --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift @@ -0,0 +1,184 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization +import Sparkle +import Testing +import Updates +@testable import XStatsUI + +@MainActor +struct SparkleInstallerTests { + private func release(build: String = "200") -> UpdateRelease { + UpdateRelease(version: "1.0.0", build: build, date: "2026-09-30", minimumSystem: "14.0", + url: URL(string: "https://example.test/XStats-1.0.0-AppleSilicon.zip")!, + sha256: String(repeating: "a", count: 64), size: 1000, dmg: nil, notes: ["test"], changelog: nil) + } + + @Test func cancelRejectsLateDownloadVerificationAndErrorCallbacks() { + var phases: [UpdateController.Phase] = [] + var cancellations = 0 + let installer = SparkleInstaller(onPhase: { phases.append($0) }, onRelaunch: {}) + installer.showUserInitiatedUpdateCheck { cancellations += 1 } + installer.cancel() + installer.showDownloadInitiated { cancellations += 1 } + installer.showDownloadDidReceiveExpectedContentLength(100) + installer.showDownloadDidReceiveData(ofLength: 100) + installer.showDownloadDidStartExtractingUpdate() + var acknowledged = false + installer.showUpdaterError(NSError(domain: "test", code: 1)) { acknowledged = true } + #expect(phases == [.available]) + #expect(cancellations == 2 && acknowledged) + } + + @Test func downloadProgressIsBoundedAndCancellationStopsAtExtraction() { + var phases: [UpdateController.Phase] = [] + var cancellations = 0 + let installer = SparkleInstaller(onPhase: { phases.append($0) }, onRelaunch: {}) + installer.showDownloadInitiated { cancellations += 1 } + installer.showDownloadDidReceiveExpectedContentLength(10_000) + for _ in 0..<10_000 { installer.showDownloadDidReceiveData(ofLength: 1) } + #expect(phases.last == .downloading(1)) + #expect(phases.count <= 202) + installer.showDownloadDidReceiveData(ofLength: UInt64.max) + #expect(phases.last == .downloading(1)) + installer.showDownloadDidStartExtractingUpdate() + installer.cancel() + #expect(phases.last == .verifying && cancellations == 0) + } + + @Test(arguments: ["200", "201"]) + func mismatchedSignedFeedCannotInstallAnotherBuild(build: String) throws { + let release = release() + let installer = SparkleInstaller(onPhase: { _ in }, onRelaunch: {}) + installer.check(userInitiated: false, action: .install(release)) + let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: installer, delegate: installer) + // 只构造清单字段;不使用该初始化器中依赖宿主版本和系统的属性。 + let item = try #require(SUAppcastItem(dictionary: [ + "sparkle:version": build, "sparkle:shortVersionString": release.version, + "sparkle:minimumSystemVersion": release.minimumSystem, + "enclosure": ["url": release.url.absoluteString, "length": "1000", "sparkle:installationType": "application"], + ])) + if build == release.build { + try installer.updater(updater, shouldProceedWithUpdate: item, updateCheck: .updates) + } else { + #expect(throws: UpdateError.self) { + try installer.updater(updater, shouldProceedWithUpdate: item, updateCheck: .updates) + } + } + } + private func item(_ release: UpdateRelease) throws -> SUAppcastItem { + try #require(SUAppcastItem(dictionary: [ + "sparkle:version": release.build, "sparkle:shortVersionString": release.version, + "sparkle:minimumSystemVersion": release.minimumSystem, + "xstats:date": release.date, "xstats:sha256": release.sha256, + "xstats:dmg": "https://example.test/update.dmg", + "sparkle:fullReleaseNotesLink": "https://example.test/changelog", + "description": ["content": "test\nsecond line", "format": "plain-text"], + "enclosure": ["url": release.url.absoluteString, "length": "1000", "sparkle:installationType": "application"], + ])) + } + + @Test func successfulCheckTimeMigratesOnceWithoutOverwritingSparkle() { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let old = Date(timeIntervalSince1970: 1000) + let sparkle = Date(timeIntervalSince1970: 2000) + defaults.set(old, forKey: "updateLastChecked") + _ = UpdateController(settings: AppSettings(defaults: defaults), defaults: defaults) + #expect(defaults.object(forKey: "SULastCheckTime") as? Date == old) + defaults.set(sparkle, forKey: "SULastCheckTime") + _ = UpdateController(settings: AppSettings(defaults: defaults), defaults: defaults) + #expect(defaults.object(forKey: "SULastCheckTime") as? Date == sparkle) + } + + @Test func stableRegionalFeedURLsAndPublicDelegateSelectors() { + #expect(UpdateFeed.sparkleURLs(prefersChina: true).map(\.absoluteString) == [ + "https://x-stats.china.12306.work/api/v1/update/appcast.xml", + "https://xstats-apps.12306.work/api/v1/update/appcast.xml", + ]) + let driver = SparkleInstaller(onPhase: { _ in }, onRelaunch: {}) + for selector in ["feedParametersForUpdater:sendingSystemProfile:", "updater:mayPerformUpdateCheck:error:", + "updater:didFinishLoadingAppcast:", "updaterDidNotFindUpdate:error:", + "updater:didFinishUpdateCycleForUpdateCheck:error:"] { + #expect(driver.responds(to: NSSelectorFromString(selector))) + } + } + + @Test func signedItemPreservesNotesDateAndManualLinks() throws { + let candidate = try SparkleInstaller.release(from: item(release())) + #expect(candidate.date == "2026-09-30") + #expect(candidate.sha256 == release().sha256) + #expect(candidate.dmg?.absoluteString == "https://example.test/update.dmg") + #expect(candidate.changelog?.absoluteString == "https://example.test/changelog") + #expect(candidate.notes == ["test", "second line"]) + } + + @Test func signedItemRetainsChineseAndEnglishIndependentOfSparklesChoice() throws { + var fields = try item(release()).propertiesDictionary + fields["xstats:notes-zh-Hans"] = "Chinese-first\nChinese-second" + fields["xstats:notes-en"] = "English-first\nEnglish-second" + // 即使 Sparkle 已按系统语言选了英文,中文界面仍须显示中文。 + fields["description"] = ["content": "system-selected English", "format": "plain-text"] + let candidate = try SparkleInstaller.release(from: #require(SUAppcastItem(dictionary: fields))) + #expect(candidate.notes == ["Chinese-first", "Chinese-second"]) + for language in AppLanguage.allCases where language != .system { + let expected = language.languageCode.hasPrefix("zh-") ? candidate.notes : ["English-first", "English-second"] + #expect(candidate.notes(for: language.languageCode) == expected) + } + #expect(candidate.notes(for: "unsupported") == ["English-first", "English-second"]) + } + + + @Test func backgroundDismissesWithoutDownloadingAndMigratesLegacySkipThroughSparkle() throws { + var found: [String] = [] + var phases: [UpdateController.Phase] = [] + var migrated = false + let driver = SparkleInstaller(onPhase: { phases.append($0) }, onRelaunch: {}) + driver.onRelease = { release, manual in #expect(!manual); found.append(release.version) } + // 使用公开 NSSecureCoding 接口构造状态,不调用 Sparkle 私有初始化器。 + let archive = NSKeyedArchiver(requiringSecureCoding: true) + archive.encode(0, forKey: "SPUUserUpdateStateStage") + archive.encode(false, forKey: "SPUUserUpdateStateUserInitiated") + archive.finishEncoding() + let decoder = try NSKeyedUnarchiver(forReadingFrom: archive.encodedData) + defer { decoder.finishDecoding() } + let state = try #require(SPUUserUpdateState(coder: decoder)) + var choice: SPUUserUpdateChoice? + driver.showUpdateFound(with: try item(release()), state: state) { choice = $0 } + #expect(choice == .dismiss && found == ["1.0.0"]) + driver.legacySkippedVersion = { "1.0.0" } + driver.onLegacySkipMigrated = { migrated = true } + driver.showUpdateFound(with: try item(release()), state: state) { choice = $0 } + #expect(choice == .skip && migrated && found.count == 1) + // 迁移只表示跳过新版,不能宣称当前安装已经是最新;周期结束后也应保持该状态。 + #expect(phases == [.available, .idle]) + let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver) + driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: nil) + #expect(phases.last == .idle) + } + + @Test func skippedNewerBuildDoesNotClaimTheInstalledAppIsUpToDate() throws { + var phases: [UpdateController.Phase] = [] + let driver = SparkleInstaller(onPhase: { phases.append($0) }, onRelaunch: {}) + let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver) + let error = NSError(domain: SUSparkleErrorDomain, code: Int(SUError.noUpdateError.rawValue), + userInfo: [SPULatestAppcastItemFoundKey: try item(release(build: "99999999"))]) + driver.updaterDidNotFindUpdate(updater, error: error) + #expect(phases == [.idle]) + } + + @Test func calendarMonthLaunchOnlyAndFailureRetryIntervals() throws { + let calendar = Calendar.current + let jan = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 31, hour: 12))) + let feb = try #require(calendar.date(byAdding: .month, value: 1, to: jan)) + let launch = jan.addingTimeInterval(-1) + #expect(UpdateCheckSchedule.monthly.sparkleInterval(lastChecked: jan, now: jan, launchedAt: launch) == feb.timeIntervalSince(jan)) + #expect(UpdateCheckSchedule.weekly.sparkleInterval(lastChecked: jan, now: jan, launchedAt: launch) == TimeInterval(7 * 86400)) + #expect(UpdateCheckSchedule.daily.sparkleInterval(lastChecked: jan, now: jan, launchedAt: launch, retry: true) == TimeInterval(3600)) + #expect(UpdateCheckSchedule.never.sparkleInterval(lastChecked: nil, now: jan, launchedAt: launch) == nil) + #expect(UpdateCheckSchedule.atLaunch.sparkleInterval(lastChecked: jan, now: jan, launchedAt: launch) == nil) + #expect(UpdateCheckSchedule.atLaunch.sparkleInterval(lastChecked: nil, now: jan, launchedAt: launch) == TimeInterval(3600)) + } + +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index 1d53453..63e7396 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -597,27 +597,7 @@ private func isolatedDefaults() -> UserDefaults { #expect(controller.manualDownloadURL.absoluteString == dmg) } - @Test func updateCheckFallsBackSequentially() async throws { - let feed = Data(#"{"version":"2026.09.21.03","build":"108","date":"2026-09-21","minimumSystem":"14.0","url":"https://example.test/XStats.zip","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":123,"notes":["fallback"]}"#.utf8) - var requested: [String] = [] - let result = await UpdateController.fetch( - currentVersion: "2026.09.21.02", - installationID: String(repeating: "b", count: 64), - prefersChina: true - ) { request in - let url = try #require(request.url) - requested.append(url.absoluteString) - if requested.count == 1 { throw URLError(.cannotConnectToHost) } - let response = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)) - return (feed, response) - } - #expect(try result.get().version == "2026.09.21.03") - #expect(requested == [ - "https://x-stats.china.12306.work/api/v1/update/check", - "https://xstats-apps.12306.work/api/v1/update/check", - ]) - } } @Suite struct DiagnosticsTests { diff --git a/ReleaseNotes.json b/ReleaseNotes.json new file mode 100644 index 0000000..c64f84b --- /dev/null +++ b/ReleaseNotes.json @@ -0,0 +1,23 @@ +{ + "version": "0.14.0", + "sourceNotes": [ + "菜单栏网速旁可选显示公网 IP 归属地的国旗或地区文字,复用网络详情的查询结果,不增加网络请求", + "进程页新增系统活动监视器入口", + "进程管理改为默认关闭的可选功能,可在通用设置中按需开启", + "“AI 用量与额度”统一简化为“AI 用量”,同步多语言名称与说明", + "优化卸载确认弹窗的按钮、文案和长应用名排版,明确所选项目与废纸篓恢复方式", + "修复在线更新安装后可能无法自动启动的问题", + "修复卸载前退出菜单栏应用后,“正在运行”提示及卸载按钮未及时刷新的问题" + ], + "translations": { + "en": [ + "Optionally show the public IP country flag or region beside menu bar network speeds, reusing existing results without extra requests", + "Add a shortcut to Activity Monitor on the Processes page; when the built-in process feature is disabled, the process keyboard shortcut opens Activity Monitor", + "Make process management optional and disabled by default; enable it in General settings. Per-app usage remains available on other monitoring pages", + "Rename “AI Usage and Quotas” to “AI Usage” across all languages", + "Improve uninstall confirmation buttons, wording, and long app names; clarify selected items and Trash recovery", + "Fix automatic relaunch after installing an update; if the app does not relaunch after the first upgrade from an older version, open it manually once", + "Fix delayed refresh of running status and the uninstall button after quitting menu bar apps" + ] + } +} diff --git a/Taskfile.yml b/Taskfile.yml index d16a276..ad96f99 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -61,8 +61,9 @@ tasks: -derivedDataPath "{{.DERIVED}}" \ -destination 'generic/platform=macOS' ARCHS=arm64 ONLY_ACTIVE_ARCH=NO \ XSTATS_APP_PROFILE="${XSTATS_APP_PROFILE:-}" XSTATS_WIDGET_PROFILE="${XSTATS_WIDGET_PROFILE:-}" \ - {{if .TEAM_ID}}CODE_SIGN_IDENTITY="{{.SIGN_ID}}" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM={{.TEAM_ID}}{{if eq .CONFIG "Release"}} OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO{{end}}{{end}} \ + {{if .TEAM_ID}}CODE_SIGN_IDENTITY="{{.SIGN_ID}}" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM={{.TEAM_ID}}{{if eq .CONFIG "Release"}} OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO{{end}}{{else}}CODE_SIGNING_ALLOWED=NO ENABLE_HARDENED_RUNTIME=NO{{end}} \ build + - bash scripts/sign_sparkle.sh "{{.APP}}" "{{.SIGN_ID}}" run: desc: 编译、安装并启动 App @@ -102,6 +103,8 @@ tasks: - python3 scripts/release_provenance_test.py - python3 scripts/publish_api_test.py - python3 scripts/sync_changelog_test.py + - python3 scripts/sparkle_appcast_test.py + - python3 scripts/github_release_notes_test.py - task: test-api - task: test-swift diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 92e3d6a..2656275 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -109,3 +109,9 @@ XStats 的 AI Provider 契约与状态设计参考了以下项目公开的 Provi 1.5.0(MIT License,Copyright (c) 2026 6tail),提供公历、农历、藏历、回历、干支、 节气、节日、调休、三伏与传统梅雨日期计算。未修改上游源码。 完整许可见 [LICENSES/Tyme4Swift-MIT.txt](LICENSES/Tyme4Swift-MIT.txt)。 + +## Sparkle + +应用内安装通过 Swift Package Manager 使用 [sparkle-project/Sparkle](https://github.com/sparkle-project/Sparkle) +2.10.0。未修改上游源码;完整上游许可随应用资源中的 `Sparkle-License.txt` 分发。 +完整许可证及其内部组件声明见 [LICENSES/Sparkle-License.txt](LICENSES/Sparkle-License.txt)。 diff --git a/project.yml b/project.yml index 2b1f524..f2f3558 100644 --- a/project.yml +++ b/project.yml @@ -34,6 +34,8 @@ targets: platform: macOS sources: - path: App + - path: LICENSES/Sparkle-License.txt + buildPhase: resources - path: Helper/work.12306.xstats.helper.plist buildPhase: copyFiles: @@ -50,6 +52,12 @@ targets: CFBundleLocalizations: [zh-Hans, zh-Hant, ja, ko, en, de, es, fr, ar] LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) LSUIElement: true + SUPublicEDKey: "j/I8zd8BXz4oPldnhocEySXTMmXPxKMyBRwoA4ewLIk=" + SUEnableAutomaticChecks: false + SUAutomaticallyUpdate: false + SUEnableSystemProfiling: false + SURequireSignedFeed: true + SUVerifyUpdateBeforeExtraction: true LSApplicationCategoryType: public.app-category.utilities NSHumanReadableCopyright: © 2026 XStats NSCalendarsFullAccessUsageDescription: 在本机日历面板显示您选择的系统日程,不修改或上传日程内容。 diff --git a/scripts/arm64_release_test.sh b/scripts/arm64_release_test.sh index 584354a..8ab8ffc 100644 --- a/scripts/arm64_release_test.sh +++ b/scripts/arm64_release_test.sh @@ -16,6 +16,11 @@ if grep -q -- '^ -quiet' <<< "$compile"; then exit 1 fi +# 无证书的 CI 用临时签名:关闭团队验证所需的 runtime,正式签名路径仍必须保留。 +adhoc="$(task --dry compile SIGN_ID=- 2>&1)" +grep -q 'CODE_SIGNING_ALLOWED=NO ENABLE_HARDENED_RUNTIME=NO' <<< "$adhoc" +grep -q 'scripts/sign_sparkle.sh' <<< "$adhoc" + WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT diff --git a/scripts/github_release_notes.py b/scripts/github_release_notes.py new file mode 100644 index 0000000..4f7f910 --- /dev/null +++ b/scripts/github_release_notes.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 ysicing +# SPDX-License-Identifier: AGPL-3.0-or-later +"""生成 GitHub Release 的完整中文正文和英文更新说明,不执行发布。""" + +import argparse +from pathlib import Path +import re + +from appcast import release_notes +from sparkle_appcast import ROOT, load_localized_notes + + +def render(version: str, changelog: str, translations: Path) -> str: + section = re.search(rf"^## {re.escape(version)} · \S+\n(.*?)(?=^## |\Z)", changelog, re.S | re.M) + if not section or not section.group(1).strip(): + raise ValueError(f"CHANGELOG.md 里没有 {version} 的正文") + _, notes = release_notes(changelog, version) + if not notes: + raise ValueError(f"CHANGELOG.md 里没有 {version} 的更新条目") + localized = load_localized_notes({"version": version, "notes": notes}, translations) + # 中文保留原有分类、链接和升级提示;英文复用发布摘要,避免维护两份重复译文。 + english = "\n".join(f"- {note}" for note in localized["en"]) + return f"## 中文\n\n{section.group(1).strip()}\n\n## English\n\n{english}\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + args = parser.parse_args() + print(render(args.version, (ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), + ROOT / "ReleaseNotes.json"), end="") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError) as error: + raise SystemExit(f"error: {error}") from error diff --git a/scripts/github_release_notes_test.py b/scripts/github_release_notes_test.py new file mode 100644 index 0000000..530c4a4 --- /dev/null +++ b/scripts/github_release_notes_test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 ysicing +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from github_release_notes import ROOT, render + + +class GitHubReleaseNotesTests(unittest.TestCase): + changelog = """# 更新日志 + +## 1.0.0 · 2026-09-30 + +### 新增 + +- 新增菜单栏功能:支持自定义显示和[设置说明](https://example.test/settings)。 + +### 修复 + +- 修复更新后的启动问题;旧版首次升级可手动打开一次。 + +## 0.9.0 · 2026-09-29 + +- 历史条目不属于本次发布。 +""" + + def metadata(self): + return {"version": "1.0.0", "sourceNotes": ["新增菜单栏功能:支持自定义显示和[设置说明](https://example.test/settings)", + "修复更新后的启动问题"], + "translations": {"en": ["Add a menu bar feature with custom display and [settings](https://example.test/settings)", + "Fix relaunch after updating; open the app manually once after the first upgrade from an older version"]}} + + def test_preserves_full_chinese_and_adds_english_without_previous_releases(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ReleaseNotes.json" + path.write_text(json.dumps(self.metadata(), ensure_ascii=False), encoding="utf-8") + body = render("1.0.0", self.changelog, path) + chinese, english = body.split("## English\n\n", 1) + self.assertTrue(chinese.startswith("## 中文\n\n")) + self.assertIn("### 新增", chinese) + self.assertIn("### 修复", chinese) + self.assertIn("支持自定义显示和[设置说明](https://example.test/settings)。", chinese) + self.assertIn("旧版首次升级可手动打开一次。", chinese) + self.assertIn("open the app manually once after the first upgrade", english) + self.assertIn("[settings](https://example.test/settings)", english) + self.assertNotIn("历史条目", body) + self.assertNotIn("0.9.0", body) + + def test_stale_or_missing_english_fails_before_emitting_body(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ReleaseNotes.json" + for mutation in ({"version": "0.9.0"}, {"sourceNotes": ["outdated"]}, {"translations": {}}): + with self.subTest(mutation=mutation): + path.write_text(json.dumps({**self.metadata(), **mutation}), encoding="utf-8") + with self.assertRaises(ValueError): + render("1.0.0", self.changelog, path) + + def test_missing_empty_or_no_item_release_is_rejected(self): + for changelog in (self.changelog.replace("## 1.0.0 ·", "## 1.0.1 ·"), + "## 1.0.0 · 2026-09-30\n\n## 0.9.0 · 2026-09-29\n- old\n", + "## 1.0.0 · 2026-09-30\n\nOnly explanatory text\n"): + with self.subTest(changelog=changelog), self.assertRaises(ValueError): + render("1.0.0", changelog, Path("missing.json")) + + def test_cli_generates_current_release_and_unknown_version_has_no_output(self): + metadata = json.loads((ROOT / "ReleaseNotes.json").read_text(encoding="utf-8")) + command = [sys.executable, str(ROOT / "scripts/github_release_notes.py")] + valid = subprocess.run([*command, metadata["version"]], capture_output=True, text=True) + self.assertEqual(valid.returncode, 0, valid.stderr) + self.assertIn("## 中文\n", valid.stdout) + self.assertIn("## English\n", valid.stdout) + for note in metadata["translations"]["en"]: + self.assertIn(note, valid.stdout) + invalid = subprocess.run([*command, "999.999.999"], capture_output=True, text=True) + self.assertNotEqual(invalid.returncode, 0) + self.assertEqual(invalid.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/publish_api.py b/scripts/publish_api.py index 93f69a2..3b123bf 100644 --- a/scripts/publish_api.py +++ b/scripts/publish_api.py @@ -8,6 +8,8 @@ import json import os import sys import urllib.request +import urllib.error +from urllib.parse import urlsplit, urlunsplit from collections.abc import Mapping from pathlib import Path @@ -18,6 +20,25 @@ DEFAULT_ENDPOINTS = ( ) +def check_sparkle_endpoints(endpoints: list[str]) -> None: + """不携带安装标识的探测必须返回 400,防止先发布客户端而服务端仍是旧版。""" + if not endpoints: + raise ValueError("至少需要一个版本发布接口") + for endpoint in endpoints: + parts = urlsplit(endpoint) + if not parts.path.endswith("/api/v1/releases/current"): + raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}") + url = urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", "", "")) + request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + raise RuntimeError(f"{url} 未拒绝缺少安装标识的请求:{response.status}") + except urllib.error.HTTPError as error: + with error: + if error.code != 400 or error.headers.get("Cache-Control") != "no-store": + raise RuntimeError(f"请先部署支持 Sparkle 的 API:{url} 返回 {error.code}") from error + + def publish(appcast: Path, endpoints: list[str], token: str) -> None: if not token: raise ValueError("XSTATS_RELEASE_TOKEN 不能为空") @@ -59,7 +80,10 @@ def main() -> None: endpoints = configured_endpoints(os.environ) token = os.environ.get("XSTATS_RELEASE_TOKEN", "") try: - publish(Path(sys.argv[1]), endpoints, token) + if sys.argv[1] == "--check-sparkle": + check_sparkle_endpoints(endpoints) + else: + publish(Path(sys.argv[1]), endpoints, token) except Exception as error: raise SystemExit(f"发布版本到 API 失败:{error}") from error diff --git a/scripts/publish_api_test.py b/scripts/publish_api_test.py index c254b6c..0f409a5 100644 --- a/scripts/publish_api_test.py +++ b/scripts/publish_api_test.py @@ -8,9 +8,12 @@ import json import tempfile import threading import unittest +import urllib.error +from unittest.mock import patch from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import appcast import publish_api @@ -21,6 +24,28 @@ class PublishAPITest(unittest.TestCase): "https://x-stats.china.12306.work/api/v1/releases/current", ]) + def test_sparkle_gate_checks_both_regions_without_installation_data(self) -> None: + errors = [urllib.error.HTTPError(url, 400, "invalid request", {"Cache-Control": "no-store"}, io.BytesIO(b"")) + for url in publish_api.DEFAULT_ENDPOINTS] + with patch("publish_api.urllib.request.urlopen", side_effect=errors) as send: + publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS)) + urls = [call.args[0].full_url for call in send.call_args_list] + self.assertEqual(urls, [ + "https://xstats-apps.12306.work/api/v1/update/appcast.xml", + "https://x-stats.china.12306.work/api/v1/update/appcast.xml", + ]) + for call in send.call_args_list: + self.assertNotIn("installation_id", call.args[0].full_url) + self.assertIsNone(call.args[0].data) + self.assertFalse(call.args[0].has_header("Authorization")) + + def test_sparkle_gate_rejects_old_server_or_cached_validation(self) -> None: + for status, headers in ((404, {}), (400, {}), (503, {"Cache-Control": "no-store"})): + error = urllib.error.HTTPError("https://example.test", status, "error", headers, io.BytesIO(b"")) + with patch("publish_api.urllib.request.urlopen", side_effect=error): + with self.assertRaises(RuntimeError): + publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS)) + def test_sends_manifest_with_bearer_token(self) -> None: received = [] @@ -43,8 +68,19 @@ class PublishAPITest(unittest.TestCase): thread.start() try: with tempfile.TemporaryDirectory() as directory: + archive = Path(directory) / "XStats-0.14.1-AppleSilicon.zip" + archive.write_bytes(b"legacy and Sparkle share the same archive") + dmg = Path(directory) / "XStats-0.14.1-AppleSilicon.dmg" + # JSON 顶层字段继续供旧客户端解码;Sparkle 元数据留在独立的 XML。 + manifest = { + "version": "0.14.1", "build": "126", "date": "2026-09-30", + "minimumSystem": "14.0", + **appcast.asset("https://example.test/download", str(archive), str(dmg)), + "notes": ["支持 Sparkle 更新"], + "changelog": "https://github.com/ysicing/xstats/blob/main/CHANGELOG.md", + } path = Path(directory) / "appcast.json" - path.write_text('{"version":"2026.09.21.03"}', encoding="utf-8") + path.write_text(json.dumps(manifest), encoding="utf-8") base = f"http://127.0.0.1:{server.server_port}" with contextlib.redirect_stdout(io.StringIO()): publish_api.publish(path, [f"{base}/global", f"{base}/china"], "release-secret") @@ -57,7 +93,11 @@ class PublishAPITest(unittest.TestCase): for item in received: self.assertEqual(item["authorization"], "Bearer release-secret") self.assertEqual(item["content_type"], "application/json") - self.assertEqual(item["body"], {"version": "2026.09.21.03"}) + self.assertEqual(item["body"], manifest) + self.assertTrue(item["body"]["url"].endswith("-AppleSilicon.zip")) + self.assertEqual(len(item["body"]["sha256"]), 64) + self.assertIsInstance(item["body"]["size"], int) + self.assertIsInstance(item["body"]["build"], str) if __name__ == "__main__": diff --git a/scripts/publish_release.sh b/scripts/publish_release.sh index 894218f..9e1f698 100755 --- a/scripts/publish_release.sh +++ b/scripts/publish_release.sh @@ -44,11 +44,22 @@ assert feed["version"] == sys.argv[2] and feed["notes"], feed assert feed["sha256"] == digest(sys.argv[3]) and feed["url"].endswith("-AppleSilicon.zip"), feed assert "intel" not in feed, feed PY +python3 scripts/sparkle_appcast.py "$APPCAST" "dist/XStats-${VERSION}-AppleSilicon.zip" --verify +# 新客户端依赖固定 XML 入口;先部署两区域 API,再对外发布客户端。探测不产生安装统计。 +python3 scripts/publish_api.py --check-sparkle grep -q "version \"${VERSION}\"" "$CASK" || { echo "$CASK 的版本不是 ${VERSION}" >&2; exit 1; } dmg="dist/XStats-${VERSION}-AppleSilicon.dmg" xcrun stapler validate "$dmg" >/dev/null || { echo "$dmg 没有装订公证票据" >&2; exit 1; } grep -q "$(shasum -a 256 "$dmg" | cut -d' ' -f1)" "$CASK" || { echo "$CASK 里的 sha256 与 $dmg 不一致" >&2; exit 1; } +# 在上传前确认中英文正文完整有效;创建和重跑编辑 Release 使用同一份内容。 +# 后面的 Homebrew 段会设置 EXIT trap,因此这里用完立即删除临时文件。 +NOTES="$(mktemp)" +if ! python3 scripts/github_release_notes.py "$VERSION" > "$NOTES"; then + rm -f "$NOTES" + exit 1 +fi + # 上传后从 CDN 回读比对:安装包文件名带版本号、内容不可变,长 TTL 缓存无副作用; # 若此前有人探测过同名 URL 留下 404 负缓存,刷新该路径后重跑即可。 upload() { @@ -62,19 +73,10 @@ upload() { } upload "dist/XStats-${VERSION}-AppleSilicon.dmg" upload "dist/XStats-${VERSION}-AppleSilicon.zip" +upload "dist/XStats-${VERSION}-AppleSilicon.xml" # GitHub Release 是手动下载入口:应用内“手动下载”在没拿到清单时会跳到 releases/latest, # 那里必须挂着 dmg。整段必须可安全重跑。 -# 不能用 trap 清理:脚本后面的 Homebrew tap 段会再设一个 EXIT trap,把这个覆盖掉。 -# 用完立刻删;中途失败最多留下一个临时小文件。 -NOTES="$(mktemp)" -# 取 CHANGELOG 里该版本段落的原文,而不是 appcast 里截断到 48 字的摘要 -awk -v v="## ${VERSION} · " ' - index($0, v) == 1 { inside = 1; next } - inside && /^## / { exit } - inside { print } -' CHANGELOG.md > "$NOTES" -[ -s "$NOTES" ] || { rm -f "$NOTES"; echo "CHANGELOG.md 里没有 ${VERSION} 的正文" >&2; exit 1; } DMG="dist/XStats-${VERSION}-AppleSilicon.dmg" if gh release view "v${VERSION}" >/dev/null 2>&1; then # 重跑:远端 tag 必须还指向这次发布的提交,不擅自移动已有的 tag。 diff --git a/scripts/release.sh b/scripts/release.sh index a10c332..20a3c39 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -64,12 +64,22 @@ rm -rf "$APP" task build CONFIG=Release INSTALL=0 BUMP=0 SIGN_ID="$SIGN_ID" for binary in "$APP/Contents/MacOS/XStats" "$APP/Contents/MacOS/XStatsHelper" \ - "$APP/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget"; do + "$APP/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Sparkle" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Autoupdate" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Updater.app/Contents/MacOS/Updater" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/XPCServices/Installer.xpc/Contents/MacOS/Installer" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"; do [ "$(lipo -archs "$binary")" = arm64 ] \ || { echo "error: $binary 的架构是 $(lipo -archs "$binary"),应当只有 arm64" >&2; exit 1; } done codesign --verify --deep --strict --verbose=2 "$APP" -for binary in "$APP" "$APP/Contents/MacOS/XStatsHelper" "$APP/Contents/PlugIns/XStatsWidget.appex"; do +for binary in "$APP" "$APP/Contents/MacOS/XStatsHelper" "$APP/Contents/PlugIns/XStatsWidget.appex" \ + "$APP/Contents/Frameworks/Sparkle.framework" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Autoupdate" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/Updater.app" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/XPCServices/Installer.xpc" \ + "$APP/Contents/Frameworks/Sparkle.framework/Versions/Current/XPCServices/Downloader.xpc"; do details="$(codesign -dvv "$binary" 2>&1)" echo "$details" | grep -q "TeamIdentifier=${TEAM_ID}" \ || { echo "error: $binary 未使用团队 ${TEAM_ID} 签名" >&2; exit 1; } @@ -117,6 +127,7 @@ ditto -c -k --keepParent "$APP" "$DIST/$NAME.zip" # 版本清单只包含 Apple Silicon 安装包 python3 scripts/appcast.py "$VERSION" "$BUILD" "$DOWNLOAD_BASE" \ "$DIST/XStats-${VERSION}-AppleSilicon.zip" "$DIST/XStats-${VERSION}-AppleSilicon.dmg" > "$DIST/appcast.json" +python3 scripts/sparkle_appcast.py "$DIST/appcast.json" "$DIST/$NAME.zip" # ---- Homebrew cask ------------------------------------------------------------ @@ -150,7 +161,7 @@ CASK echo echo "✅ ${DIST}/${NAME}.dmg SHA-256 $(shasum -a 256 "$DIST/$NAME.dmg" | cut -d' ' -f1)" -echo " 在线升级:${DIST}/*.zip · ${DIST}/appcast.json" +echo " 在线升级:${DIST}/*.zip · ${DIST}/*.xml · ${DIST}/appcast.json" echo " Homebrew cask:${DIST}/xstats.rb" echo " 本机已安装的 XStats 保持不变" if [ "${SKIP_NOTARIZE:-0}" = "1" ]; then diff --git a/scripts/release_all.sh b/scripts/release_all.sh index d064a42..2887bdd 100755 --- a/scripts/release_all.sh +++ b/scripts/release_all.sh @@ -65,6 +65,7 @@ python3 scripts/release_provenance.py prepare >/dev/null RELEASE_FILES=( project.yml CHANGELOG.md + ReleaseNotes.json README.md README.en.md README.ja.md diff --git a/scripts/release_all_test.sh b/scripts/release_all_test.sh index 54cfe78..85a97a3 100755 --- a/scripts/release_all_test.sh +++ b/scripts/release_all_test.sh @@ -32,6 +32,7 @@ LOG for file in README.md README.en.md README.ja.md README.ko.md; do printf '# XStats\n' > "$WORK/repo/$file" done + printf '{"version":"0.8.0"}\n' > "$WORK/repo/ReleaseNotes.json" : > "$WORK/repo/Assets/readme/activity.svg" : > "$WORK/repo/Assets/readme/activity.zh.svg" : > "$WORK/log" @@ -108,7 +109,7 @@ python3 scripts/release_provenance.py prepare task test task release python3 scripts/release_provenance.py prepare -git add -- project.yml CHANGELOG.md README.md README.en.md README.ja.md README.ko.md Assets/readme/activity.svg Assets/readme/activity.zh.svg +git add -- project.yml CHANGELOG.md ReleaseNotes.json README.md README.en.md README.ja.md README.ko.md Assets/readme/activity.svg Assets/readme/activity.zh.svg git diff --cached --quiet git commit -m chore(release): 发布 0.8.0 git push origin main diff --git a/scripts/release_provenance.py b/scripts/release_provenance.py index d85203a..358044c 100644 --- a/scripts/release_provenance.py +++ b/scripts/release_provenance.py @@ -14,6 +14,7 @@ from pathlib import Path ALLOWED_RELEASE_FILES = { "project.yml", "CHANGELOG.md", + "ReleaseNotes.json", "README.md", "README.en.md", "README.ja.md", @@ -70,6 +71,9 @@ def record(repo: Path, output: Path, base_commit: str, version: str, build: str) "project_sha256": sha256(repo / "project.yml"), "changelog_sha256": sha256(repo / "CHANGELOG.md"), } + notes = repo / "ReleaseNotes.json" + if notes.is_file(): + payload["release_notes_sha256"] = sha256(notes) output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_suffix(output.suffix + ".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -90,6 +94,10 @@ def verify(repo: Path, provenance: Path, version: str, build: str) -> None: raise ProvenanceError("project.yml 与构建时不一致") if sha256(repo / "CHANGELOG.md") != payload.get("changelog_sha256"): raise ProvenanceError("CHANGELOG.md 与构建时不一致") + notes = repo / "ReleaseNotes.json" + if notes.exists() or "release_notes_sha256" in payload: + if not notes.is_file() or sha256(notes) != payload.get("release_notes_sha256"): + raise ProvenanceError("ReleaseNotes.json 与构建时不一致") if git(repo, "status", "--porcelain", "--untracked-files=all").stdout: raise ProvenanceError("发布前工作区或暂存区不干净") diff --git a/scripts/release_provenance_test.py b/scripts/release_provenance_test.py index 6f67c75..e4413d3 100644 --- a/scripts/release_provenance_test.py +++ b/scripts/release_provenance_test.py @@ -61,6 +61,20 @@ class ReleaseProvenanceTest(unittest.TestCase): with self.assertRaises(release_provenance.ProvenanceError): release_provenance.verify(self.repo, provenance, "1.0.0", "110") + def test_localized_notes_are_release_metadata_bound_to_build(self) -> None: + self.write("ReleaseNotes.json", '{"version":"1.0.0","translations":{"en":["first"]}}') + base = release_provenance.prepare(self.repo) + provenance = self.repo / "dist/release-provenance.json" + release_provenance.record(self.repo, provenance, base, "1.0.0", "109") + self.git("add", "ReleaseNotes.json") + self.git("commit", "-m", "release notes") + release_provenance.verify(self.repo, provenance, "1.0.0", "109") + self.write("ReleaseNotes.json", '{"version":"1.0.0","translations":{"en":["changed"]}}') + self.git("add", "ReleaseNotes.json") + self.git("commit", "-m", "edit translation after build") + with self.assertRaisesRegex(release_provenance.ProvenanceError, "ReleaseNotes.json"): + release_provenance.verify(self.repo, provenance, "1.0.0", "109") + if __name__ == "__main__": unittest.main() diff --git a/scripts/sign_sparkle.sh b/scripts/sign_sparkle.sh new file mode 100644 index 0000000..66d78ac --- /dev/null +++ b/scripts/sign_sparkle.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Copyright (C) 2026 ysicing +# SPDX-License-Identifier: AGPL-3.0-or-later +set -euo pipefail + +APP="$1" +IDENTITY="${2:--}" +FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework" +[ -d "$FRAMEWORK" ] || { echo "error: 应用未嵌入 Sparkle.framework" >&2; exit 1; } +STAMP=(--timestamp) +OPTIONS=(--options runtime) +if [ -z "$IDENTITY" ] || [ "$IDENTITY" = "-" ]; then + IDENTITY="-" + STAMP=(--timestamp=none) + OPTIONS=(--options 0) +fi + +# 官方框架包含 Intel 切片;只修改嵌入副本,保持 Swift Package 原始制品完整。 +for binary in "$FRAMEWORK/Versions/Current/Sparkle" \ + "$FRAMEWORK/Versions/Current/Autoupdate" \ + "$FRAMEWORK/Versions/Current/Updater.app/Contents/MacOS/Updater" \ + "$FRAMEWORK/Versions/Current/XPCServices/Installer.xpc/Contents/MacOS/Installer" \ + "$FRAMEWORK/Versions/Current/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"; do + if [ "$(lipo -archs "$binary")" != arm64 ]; then + lipo "$binary" -thin arm64 -output "$binary" + fi + [ "$(lipo -archs "$binary")" = arm64 ] || { echo "error: $binary 不是 arm64" >&2; exit 1; } +done + +# Xcode 的 Embed & Sign 仅重签框架外层;官方二进制中的安装进程/XPC 仍需使用本应用身份。 +# 逐层从内到外签名,保留根应用与系统扩展已有的 entitlement,不使用 --deep 代替正确的顺序。 +for component in "$FRAMEWORK/Versions/Current/XPCServices/Downloader.xpc" \ + "$FRAMEWORK/Versions/Current/XPCServices/Installer.xpc" \ + "$FRAMEWORK/Versions/Current/Updater.app" \ + "$FRAMEWORK/Versions/Current/Autoupdate"; do + [ -e "$component" ] || { echo "error: 缺少 Sparkle 辅助程序 $component" >&2; exit 1; } + codesign --force --sign "$IDENTITY" "${STAMP[@]}" "${OPTIONS[@]}" "$component" +done +codesign --force --sign "$IDENTITY" "${STAMP[@]}" "${OPTIONS[@]}" "$FRAMEWORK" +if [ "$IDENTITY" = "-" ]; then + # 无团队的临时签名无法通过 Hardened Runtime 的动态库团队校验,仅本地/CI 关闭 runtime。 + # Xcode 的无证书路径不签名,由此处补齐自身二进制及 entitlement;线上安装仍禁止此构建。 + ROOT="$(cd "$(dirname "$0")/.." && pwd)" + codesign --force --sign - --timestamp=none --options 0 "$APP/Contents/MacOS/XStatsHelper" + codesign --force --sign - --timestamp=none --options 0 --entitlements "$ROOT/Widget/XStatsWidget.entitlements" "$APP/Contents/PlugIns/XStatsWidget.appex" + codesign --force --sign - --timestamp=none --options 0 --entitlements "$ROOT/App/XStats.entitlements" "$APP" +else + codesign --force --sign "$IDENTITY" "${STAMP[@]}" --preserve-metadata=identifier,entitlements,flags "$APP" +fi +codesign --verify --deep --strict "$APP" diff --git a/scripts/sparkle_appcast.py b/scripts/sparkle_appcast.py new file mode 100644 index 0000000..b6dfaaa --- /dev/null +++ b/scripts/sparkle_appcast.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 ysicing +# SPDX-License-Identifier: AGPL-3.0-or-later +"""从旧客户端的 JSON 清单生成、签名或核验对应的 Sparkle XML。私钥只读取钥匙串。""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import plistlib +import subprocess +from urllib.parse import urlsplit +import xml.etree.ElementTree as ET + +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +XSTATS = "https://github.com/ysicing/xstats/appcast" +XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" +# 更新摘要只提供中英文;所有中文语言使用简体中文,其余语言使用英文。 +NOTE_LANGUAGES = ("en", "zh-Hans") +ET.register_namespace("sparkle", SPARKLE) +ET.register_namespace("xstats", XSTATS) +ROOT = Path(__file__).resolve().parent.parent + + +def tools_directory() -> Path: + override = os.environ.get("SPARKLE_TOOLS_DIR") + candidates = [Path(override)] if override else [ + ROOT / "Packages/XStatsKit/.build/artifacts/sparkle/Sparkle/bin", + ROOT / "build/DerivedData-arm64/SourcePackages/artifacts/sparkle/Sparkle/bin", + ] + for directory in candidates: + if all((directory / name).is_file() for name in ("sign_update", "generate_keys")): + return directory + raise ValueError("未找到 Sparkle 签名工具,请先解析 Swift 依赖或设置 SPARKLE_TOOLS_DIR") + + +def run_tool(tool: Path, *arguments: str) -> str: + result = subprocess.run([str(tool), *arguments], capture_output=True, text=True) + if result.returncode: + raise ValueError(f"Sparkle {tool.name} 失败,请检查签名钥匙串账户及制品") + return result.stdout.strip() + + +def archive_digest(archive: Path) -> str: + digest = hashlib.sha256() + with archive.open("rb") as source: + for chunk in iter(lambda: source.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_manifest(manifest: Path, archive: Path) -> dict: + feed = json.loads(manifest.read_text(encoding="utf-8")) + url = urlsplit(feed["url"]) + if url.scheme != "https" or not url.netloc or Path(url.path).name != archive.name or archive.suffix != ".zip": + raise ValueError("清单下载地址与升级 ZIP 不一致") + if feed["sha256"] != archive_digest(archive) or feed["size"] != archive.stat().st_size: + raise ValueError("清单摘要或大小与升级 ZIP 不一致") + if not str(feed["build"]).isdigit() or int(feed["build"]) <= 0 or not feed["notes"]: + raise ValueError("清单构建号或更新摘要无效") + return feed + + +def load_localized_notes(feed: dict, path: Path = ROOT / "ReleaseNotes.json") -> dict[str, list[str]]: + source = json.loads(path.read_text(encoding="utf-8")) + if source.get("version") != feed["version"] or source.get("sourceNotes") != feed["notes"]: + raise ValueError("ReleaseNotes.json 的版本或中文摘要已过期,请同步当前版本译文") + translations = source.get("translations") + required = set(NOTE_LANGUAGES) - {"zh-Hans"} + if not isinstance(translations, dict) or set(translations) != required: + raise ValueError("ReleaseNotes.json 必须包含英文摘要") + for language, notes in translations.items(): + if (not isinstance(notes, list) or len(notes) != len(feed["notes"]) + or not all(isinstance(note, str) and note.strip() and "\n" not in note and "\r" not in note for note in notes)): + raise ValueError(f"ReleaseNotes.json 的 {language} 摘要必须与中文条目一一对应") + return {"zh-Hans": feed["notes"], **translations} + + +def make_feed(feed: dict, signature: str, localized_notes: dict[str, list[str]] | None = None) -> bytes: + if len(base64.b64decode(signature, validate=True)) != 64: + raise ValueError("无效的 Ed25519 更新包签名") + rss = ET.Element("rss", {"version": "2.0"}) + channel = ET.SubElement(rss, "channel") + ET.SubElement(channel, "title").text = "XStats" + ET.SubElement(channel, "link").text = "https://github.com/ysicing/xstats" + ET.SubElement(channel, "description").text = "XStats updates" + item = ET.SubElement(channel, "item") + ET.SubElement(item, "title").text = f"XStats {feed['version']}" + ET.SubElement(item, f"{{{SPARKLE}}}version").text = feed["build"] + ET.SubElement(item, f"{{{SPARKLE}}}shortVersionString").text = feed["version"] + ET.SubElement(item, f"{{{SPARKLE}}}minimumSystemVersion").text = feed["minimumSystem"] + ET.SubElement(item, f"{{{SPARKLE}}}hardwareRequirements").text = "arm64" + notes = localized_notes if localized_notes is not None else {"zh-Hans": feed["notes"]} + for language in NOTE_LANGUAGES: + if language not in notes: + continue + text = "\n".join(notes[language]) + ET.SubElement(item, "description", {XML_LANG: language, f"{{{SPARKLE}}}format": "plain-text"}).text = text + # Sparkle 只留下系统偏好对应的 description;保留各语言原文供应用内即时切换。 + ET.SubElement(item, f"{{{XSTATS}}}notes-{language}").text = text + for field in ("date", "dmg", "sha256"): + if feed.get(field): + ET.SubElement(item, f"{{{XSTATS}}}{field}").text = feed[field] + if feed.get("changelog"): + ET.SubElement(item, f"{{{SPARKLE}}}fullReleaseNotesLink").text = feed["changelog"] + ET.SubElement(item, "enclosure", { + "url": feed["url"], "length": str(feed["size"]), "type": "application/octet-stream", + f"{{{SPARKLE}}}edSignature": signature, f"{{{SPARKLE}}}installationType": "application", + }) + ET.indent(rss) + data = ET.tostring(rss, encoding="utf-8", xml_declaration=True) + b"\n" + if len(data) > 256 * 1024: + raise ValueError("Sparkle XML 超过后端读取上限 256 KiB") + return data + + +def verify_feed(feed: dict, xml: Path, archive: Path, tools: Path, account: str, localized_notes: dict[str, list[str]] | None = None) -> None: + if xml.stat().st_size > 256 * 1024: + raise ValueError("签名后的 Sparkle XML 超过后端读取上限 256 KiB") + items = ET.parse(xml).getroot().findall("channel/item") + if len(items) != 1: + raise ValueError("Sparkle XML 必须只包含一个版本条目") + item = items[0] + if item.findtext(f"{{{SPARKLE}}}hardwareRequirements") != "arm64": + raise ValueError("Sparkle XML 的架构必须为 arm64") + for field, element in (("build", "version"), ("version", "shortVersionString"), ("minimumSystem", "minimumSystemVersion")): + if item.findtext(f"{{{SPARKLE}}}{element}") != feed[field]: + raise ValueError(f"Sparkle XML 与 JSON 的 {field} 不一致") + enclosure = item.find("enclosure") + if (enclosure is None or enclosure.get("url") != feed["url"] + or enclosure.get("length") != str(feed["size"]) + or enclosure.get(f"{{{SPARKLE}}}installationType") != "application"): + raise ValueError("Sparkle XML 与 JSON 的安装包不一致") + expected = {language: "\n".join(notes) for language, notes in + (localized_notes if localized_notes is not None else {"zh-Hans": feed["notes"]}).items()} + descriptions = item.findall("description") + if (len(descriptions) != len(expected) + or {node.get(XML_LANG): node.text for node in descriptions} != expected + or any(node.get(f"{{{SPARKLE}}}format") != "plain-text" for node in descriptions)): + raise ValueError("Sparkle XML 的多语言更新摘要不一致") + prefix = f"{{{XSTATS}}}notes-" + retained = [node for node in item if node.tag.startswith(prefix)] + if (len(retained) != len(expected) + or {node.tag.removeprefix(prefix): node.text for node in retained} != expected): + raise ValueError("Sparkle XML 的应用语言摘要不一致") + for field in ("date", "dmg", "sha256"): + if item.findtext(f"{{{XSTATS}}}{field}") != feed.get(field): + raise ValueError(f"Sparkle XML 与 JSON 的 {field} 不一致") + if item.findtext(f"{{{SPARKLE}}}fullReleaseNotesLink") != feed.get("changelog"): + raise ValueError("Sparkle XML 与 JSON 的更新日志链接不一致") + run_tool(tools / "sign_update", "--account", account, "--verify", str(archive), enclosure.get(f"{{{SPARKLE}}}edSignature", "")) + run_tool(tools / "sign_update", "--account", account, "--verify", str(xml)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("archive", type=Path) + parser.add_argument("--verify", action="store_true") + arguments = parser.parse_args() + tools = tools_directory() + account = os.environ.get("SPARKLE_KEY_ACCOUNT", "work.12306.xstats.sparkle") + # 防止在新机器上误用其他项目的私钥,生成一个客户端无法验证的更新源。 + public_key = run_tool(tools / "generate_keys", "--account", account, "-p") + with (ROOT / "App/Info.plist").open("rb") as source: + expected = plistlib.load(source).get("SUPublicEDKey") + if public_key != expected: + raise ValueError("签名账户的公钥与应用 SUPublicEDKey 不一致") + feed = load_manifest(arguments.manifest, arguments.archive) + localized_notes = load_localized_notes(feed) + xml = arguments.archive.with_suffix(".xml") + if not arguments.verify: + signature = run_tool(tools / "sign_update", "--account", account, "-p", str(arguments.archive)) + xml.write_bytes(make_feed(feed, signature, localized_notes)) + run_tool(tools / "sign_update", "--account", account, str(xml)) + verify_feed(feed, xml, arguments.archive, tools, account, localized_notes) + print(f"Sparkle 更新源已核验:{xml.name}") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, KeyError, ET.ParseError) as error: + raise SystemExit(f"error: {error}") from error diff --git a/scripts/sparkle_appcast_test.py b/scripts/sparkle_appcast_test.py new file mode 100644 index 0000000..fe037c1 --- /dev/null +++ b/scripts/sparkle_appcast_test.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 ysicing +# SPDX-License-Identifier: AGPL-3.0-or-later + +import base64 +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +from sparkle_appcast import NOTE_LANGUAGES, ROOT, SPARKLE, XML_LANG, XSTATS, load_localized_notes, load_manifest, make_feed, verify_feed + + +class SparkleAppcastTests(unittest.TestCase): + def test_xml_preserves_release_and_escapes_notes(self): + feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, + "url": "https://example.test/XStats-1.0.0-AppleSilicon.zip", "notes": [" & 新版本", "line two"], "date": "2026-09-30", + "dmg": "https://example.test/update.dmg", "sha256": "a" * 64, + "changelog": "https://example.test/changelog"} + signature = base64.b64encode(b"s" * 64).decode() + item = ET.fromstring(make_feed(feed, signature)).find("channel/item") + self.assertEqual(item.findtext(f"{{{SPARKLE}}}version"), "200") + self.assertEqual(item.findtext(f"{{{SPARKLE}}}shortVersionString"), "1.0.0") + self.assertEqual(item.findtext(f"{{{SPARKLE}}}hardwareRequirements"), "arm64") + self.assertEqual(item.findtext("description"), " & 新版本\nline two") + self.assertEqual(item.find("description").get(f"{{{SPARKLE}}}format"), "plain-text") + for field in ("date", "dmg", "sha256"): + self.assertEqual(item.findtext(f"{{{XSTATS}}}{field}"), feed[field]) + self.assertEqual(item.findtext(f"{{{SPARKLE}}}fullReleaseNotesLink"), feed["changelog"]) + self.assertEqual(item.find("enclosure").get(f"{{{SPARKLE}}}edSignature"), signature) + with self.assertRaises(ValueError): + make_feed(feed, base64.b64encode(b"short").decode()) + + def test_verify_rejects_wrong_metadata_even_before_signature_tools(self): + feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, + "url": "https://example.test/XStats-1.0.0-AppleSilicon.zip", "notes": ["test"]} + signature = base64.b64encode(b"s" * 64).decode() + with tempfile.TemporaryDirectory() as directory, patch("sparkle_appcast.run_tool") as tool: + xml = Path(directory) / "update.xml" + for field, value in (("build", "201"), ("url", "https://example.test/other.zip"), + ("size", 4), ("notes", ["other"]), ("minimumSystem", "15.0")): + with self.subTest(field=field): + xml.write_bytes(make_feed({**feed, field: value}, signature)) + with self.assertRaises(ValueError): + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") + root = ET.fromstring(make_feed(feed, signature)) + root.find("channel/item").find(f"{{{SPARKLE}}}hardwareRequirements").text = "x86_64" + xml.write_bytes(ET.tostring(root)) + with self.assertRaises(ValueError): + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") + root = ET.fromstring(make_feed(feed, signature)) + root.find("channel").append(ET.fromstring(ET.tostring(root.find("channel/item")))) + xml.write_bytes(ET.tostring(root)) + with self.assertRaises(ValueError): + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") + tool.assert_not_called() + + def test_english_translation_matches_the_current_changelog(self): + from appcast import release_notes + source = json.loads((ROOT / "ReleaseNotes.json").read_text()) + _, notes = release_notes((ROOT / "CHANGELOG.md").read_text(), source["version"]) + localized = load_localized_notes({"version": source["version"], "notes": notes}) + self.assertEqual(set(localized), {"en", "zh-Hans"}) + self.assertEqual(localized["zh-Hans"], notes) + + def test_stale_incomplete_or_invalid_translations_are_rejected(self): + feed = {"version": "1.0.0", "notes": ["source"]} + source = {"version": "1.0.0", "sourceNotes": ["source"], + "translations": {language: [language] for language in NOTE_LANGUAGES if language != "zh-Hans"}} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ReleaseNotes.json" + path.write_text(json.dumps(source)) + self.assertEqual(load_localized_notes(feed, path)["en"], ["en"]) + mutations = [ + {**source, "version": "0.9.0"}, {**source, "sourceNotes": ["stale"]}, + {**source, "translations": {}}, + {**source, "translations": {**source["translations"], "ja": ["extra"]}}, + *[{**source, "translations": {**source["translations"], "en": value}} + for value in ([], [""], ["one", "two"], ["split\nline"], [1])], + ] + for value in mutations: + with self.subTest(value=value): + path.write_text(json.dumps(value)) + with self.assertRaises(ValueError): + load_localized_notes(feed, path) + + def test_signed_xml_contains_native_and_application_language_notes(self): + feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, + "url": "https://example.test/update.zip", "notes": ["中文"]} + localized = {"zh-Hans": feed["notes"], "en": [" & details"]} + signature = base64.b64encode(b"s" * 64).decode() + with tempfile.TemporaryDirectory() as directory, patch("sparkle_appcast.run_tool") as tool: + xml = Path(directory) / "update.xml" + data = make_feed(feed, signature, localized) + xml.write_bytes(data) + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) + self.assertEqual(tool.call_count, 2) + item = ET.fromstring(data).find("channel/item") + descriptions = item.findall("description") + self.assertEqual(descriptions[0].get(XML_LANG), "en") + self.assertEqual({node.get(XML_LANG): node.text for node in descriptions}, + {language: "\n".join(notes) for language, notes in localized.items()}) + for field in ("description", f"{{{XSTATS}}}notes-en"): + root = ET.fromstring(data) + root.find("channel/item").find(field).text = "tampered" + xml.write_bytes(ET.tostring(root)) + tool.reset_mock() + with self.assertRaises(ValueError): + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) + tool.assert_not_called() + root = ET.fromstring(data) + item = root.find("channel/item") + item.append(ET.fromstring(ET.tostring(item.find("description")))) + xml.write_bytes(ET.tostring(root)) + with self.assertRaises(ValueError): + verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) + + def test_feed_size_limit_includes_translations(self): + feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, + "url": "https://example.test/update.zip", "notes": ["source"]} + with self.assertRaises(ValueError): + make_feed(feed, base64.b64encode(b"s" * 64).decode(), {"en": ["x" * (256 * 1024)]}) + + + def test_stale_manifest_or_wrong_archive_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + archive = Path(directory) / "XStats-1.0.0-AppleSilicon.zip" + archive.write_bytes(b"zip") + manifest = Path(directory) / "appcast.json" + feed = {"version": "1.0.0", "build": "200", "size": 3, "notes": ["test"], + "url": f"https://example.test/{archive.name}", "sha256": hashlib.sha256(b"zip").hexdigest()} + manifest.write_text(json.dumps(feed)) + self.assertEqual(load_manifest(manifest, archive), feed) + archive.write_bytes(b"modified archive") + with self.assertRaises(ValueError): + load_manifest(manifest, archive) + archive.write_bytes(b"zip") + feed["url"] = "https://example.test/Another.zip" + manifest.write_text(json.dumps(feed)) + with self.assertRaises(ValueError): + load_manifest(manifest, archive) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/api/main.go b/server/api/main.go index 3aad8d6..ad1a7cd 100644 --- a/server/api/main.go +++ b/server/api/main.go @@ -8,6 +8,7 @@ import ( "embed" "errors" "html/template" + "io" "log" "net/http" "net/url" @@ -108,6 +109,18 @@ func loadConfig(getenv func(string) string) (config, error) { } func newApplication(databasePath string, releaseToken string) (*fiber.App, error) { + return newApplicationWithFeedClient(databasePath, releaseToken, &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if request.URL.Scheme != "https" || len(via) >= 5 { + return errors.New("invalid feed redirect") + } + return nil + }, + }) +} + +func newApplicationWithFeedClient(databasePath string, releaseToken string, feedClient *http.Client) (*fiber.App, error) { databaseLogger := gormlogger.New(log.Default(), gormlogger.Config{ SlowThreshold: 500 * time.Millisecond, LogLevel: gormlogger.Warn, @@ -175,23 +188,7 @@ func newApplication(databasePath string, releaseToken string) (*fiber.App, error !installationIDPattern.MatchString(check.InstallationID) { return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) } - now := time.Now().UTC() - if err := database.Transaction(func(transaction *gorm.DB) error { - var item installation - result := transaction.First(&item, "id = ?", check.InstallationID) - if result.Error != nil && result.Error != gorm.ErrRecordNotFound { - return result.Error - } - if result.Error == gorm.ErrRecordNotFound { - item = installation{ID: check.InstallationID, CurrentVersion: check.CurrentVersion, Checks: 1, FirstSeenAt: now, LastSeenAt: now} - return transaction.Create(&item).Error - } - return transaction.Model(&item).Updates(map[string]any{ - "current_version": check.CurrentVersion, - "last_seen_at": now, - "checks": gorm.Expr("checks + 1"), - }).Error - }); err != nil { + if err := recordUpdateCheck(database, check); err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not record update check"}) } @@ -201,6 +198,48 @@ func newApplication(databasePath string, releaseToken string) (*fiber.App, error } return c.JSON(current) }) + // 固定入口选择当前发布对应的不可变 XML;保留签名字节,不解析重写或在服务端持有私钥。 + // 统计与本次 GET 合并,Sparkle 客户端无需再发一份 JSON POST。 + app.Get("/api/v1/update/appcast.xml", checkLimiter, func(c fiber.Ctx) error { + c.Set(fiber.HeaderCacheControl, "no-store") + check := updateCheck{CurrentVersion: c.Query("current_version"), InstallationID: c.Query("installation_id")} + if check.CurrentVersion == "" || len(check.CurrentVersion) > 64 || !installationIDPattern.MatchString(check.InstallationID) { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + var current release + if err := database.Where("is_current = ?", true).First(¤t).Error; err != nil { + return c.SendStatus(http.StatusServiceUnavailable) + } + asset, err := url.Parse(current.URL) + if err != nil || asset.Scheme != "https" || asset.Host == "" || !strings.HasSuffix(asset.Path, ".zip") { + return c.SendStatus(http.StatusServiceUnavailable) + } + asset.Path = strings.TrimSuffix(asset.Path, ".zip") + ".xml" + asset.RawPath, asset.RawQuery, asset.Fragment = "", "", "" + request, err := http.NewRequestWithContext(c.Context(), http.MethodGet, asset.String(), nil) + if err != nil { + return c.SendStatus(http.StatusBadGateway) + } + response, err := feedClient.Do(request) + if err != nil { + return c.SendStatus(http.StatusBadGateway) + } + defer response.Body.Close() + // 单版本清单有界读取;CDN 错误页、大响应和重定向降级不能成为更新源。 + const maximumFeedBytes = 256 * 1024 + if response.StatusCode != http.StatusOK || response.Request.URL.Scheme != "https" { + return c.SendStatus(http.StatusBadGateway) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maximumFeedBytes+1)) + if err != nil || len(data) == 0 || len(data) > maximumFeedBytes { + return c.SendStatus(http.StatusBadGateway) + } + if err := recordUpdateCheck(database, check); err != nil { + return c.SendStatus(http.StatusInternalServerError) + } + c.Type("xml", "utf-8") + return c.Send(data) + }) app.Get("/stats", func(c fiber.Ctx) error { var data dashboardData data.GeneratedAt = time.Now().Format("2006-01-02 15:04:05 MST") @@ -235,6 +274,26 @@ func newApplication(databasePath string, releaseToken string) (*fiber.App, error return app, nil } +func recordUpdateCheck(database *gorm.DB, check updateCheck) error { + now := time.Now().UTC() + return database.Transaction(func(transaction *gorm.DB) error { + var item installation + result := transaction.First(&item, "id = ?", check.InstallationID) + if result.Error != nil && result.Error != gorm.ErrRecordNotFound { + return result.Error + } + if result.Error == gorm.ErrRecordNotFound { + item = installation{ID: check.InstallationID, CurrentVersion: check.CurrentVersion, Checks: 1, FirstSeenAt: now, LastSeenAt: now} + return transaction.Create(&item).Error + } + return transaction.Model(&item).Updates(map[string]any{ + "current_version": check.CurrentVersion, + "last_seen_at": now, + "checks": gorm.Expr("checks + 1"), + }).Error + }) +} + func main() { configuration, err := loadConfig(os.Getenv) if err != nil { diff --git a/server/api/sparkle_test.go b/server/api/sparkle_test.go new file mode 100644 index 0000000..a37738e --- /dev/null +++ b/server/api/sparkle_test.go @@ -0,0 +1,142 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/libtnb/sqlite" + "gorm.io/gorm" +) + +type feedTransport func(*http.Request) (*http.Response, error) + +func (f feedTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string) { + t.Helper() + body := fmt.Sprintf(`{"version":%q,"build":%q,"date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/XStats-%s.zip","sha256":"%s","size":1000,"notes":["test"]}`, version, build, version, strings.Repeat("a", 64)) + req := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer release-secret") + res, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + t.Fatalf("publish: %d", res.StatusCode) + } +} + +func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) { + bytes := "\n\n\n" + var urls []string + client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) { + urls = append(urls, r.URL.String()) + if r.URL.RawQuery != "" { + t.Fatal("installation identifier forwarded to CDN") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(bytes)), Header: make(http.Header), Request: r}, nil + })} + databasePath := t.TempDir() + "/xstats.sqlite" + app, err := newApplicationWithFeedClient(databasePath, "release-secret", client) + if err != nil { + t.Fatal(err) + } + endpoint := "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id=" + strings.Repeat("b", 64) + for index, version := range []string{"0.14.1", "0.14.2"} { + publishSparkleFixture(t, app, version, fmt.Sprint(126+index)) + res, err := app.Test(httptest.NewRequest(http.MethodGet, endpoint, nil)) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 || string(data) != bytes || res.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("feed: %d %s", res.StatusCode, data) + } + } + if strings.Join(urls, ",") != "https://example.test/XStats-0.14.1.xml,https://example.test/XStats-0.14.2.xml" { + t.Fatal(urls) + } + database, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + connection, err := database.DB() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + var record installation + if err := database.First(&record, "id = ?", strings.Repeat("b", 64)).Error; err != nil { + t.Fatal(err) + } + if record.Checks != 2 { + t.Fatalf("GET count = %d", record.Checks) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.14.0","installation_id":"`+strings.Repeat("b", 64)+`"}`)) + req.Header.Set("Content-Type", "application/json") + res, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatal(res.StatusCode) + } + if err := database.First(&record, "id = ?", strings.Repeat("b", 64)).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := database.Model(&installation{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 || record.Checks != 3 { + t.Fatalf("legacy/new telemetry: rows %d, checks %d", count, record.Checks) + } +} + +func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T) { + for _, body := range []string{"", strings.Repeat("x", 256*1024+1)} { + calls := 0 + client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: r}, nil + })} + app, err := newApplicationWithFeedClient(t.TempDir()+"/xstats.sqlite", "release-secret", client) + if err != nil { + t.Fatal(err) + } + publishSparkleFixture(t, app, "0.14.1", "126") + for _, query := range []string{"", "?current_version=0.14.0&installation_id=serial"} { + res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml"+query, nil)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 400 { + t.Fatal(res.StatusCode) + } + } + if calls != 0 { + t.Fatal("invalid request reached CDN") + } + res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id="+strings.Repeat("a", 64), nil)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 502 { + t.Fatal(res.StatusCode) + } + } +}