diff --git a/.agents/skills/xstats-release/SKILL.md b/.agents/skills/xstats-release/SKILL.md index 66347a4..b37efbc 100644 --- a/.agents/skills/xstats-release/SKILL.md +++ b/.agents/skills/xstats-release/SKILL.md @@ -28,10 +28,10 @@ Before acting, read the repository `AGENTS.md`, the release section of `DEVELOPM - 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/`. -- 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. +- Before publishing, ensure all configured endpoints across both regions support the fixed `/api/v1/apps/xstats/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. +- Publish the same appcast to all three release endpoints: `apps.12306.work`, `apps-api.xiai.me`, and `apps.china.12306.work`. 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 0ff9df1..acd93de 100644 --- a/.agents/skills/xstats-release/references/runbook.md +++ b/.agents/skills/xstats-release/references/runbook.md @@ -36,6 +36,16 @@ gh auth status The signing identity must be `Developer ID Application` with its private key. Merely having a valid `Apple Development` identity or a working notary profile is insufficient. +Install separate Developer ID provisioning profiles for the app and Widget, both enabling +`group.work.12306.xstats`, and select them with `XSTATS_APP_PROFILE` and `XSTATS_WIDGET_PROFILE`. +Use `SIGN_ID` if the default identity does not match those profiles. Configuration is summarized in +[DEVELOPMENT.md](../../../../DEVELOPMENT.md#本地签名与权限). + +Sparkle signing uses the existing keychain account `work.12306.xstats.sparkle`; its public key must +match `SUPublicEDKey` in `project.yml`. Back up and securely transfer this private key before changing +release machines. `SPARKLE_KEY_ACCOUNT` selects an existing account and `SPARKLE_TOOLS_DIR` selects +the official signing tools. Missing keys are a blocker; do not generate or rotate them during a release. + For the one-command path, `XSTATS_RELEASE_TOKEN` must already be available through an authorized environment or credential store. `task release-all` refuses to start when the token is absent, the branch is not `main`, `HEAD` differs from `origin/main`, or the release tag already exists. ## 2. Prepare version metadata @@ -137,12 +147,12 @@ TAP=ysicing/homebrew-tap Its required order is: 1. Verify provenance, clean worktree, and `HEAD == origin/main`. -Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. After the JSON manifest is published, `python3 scripts/publish_api.py --verify-sparkle dist/XStats--AppleSilicon.xml` fetches each regional XML endpoint with the reserved probe installation ID (64 zeros, not counted in statistics) and requires the bytes to match the local signed XML; `publish_release.sh` runs it automatically. 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. +Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. After the JSON manifest is published, `python3 scripts/publish_api.py --verify-sparkle dist/XStats--AppleSilicon.xml` fetches each regional XML endpoint with the reserved probe installation ID (64 zeros, not counted in statistics) and requires the bytes to match the local signed XML; `publish_release.sh` runs it automatically. All configured fixed XML endpoints across both regions 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. +5. Publish the appcast to all three API endpoints (`apps.12306.work`, `apps-api.xiai.me`, `apps.china.12306.work`). 6. Update `Casks/xstats.rb` in `ysicing/homebrew-tap`. If manual recovery is unavoidable, preserve this order. Do not publish an appcast that references an unavailable object. @@ -155,9 +165,9 @@ Prove all of the following from live state: - 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. - 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. +- All configured 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. - Temporary port forwards, credential aliases, debug pods, and staging directories created by this run are removed. -Report separate evidence for signing/notarization, object storage, GitHub Release, both update APIs, Homebrew, current CI status, and Git cleanliness. Pending CI alone does not make an otherwise verified release partial; do not claim it passed. If a distribution check is missing or a completed CI failure affects the release, call the release partial rather than complete. +Report separate evidence for signing/notarization, object storage, GitHub Release, all configured update API endpoints, Homebrew, current CI status, and Git cleanliness. Pending CI alone does not make an otherwise verified release partial; do not claim it passed. If a distribution check is missing or a completed CI failure affects the release, call the release partial rather than complete. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f19373f..2b3b9e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,6 +68,10 @@ the mode key (`F0md`) accepts a direct write; M1–M4 first need `Ftst=1` and a `thermalmonitord` lets go. The firmware records only manual or automatic, not who set it, so a fan in manual mode that XStats did not set is shown as controlled by another program. +Fan controls appear only after a positive fan count has been sampled. Unknown capability keeps +sampling demand active; a confirmed count survives transient read failures for the current run. +Capability filtering never overwrites the user's saved display preferences. + ## Helper Protocol 5 removes the identifier-only ad-hoc authentication fallback. Ad-hoc apps refuse helper @@ -117,6 +121,17 @@ entries. When the app cannot be moved, successfully moved residue is removed fro failed items remain available for retry. Reported sizes include only files actually moved. This flow currently does not unregister third-party background services or stop independently running helpers. +Homebrew cleanup uses `brew cleanup --prune=30 --dry-run` for candidates and the native cleanup +command after confirmation. Previews have a 60-second timeout and a 2 MB output limit; execution +keeps bounded diagnostics, supports cancellation, and reports only confirmed reclaimed space. +Homebrew caches stay outside generic cleanup rules; automatic updates and dependency removal are disabled. + +Project-artifact scans run only on request, descend at most six levels, and flag recent seven-day +activity. Scan roots persist locally outside settings backups. App bundles, tracked Git content, +nested repositories, credentials and symlinks cannot become deletion candidates. Before moving a +selected item to Trash, recheck Git tracking and path identity; warn for cloud-synced locations. +Scanning and post-cleanup size checks respond to cancellation without publishing partial measurements. + ## Disk tools The disk page is also where users act on the disk. `DiskToolsController` (`XStatsUI/State`) fronts four @@ -154,6 +169,22 @@ an empty compact toolbar, so the traffic lights sit on the same ground colour as line up with the 40 pt page header; the app switches to a regular activation policy while a window is open and back to accessory when all are closed. +Status-item drawing retains only the previous readings and image; unchanged readings reuse it, +while history styles also compare their history. Settings, language, appearance and layout invalidate +the image. Settings previews stop observing samples when the main window closes or minimizes. +Panel height updates coalesce absolute content measurements within a layout cycle and apply on the +next cycle; they never synchronously accumulate viewport deltas or replace actual interaction state. +Regression coverage includes `StatusPanelLayoutTests`. + +## Rest timers + +`RestSession` uses absolute `mach_continuous_time` deadlines and recalculates after wake instead +of accumulating timer ticks. Rest features default off and require a manual start. Phase changes +preserve their remaining time; mode or duration changes pause the session. Daily completion counts +and HUD placement stay local. Curtains, the menu-bar timer and Mini HUD belong to the main app; +noise is synthesized through `AVAudioSourceNode` only during rest. The Widget reads shared phase +and deadline values, so WidgetKit refresh timing cannot provide second-accurate reminders. + ## AI process explanations `ProcessExplainer` uses only the on-device Apple Intelligence model. It checks framework and @@ -196,8 +227,13 @@ settings picker, backups, month grids and widgets. Menu-bar lunar modes remain a every app language and use localized lunar dates when lunar display is enabled. Disabling lunar display hides its menu-bar presets and text-style controls, renders the saved lunar preset as date and weekday, and stops applying lunar text styles without overwriting them. Older backups preserve those settings. Additional -calendars default off and appear only in selected-day details. There is no event access or -network request at runtime. Use `--snapshot --calendar-only` for deterministic +calendars default off and appear only in selected-day details. EventKit schedules and due reminders +are opt-in, request permission explicitly, and query only the visible panel's 42-day range. They are +read-only and excluded from widgets, settings backups and server requests; list IDs stay local. +Month data caches only the most recent month, keyed by week start and time zone. Event date markers +are computed in the worker actor; notifications coalesce for 300 milliseconds, and closing the panel +cancels pending refreshes. The panel measures its initial height once and scrolls subsequent changes. +Use `--snapshot --calendar-only` for deterministic light/dark calendar screenshots without starting unrelated samplers or scans. ## AI usage and quotas @@ -311,6 +347,10 @@ since that item is the most natural place to discover the feature. Refreshes are queued rather than dropped: a refresh arriving while another is in flight runs after it, so rebuilding the polling cadence cannot mistake a skipped call for a completed initial refresh and then sleep out the whole interval. Stopping cancels the in-flight scan. These settings are local and not synced through WebDAV. +Local usage aggregates are reused only while the file set, metadata, local date and time zone remain +unchanged. In-memory caching retains only the previous metadata and successful aggregate; failed +scans do not populate it. Incremental parsing checkpoints stay in SQLite. Regression coverage includes +`LocalUsageScanCacheTests` and `ScanCancellationTests`. Cache creation is shown separately but already included in the input total. Claude credentials, account data, Cowork containers and subscription limits remain outside the local scanner's scope; the quota reader has no access to session-log content. @@ -381,10 +421,12 @@ uploading artifacts and uses the same bilingual body for release creation and ed `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 +confirmation. China-region locales prefer `https://apps.china.12306.work/api/v1/apps/xstats/update/appcast.xml`; +other locales prefer `https://apps.12306.work/api/v1/apps/xstats/update/appcast.xml`, followed by the +overseas backup `https://apps-api.xiai.me/api/v1/apps/xstats/update/appcast.xml`. China-region order is +China, overseas primary, overseas backup; other locales try overseas primary, overseas backup, China. +Failed feed checks try each remaining endpoint once, serially; the cycle ends after the final failure. +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 @@ -422,19 +464,20 @@ old clients continue using the unchanged JSON response and their existing instal 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`, the fixed signed `GET /api/v1/update/appcast.xml`, and the aggregate -`GET /stats` dashboard. The XML route verifies the original Ed25519 feed signature, RSS structure and -release/archive metadata against the current JSON release before recording a successful check. Invalid -feeds return 502 without changing telemetry; the legacy JSON endpoint remains independent. Both backends -use the client public key and must be updated together if it rotates. Valid XML is returned byte for byte. 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 -endpoint requires `XSTATS_RELEASE_TOKEN`. `scripts/publish_release.sh` uploads the dmg and zip to +Current XStats requests use `POST /api/v1/apps/xstats/update/check`, authenticated +`PUT /api/v1/apps/xstats/releases/current`, and signed `GET /api/v1/apps/xstats/update/appcast.xml`. +The service keeps the original paths for installed clients; both route sets share the same XStats +release and installation records. The in-repository `server/api` retains the legacy implementation +and compatibility tests. The XML route verifies the original Ed25519 feed signature, RSS structure +and release/archive metadata before recording a successful check, then returns the signed bytes +unchanged. Invalid feeds return 502 without changing statistics; JSON checks remain independent. +Both regions use the client public key and must be updated together if it rotates. The service seeds +XStats on first startup, so routine releases keep the existing JSON fields without supplying application +metadata. New applications register their public key through their own publication path. Release and +admin endpoints require `XSTATS_RELEASE_TOKEN`. `scripts/publish_release.sh` uploads the dmg and zip to object storage with `mc`, verifies each one by re-reading it from the CDN, creates the GitHub Release that carries the dmg for manual downloads, and only then submits the generated appcast through -`scripts/publish_api.py` to both regional services. The manifest lands last, so an installed app never +`scripts/publish_api.py` to all three configured endpoints across both regions. The manifest lands last, so an installed app never sees a version whose package is not yet in place. ## WebDAV settings sync diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 960f01f..2ee24b2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -3,224 +3,105 @@ SPDX-License-Identifier: AGPL-3.0-or-later --> # XStats 开发指南 -本文面向 XStats 的二次开发、构建、测试和发布。普通用户请先阅读 -[README.md](README.md)。 +本文用于本地开发、构建、测试和发布。应用使用说明见 [README.md](README.md), +模块边界与实现约束见 [ARCHITECTURE.md](ARCHITECTURE.md)。 ## 开发环境 -需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)、 -[Go 1.25+](https://go.dev/)、[Go Task](https://taskfile.dev/)、以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen)。 +- macOS 14+,Apple Silicon Mac;构建固定使用 `arm64`。 +- Xcode 26+;仅安装 CommandLineTools 无法提供所需的 SwiftUI 宏插件。 +- Python 3、Go 1.25+、Go Task 和 XcodeGen;Go 用于 API 兼容性测试。 ```bash brew install go go-task xcodegen ``` -## 构建与测试 +## 常用命令 + +在仓库根目录执行: ```bash -task generate # 由 project.yml 生成 Xcode 工程 -task build # Release 构建、安装并启动 -task build BUMP=0 INSTALL=0 # 只构建,不更新版本或安装 -task compile CONFIG=Debug # 只编译 Debug,不安装 -task test # 版本脚本、API 和 Swift 单元测试 -task open # 生成工程并用 Xcode 打开 -task clean # 删除 build/ +task generate # 从 project.yml 生成 Xcode 工程 +task build # 构建、安装并启动;构建号递增 +task build BUMP=0 INSTALL=0 # 只构建,不改构建号或安装 +task compile CONFIG=Debug # 只编译 Debug,不安装 +task test # 脚本、Go API 和 Swift 测试 +task open # 生成工程并用 Xcode 打开 +task snapshot # 使用已安装的 App 渲染实时截图 +task version # 查看版本与构建号 +task clean # 删除 build/ ``` -更新与安装统计 API 位于 `server/api`,直接运行一个 Fiber/GORM 程序: +`project.yml` 是 Xcode 工程配置来源,调整 target、entitlement 或签名设置后重新生成工程。 +修改 `CHANGELOG.md` 后运行 `python3 scripts/sync_changelog.py`,同步 README 版本摘要与活跃度图。 + +## 本地签名与权限 + +有 Developer ID Application 证书时默认选择钥匙串中的第一个身份;多张证书时用 +`SIGN_ID` 指定证书指纹。无可用身份时使用 ad-hoc 签名,不能在线安装更新或使用特权辅助工具。 + +主应用与 Widget 的签名需要启用相同的 App Group,并分别安装匹配的 provisioning profile: + +| 项目 | 标识或配置 | +|---|---| +| 主应用 Bundle ID | `work.12306.xstats.app` | +| Widget Bundle ID | `work.12306.xstats.app.widget` | +| App Group | `group.work.12306.xstats` | +| 主应用 profile 名称 | 环境变量 `XSTATS_APP_PROFILE` | +| Widget profile 名称 | 环境变量 `XSTATS_WIDGET_PROFILE` | ```bash -cd server/api -XSTATS_RELEASE_TOKEN='本地测试令牌' go run . +export XSTATS_APP_PROFILE='主应用 profile 名称' +export XSTATS_WIDGET_PROFILE='Widget profile 名称' +task compile CONFIG=Debug ``` -默认监听 `:8080`,数据库为当前目录的 `xstats-api.sqlite`;可通过 `XSTATS_LISTEN` 和 -`XSTATS_DATABASE` 覆盖。`GET /stats` 是聚合统计大屏。正式发版前在环境中设置 -`XSTATS_RELEASE_TOKEN`。发布脚本默认把 `dist/appcast.json` 同时提交到全球与国内服务的 -`PUT /api/v1/releases/current`;可用逗号分隔的 `XSTATS_API_URLS` 覆盖完整列表,旧的 -`XSTATS_API_URL` 仍可覆盖为单个接口。 - -服务镜像可从仓库根目录构建: - -```bash -docker build -f server/api/Dockerfile -t xstats-server:local . -docker run --rm -p 8080:8080 -e XSTATS_RELEASE_TOKEN='本地测试令牌' \ - -v xstats-api-data:/data xstats-server:local -``` - -GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 amd64/arm64 镜像;PR 不运行。 -镜像发布到 `ghcr.io//xstats-server`,标签格式为清洗后的 `<分支>-<完整提交哈希>`。 - -构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 -有 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 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。切换到未收录法定安排的年份时,不查询该年的法定节假日,不计算或沿用当前年份的假期概览与请假建议,仅提示缺少该年数据;返回有数据的年份后恢复展示。 -日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、大陆节假日与调休、传统与公历节日、节气与时令及假期概览;其他地区(含台湾、香港、澳门)默认不启用这些地区相关的可选内容。各地区的月历和单日详情均固定显示星期,不提供独立开关;旧配置与备份中的星期开关被忽略,其他选择保留。藏历与回历各地区均默认关闭。“节气与时令”统一控制二十四节气、三伏、梅雨与数九;月历只显示节气当天、时令阶段起点及出梅,单日详情与单日小组件保留具体天数。旧节气或时令开关任一开启即恢复为开启,全部关闭才恢复为关闭;使用新的设置键使 Widget 旧摘要失效并重新生成。月历不再显示干支,也不提供对应开关;年月日干支只在日期详情中显示,旧偏好与备份中的干支选项被忽略,其他选项保留。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择;曾开关过日历功能的已有安装若未保存日历选项,沿用旧版默认(与中国大陆默认相同,每周从星期一开始),不按地区重新初始化。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”,已启用但次日所在年份安排尚未公布时提示“暂无法判断”;黄历在单日详情中固定显示,旧偏好与备份中的黄历开关被忽略;宜忌详情仍只为选中日期按需计算,小组件复用主应用预计算的八天摘要缓存。 -月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。 -月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。 -用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group; -EventKit 授权还依赖主 App 的 `com.apple.security.personal-information.calendars` entitlement,配置来源为 `project.yml`。即使已有 Info.plist 权限说明,Hardened Runtime 缺少此 entitlement 仍会直接拒绝授权且不弹窗。本地预览和发布时应检查最终 App 签名中的 entitlement;系统授权必须由用户点击允许访问后决定。 - -小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。日历节假日与黄历由主应用预计算,Widget 只读共享摘要;主应用未生成数据时,小组件显示空状态。 -网络测速在检测到 VPN 或代理时提供“直连 / 经代理”选择,默认直连并绑定物理网卡;经代理的延迟使用目标 HTTP 往返而非本机代理提前应答的 TCP 握手。国内三网节点只支持 TCP 建连计时,因此始终尝试直连;无法取得直连路径时显示原因而不提供虚假的延迟。测速线路随设置备份保存,切换线路会清除上一路线的结果。 -番茄钟与护眼休息为默认关闭的本机功能:通用设置只显示模块开关,时长、目标与声音由独立设置弹层中的系统下拉菜单调整;启用后手动开始,可暂停、重置当前阶段或跳过;切换阶段时保留各自剩余时间;有未完成进度时用“继续”恢复,点“开始”从完整时长启动,点“重置”恢复完整时长但保持暂停;跳过或自动进入下一阶段也从完整时长开始;单次模式在一轮专注与休息后暂停,循环模式连续运行,每完成四轮专注进入长休。“工作番茄”需手动点“开始工作”,随后自动交替专注与休息,每四轮进入长休;暂停或切换运行方式不结束工作时段;切换运行方式会暂停当前阶段并保留剩余时间,修改时长会暂停并回到完整专注阶段,但保留已完成轮数;点“结束工作”才结束并回到待开始状态。每日完成数只保存在本机偏好中,不加入设置备份。`RestSession` 用 `mach_continuous_time` 的绝对截止点计算阶段,唤醒后重算而非累加定时器 tick;多屏幕布、独立菜单栏计时入口与 Mini HUD 由主应用创建;HUD 可拖动并保存位置,支持数字、圆环和沙漏样式,Esc 或长按可以跳过,休息时可再专注五分钟。白噪音、粉红噪音和雨声由 `AVAudioSourceNode` 实时合成,仅在休息阶段播放。休息 Widget 从 App Group 读取阶段和预计截止时间,实际刷新受 WidgetKit 管理,不能作为秒级提醒来源。 -主应用与扩展的 Bundle ID 分别是 `work.12306.xstats.app` 和 `work.12306.xstats.app.widget`; -带 App Group 能力的 Developer ID provisioning profile 要分别安装,并通过 -`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。 -若钥匙串里有多张同名证书,应确保所选证书包含在两个 profile 中;可用 `SIGN_ID` 指定对应证书的 SHA-1 指纹。 - -Homebrew 清理使用原生命令:扫描执行 `brew cleanup --prune=30 --dry-run`,确认后执行 `brew cleanup --prune=30`。候选路径和估算空间来自命令预览,释放空间来自执行输出;日志仅记录命令执行汇总,不把预览候选记为已删除。命令中途失败或取消时释放量显示为未确认。不会根据预览路径手动删除文件,也不会回退到扫描固定缓存目录。运行时禁用自动更新、自动移除依赖和分析上报。预览设置 60 秒超时和 2 MB 输出上限;实际清理持续读取输出并只保留有限的末尾诊断文本,不因输出量中断命令。两者都支持取消。Homebrew 缓存与日志排除在通用清理规则之外。 - -清理页顶栏的“项目产物”入口打开独立窗口;项目扫描独立于普通缓存规则,只在用户点击后运行。默认扫描目录来自 Mole 的默认项目位置、Agent 工作树及自动发现的项目目录;“扫描位置”显示当前完整列表,可新增、移除默认或自定义目录,并恢复默认。第一次修改后保存完整列表,被移除的默认目录不会在下次扫描时自动加入;恢复默认会重新发现默认目录。目录配置只保存在本机偏好中,不加入跨设备备份。每个根目录最多向下查找 6 层;最近 7 天有活动的条目会标记提醒。应用包及其内部目录不会被当作项目扫描。Git 跟踪内容、嵌套仓库、部署密钥、符号链接及未完成安全检查的条目不会作为可清理候选;用户逐项选择并确认后,每项移到废纸篓前重新检查 Git 跟踪状态与路径身份。云盘候选会提示同步删除风险。实现不依赖 Mole 命令,也不会在后台常驻扫描。 - -菜单栏应用没有普通窗口,可以用已安装的应用渲染实时截图: - -```bash -/Applications/XStats.app/Contents/MacOS/XStats --snapshot build/snapshots -``` - -修改 `CHANGELOG.md` 后运行以下命令,更新 README 中的版本摘要和活跃度图: - -```bash -python3 scripts/sync_changelog.py -``` +日程权限还需要最终 App 签名包含 `com.apple.security.personal-information.calendars`; +仅有 Info.plist 说明不足以触发授权。授权由用户在应用中主动请求。 ## 发布 -### 版本号 - -公开版本号是标准 semver,真源是 `CHANGELOG.md` 顶部的第一个二级标题,格式 -`## X.Y.Z · YYYY-MM-DD`;数字段不接受前导零,日期必须真实存在,不支持 `-rc` 等后缀。 -内部构建号是单调递增的整数,与日期无关。 - -- `./scripts/version.sh` 显示当前版本和构建号。 -- `./scripts/version.sh build` 只推进构建号,`task build` 会自动调用。 -- `./scripts/version.sh release` 按 CHANGELOG 顶部标题写入公开版本号并推进构建号, - 由 `scripts/release.sh` 调用,不需要手动执行。 - -CI 不参与发版:它只在 push 到 `main` 和 PR 时跑测试与构建校验,不再由 tag 触发, -也不写版本、不建 Release。Actions 摘要仍会显示版本、构建号、Git 引用和提交号, -截图产物名也包含版本。服务器镜像工作流仍使用原有分支规则。发版全部在本地手动执行。 - -### 签名与公证 - -正式分发需要 Apple Developer Program 的 **Developer ID Application** 证书、对应私钥, -以及 `notarytool` 公证凭据。发布脚本会构建 Apple Silicon 版本,检查签名团队、 -安全时间戳、Hardened Runtime、公证票据和 Gatekeeper,然后生成 DMG、在线升级包、Sparkle XML 和 Homebrew cask。 -Sparkle 框架及内置安装器/XPC 逐层裁剪为 arm64 并重签,不能只签框架外层。 - -先将公证凭据保存到钥匙串: +正式分发需要 Developer ID 证书及私钥、公证凭据、`mc`(对象存储别名 `c-ip`)、 +已登录的 `gh`,以及环境变量 `XSTATS_RELEASE_TOKEN`。公证凭据首次保存: ```bash -xcrun notarytool store-credentials XStats \ - --apple-id you@example.com \ - --team-id YOUR_TEAM_ID +xcrun notarytool store-credentials XStats --apple-id you@example.com --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 在发布阶段强制检查。 +发版前将 `CHANGELOG.md` 顶部的 `## 未发布` 替换为 `## X.Y.Z · YYYY-MM-DD`, +同步 `ReleaseNotes.json` 的中英文摘要和四个 README 的版本徽章。 +公开版本取自 CHANGELOG,构建号由脚本递增;`project.yml` 的版本字段由脚本更新。 -然后发布: +源码改动先提交并推送;构建前只保留允许的版本元数据改动。完整发布命令: ```bash -NOTARY_PROFILE=XStats task release +NOTARY_PROFILE=XStats task release-all ``` -`task release` 只构建并校验制品,`task release-all` 会继续对外发布;两者都不会退出、替换或启动当前机器 -`/Applications/XStats.app`。发布版 App 留在 `build/DerivedData-arm64/Build/Products/Release/XStats.app` -供签名、公证和架构核验;只有明确需要本地试用时才单独执行安装。 +此命令执行测试、签名、公证、打包、提交推送版本元数据,并发布制品、API 清单和 Homebrew cask。 +`task release` 只构建制品;已推送 release commit 后若外部发布失败,用 `task publish` 续跑。 +重新执行 `task release-all` 会再次推进构建号。 -如果使用其他 profile,可以覆盖 `NOTARY_PROFILE`。`SKIP_NOTARIZE=1 task release` 只适合本机测试, -生成的包不应公开分发。 +发布构建保存在 `build/DerivedData-arm64/Build/Products/Release/XStats.app`,不安装到本机。 +脚本校验架构、同团队签名、公证、Gatekeeper、构建来源和公开制品哈希。 +Sparkle 私钥使用已有钥匙串账户,构建后的源码或摘要译文变化需要重新构建。 -`release.sh` 开始时只允许版本元数据存在未提交改动,任何源码、脚本或新增文件都会中止构建; -随后在 `dist/release-provenance.json` 记录构建前提交、版本、构建号及版本文件和更新摘要译文哈希。 -`publish_release.sh` 会验证最终提交只改了允许的版本元数据,且工作区、版本文件与构建记录一致, -避免安装包与 Git tag 对应源码不一致。 +更新与发布使用 `/api/v1/apps/xstats/...`,入口为: -准备好 CHANGELOG 正式标题和四个 README 的版本徽章后,可用一个命令执行完整流程: +- 国内:`https://apps.china.12306.work`。 +- 海外主入口:`https://apps.12306.work`。 +- 海外备用入口:`https://apps-api.xiai.me`。 -```bash -# 需要提前通过安全环境提供 XSTATS_RELEASE_TOKEN;该命令会 commit、push 并写入所有发布目标 -task release-all -``` +默认发布和校验覆盖三个入口,固定路径及环境变量覆盖方式见 [发布脚本](scripts/publish_api.py)。 +详细签名配置、制品核验、分步发布与失败恢复见 [发布 runbook](.agents/skills/xstats-release/references/runbook.md)。 -它会依次运行测试、签名公证构建、精确暂存版本元数据、创建并推送 release commit, -再上传对象存储、创建 GitHub Release、发布双区域清单并更新 Homebrew tap。 -如果 release commit 已推送后外部发布失败,修复外部问题后运行 `task publish` 续跑, -不要再次运行 `task release-all`,否则会重复推进构建号。 +## 项目目录 -需要逐阶段审阅或手工恢复时,按下面的分步流程执行。 - -打包完成后按顺序执行: - -```bash -# 1. 检查 dist/ 产物:dmg、zip、同名 xml、appcast.json、xstats.rb -ls dist/ - -# 2. 提交并推送版本改动——publish_release.sh 会校验 HEAD 与 origin/main 一致, -# 它不会替你 commit 或 push -git add project.yml CHANGELOG.md README*.md Assets/readme -git commit -m "chore(release): 1.0.0" -git push - -# 3. 上传安装包到对象存储、建 GitHub Release、提交版本清单、更新 Homebrew tap -./scripts/publish_release.sh -``` - -发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。 -安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致(`go test` 会校验),轮换发布密钥时同步更新两套后端。JSON 发布后,`publish_api.py --verify-sparkle` 以保留探测标识(64 个 `0`,不计入统计)逐区域取回 XML,须与本地已签名文件逐字节一致。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg, -作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新, -`scripts/sync_changelog.py` 不处理徽章。 - -发布前需要先把对应版本写入 `CHANGELOG.md`:把顶部的 `## 未发布` **整体替换**成正式标题, -不要在它上面再留一个 `## 未发布`。标题示例:`## 1.0.0 · 2026-09-21`。 -发布完成后的下一次开发提交再插入新的空 `## 未发布`。 - -## 项目结构 - -- `App/`:应用入口和资源。 -- `Widget/`:WidgetKit 扩展。 -- `Helper/`:特权辅助工具及 launchd 配置。 -- `Packages/XStatsKit/Sources/SMC`:SMC 通信、温度传感器和风扇控制。 -- `Packages/XStatsKit/Sources/Metrics`:CPU、内存、网络、GPU、磁盘、电池、进程和传感器采集。 -- `Packages/XStatsKit/Sources/AIUsage`:本机 Codex / Claude Code 会话日志的只读扫描、Token 统计与增量检查点;订阅额度在 AI 模块启用后自动查询,“显示本地用量”只控制日志扫描与本地 Token 展示。 -- `Packages/XStatsKit/Sources/Cleaner`:清理规则、安全守卫、扫描和执行。 -- `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,旧摘要缺少新字段时沿用原有总开关。 -- `Packages/XStatsKit/Sources/XStatsUI`:界面、设置、弹窗和菜单栏渲染。 - -- `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 对照,以及风格、充电状态和历史曲线的刷新边界。 - -硬件能力:风扇相关入口仅在成功读取到大于 0 的风扇数量后显示;尚未采样、数量未知或明确为 0 时隐藏。未知不等同于无风扇,仍沿既有需求采样重试;已确认的数量保留至本次运行结束,短暂读取失败与转速为 0 不导致入口消失。能力过滤不改写用户保存的展示偏好。 - -性能约束:本机 AI 用量扫描只在文件集合、文件元数据、统计日期或时区变化时重新聚合;进程内仅缓存上一轮元数据与汇总,解析检查点仍保存在 SQLite。扫描失败不复用该轮结果。清理扫描及工具清理后的大小核验必须在目录枚举中响应取消,不能发布部分计量结果。指标弹窗在同一布局轮次合并当前标题栏和滚动内容的绝对高度,下一轮更新窗口;不能同步累加内容与视口差值,也不能用新建视图的默认交互状态覆盖实际高度。定向回归见 `LocalUsageScanCacheTests`、`ScanCancellationTests` 和 `StatusPanelLayoutTests`。 - -更深入的模块边界、采样策略和安全约束见 [ARCHITECTURE.md](ARCHITECTURE.md)。 +| 路径 | 用途 | +|---|---| +| `App/`、`Widget/`、`Helper/` | 主应用、桌面小组件、特权辅助工具 | +| `Packages/XStatsKit/Sources/` | 采集、AI 用量、清理、更新、同步与界面模块 | +| `Packages/XStatsKit/Tests/` | Swift 测试 | +| `server/api/` | 旧更新协议的兼容实现与测试 | +| `scripts/` | 版本、构建、发布与文档同步脚本 | +| `Taskfile.yml`、`project.yml` | 任务入口与 Xcode 工程配置 | diff --git a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift index 90a11c8..f86443f 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift @@ -86,23 +86,24 @@ public enum UpdateArchitecture: Sendable, Equatable { } public enum UpdateFeed { - /// 固定 API 入口原样返回当前发布的签名 XML,同时记录检查;旧 JSON POST 协议保持可用。 + /// XStats 的多应用 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")! + private static let globalURL = URL(string: "https://apps.12306.work/api/v1/apps/xstats/update/check")! + private static let globalBackupURL = URL(string: "https://apps-api.xiai.me/api/v1/apps/xstats/update/check")! + private static let chinaURL = URL(string: "https://apps.china.12306.work/api/v1/apps/xstats/update/check")! public static func prefersChinaEndpoint(locale: Locale = .autoupdatingCurrent) -> Bool { locale.region?.identifier.uppercased() == "CN" } - /// 只串行请求:首选成功后不再访问备用端点,避免同一次检查被两边同时统计。 + /// 只串行请求:成功后不再访问剩余入口,避免同一次检查跨区域或别名重复统计。 public static func checkURLs(prefersChina: Bool) -> [URL] { - prefersChina ? [chinaURL, globalURL] : [globalURL, chinaURL] + prefersChina ? [chinaURL, globalURL, globalBackupURL] : [globalURL, globalBackupURL, chinaURL] } /// 区域 API 沿用相同的应用与系统版本标识,不启用 Sparkle 系统分析。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md index ee9d9ef..2c5effd 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md @@ -1,6 +1,6 @@ # XStats Privacy Policy -Effective date: September 28, 2026 +Effective date: October 2, 2026 This policy explains what XStats processes on your Mac and which features contact external services. XStats-operated services retain only the update-statistics aggregates described in section 2; the app's other persisted data stays on your Mac and is not uploaded to XStats-operated servers. Network tests send requests needed for the test to independent providers, as described in section 3. Contact: i@xiai.me. @@ -17,7 +17,7 @@ You choose whether and where to export diagnostics; they are not uploaded automa ## 2. Update checks and installation statistics -When automatic update checks are enabled, XStats sends its current version and the SHA-256 hash of an installation identifier to the XStats update service according to the schedule selected in Settings → About: quietly while running, at launch, daily, weekly, or monthly. Choosing Never limits these requests to manual checks. Quiet checks still contact the service but do not show an update prompt. China-region devices try `x-stats.china.12306.work` first; others try `xstats-apps.12306.work` first. The other endpoint is tried only if the preferred one fails. The identifier is derived locally from a random value or the Mac's serial number; the raw serial number is not sent. +When automatic update checks are enabled, XStats sends its current version and the SHA-256 hash of an installation identifier to the XStats update service according to the schedule selected in Settings → About: quietly while running, at launch, daily, weekly, or monthly. Choosing Never limits these requests to manual checks. Quiet checks still contact the service but do not show an update prompt. China-region devices try `apps.china.12306.work`, `apps.12306.work`, then `apps-api.xiai.me`; other devices try `apps.12306.work`, `apps-api.xiai.me`, then `apps.china.12306.work`. Each subsequent endpoint is tried serially only if the previous one fails. Checks stop after the first success or the final failure. The identifier is derived locally from a random value or the Mac's serial number; the raw serial number is not sent. The update service's application database stores the hash, current version, first and most recent check times, and cumulative check count. These aggregate records support deduplicated installation counts, version distribution, and activity statistics. They have no automatic expiry. The update service does not create a separate record for every request or store request IP addresses in its application database. It uses IP addresses in memory for one-minute rate limiting. Network infrastructure may process connection metadata under its own rules. diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md index 6ab184d..352d171 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md @@ -1,6 +1,6 @@ # XStats 隐私政策 -生效日期:2026 年 9 月 28 日 +生效日期:2026 年 10 月 2 日 本政策说明 XStats 在 macOS 上处理哪些信息,以及哪些功能会连接外部服务。XStats 自有服务仅保存第 2 节所述的更新统计汇总;应用自身的其他持久化数据保存在本机,不上传到 XStats 自有服务器。联网测试会向相应第三方发送完成测试所需的请求,详见第 3 节。联系邮箱:i@xiai.me。 @@ -17,7 +17,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本 ## 2. 更新检查与安装统计 -启用自动检查更新时,应用按你在“设置 → 关于”选择的策略(运行时静默、启动时、每日、每周或每月)向 XStats 更新服务发送当前版本和安装标识的 SHA-256 哈希,并获取版本清单;选择“从不”时仅在你手动检查后发送。“运行时静默”仍会检查,但不会弹出新版本提示。中国地区优先连接 `x-stats.china.12306.work`,其他地区优先连接 `xstats-apps.12306.work`,首选失败时才尝试另一端。安装标识在本机由随机值或机器序列号生成,序列号原文不发送。 +启用自动检查更新时,应用按你在“设置 → 关于”选择的策略(运行时静默、启动时、每日、每周或每月)向 XStats 更新服务发送当前版本和安装标识的 SHA-256 哈希,并获取版本清单;选择“从不”时仅在你手动检查后发送。“运行时静默”仍会检查,但不会弹出新版本提示。中国地区依次尝试 `apps.china.12306.work`、`apps.12306.work`、`apps-api.xiai.me`;其他地区依次尝试 `apps.12306.work`、`apps-api.xiai.me`、`apps.china.12306.work`。只有前一入口失败时才串行尝试下一入口,成功后停止,全部失败后结束本轮。安装标识在本机由随机值或机器序列号生成,序列号原文不发送。 更新服务的业务数据库保存哈希、当前版本、首次及最近检查时间、累计检查次数,用于去重统计安装量、版本分布和活跃情况。此汇总记录不设自动到期清理;更新服务不建立逐次请求明细,应用服务不将请求 IP 写入业务数据库。请求 IP 会在内存中用于一分钟限流。网络基础设施可能按其自身规则处理连接元数据。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift index b996f89..a838758 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift @@ -181,9 +181,9 @@ final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate { @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 + // 只在尚未收到有效签名 feed 时串行尝试后续入口,每个地址最多一次;下载/安装失败绝不重启安装。 + if error != nil, !loadedFeed, !cancelled, endpointIndex + 1 < endpoints.count { + endpointIndex += 1 retryingFeed = true if userInitiated || action != nil { updater.checkForUpdates() } else { updater.checkForUpdatesInBackground() } diff --git a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift index 6875fbd..f964e68 100644 --- a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift +++ b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift @@ -19,7 +19,7 @@ import Testing } @Test func buildsAnAnonymousInstallationCheckRequest() throws { - let endpoint = try #require(URL(string: "https://x-stats.china.12306.work/api/v1/update/check")) + let endpoint = try #require(URL(string: "https://apps.china.12306.work/api/v1/apps/xstats/update/check")) let request = try UpdateFeed.checkRequest( url: endpoint, currentVersion: "2026.09.21.02", @@ -39,10 +39,11 @@ import Testing } @Test func ordersRegionalEndpointsWithoutDuplicatingRequests() { - let global = "https://xstats-apps.12306.work/api/v1/update/check" - let china = "https://x-stats.china.12306.work/api/v1/update/check" - #expect(UpdateFeed.checkURLs(prefersChina: true).map(\.absoluteString) == [china, global]) - #expect(UpdateFeed.checkURLs(prefersChina: false).map(\.absoluteString) == [global, china]) + let global = "https://apps.12306.work/api/v1/apps/xstats/update/check" + let backup = "https://apps-api.xiai.me/api/v1/apps/xstats/update/check" + let china = "https://apps.china.12306.work/api/v1/apps/xstats/update/check" + #expect(UpdateFeed.checkURLs(prefersChina: true).map(\.absoluteString) == [china, global, backup]) + #expect(UpdateFeed.checkURLs(prefersChina: false).map(\.absoluteString) == [global, backup, china]) #expect(UpdateFeed.prefersChinaEndpoint(locale: Locale(identifier: "zh_CN"))) #expect(!UpdateFeed.prefersChinaEndpoint(locale: Locale(identifier: "en_US"))) } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift index 8afe9a6..6eae4a9 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift @@ -94,8 +94,9 @@ struct SparkleInstallerTests { @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", + "https://apps.china.12306.work/api/v1/apps/xstats/update/appcast.xml", + "https://apps.12306.work/api/v1/apps/xstats/update/appcast.xml", + "https://apps-api.xiai.me/api/v1/apps/xstats/update/appcast.xml", ]) let driver = SparkleInstaller(onPhase: { _ in }, onRelaunch: {}) for selector in ["feedParametersForUpdater:sendingSystemProfile:", "updater:mayPerformUpdateCheck:error:", @@ -181,31 +182,35 @@ struct SparkleInstallerTests { #expect(phases.last == (known ? .available : .idle)) } - @Test func failedPrimaryRegionRetriesFallbackOnceThenReportsFailure() { - let primary = URL(string: "https://primary.example.test/appcast.xml")! - let fallback = URL(string: "https://fallback.example.test/appcast.xml")! + @Test(arguments: [2, 3]) + func failedEndpointsRetryInOrderThenFinishOnce(endpointCount: Int) { + let endpoints = ["primary", "fallback", "last"].prefix(endpointCount).map { + URL(string: "https://\($0).example.test/appcast.xml")! + } var phases: [UpdateController.Phase] = [] var finished: [Bool] = [] - let driver = SparkleInstaller(endpoints: [primary, fallback], onPhase: { phases.append($0) }, onRelaunch: {}) + let driver = SparkleInstaller(endpoints: endpoints, onPhase: { phases.append($0) }, onRelaunch: {}) driver.onCycleFinished = { finished.append($0) } let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver) let offline = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) driver.check(userInitiated: false) - #expect(driver.feedURLString(for: updater) == primary.absoluteString) - // 主区域失败:切到备用区域重试,本轮尚未结束,不能触发失败重排。 - driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline) - #expect(finished.isEmpty) - #expect(driver.feedURLString(for: updater) == fallback.absoluteString) - try? driver.updater(updater, mayPerform: .updatesInBackground) - #expect(driver.feedURLString(for: updater) == fallback.absoluteString, "区域重试不能复位到主区域") - // 备用区域也失败:只结束一次并报告未取得 feed,由控制器按 1 小时重试间隔重排。 + #expect(driver.feedURLString(for: updater) == endpoints.first?.absoluteString) + // 每次失败只前进一个地址,重试途中不能结束本轮或复位到首选地址。 + for endpoint in endpoints.dropFirst() { + driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline) + #expect(finished.isEmpty) + #expect(driver.feedURLString(for: updater) == endpoint.absoluteString) + try? driver.updater(updater, mayPerform: .updatesInBackground) + #expect(driver.feedURLString(for: updater) == endpoint.absoluteString) + } + // 最后一个地址失败后只结束一次;保留最后地址,避免 Sparkle 因换源立即检查。 driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline) #expect(finished == [false]) #expect(phases.last == .idle) - #expect(driver.feedURLString(for: updater) == fallback.absoluteString, "结束后保留最后地址,避免 Sparkle 视为换源") - // 下一轮自动检查重新从主区域开始。 + #expect(driver.feedURLString(for: updater) == endpoints.last?.absoluteString) + // 下一轮自动检查重新从首选地址开始。 try? driver.updater(updater, mayPerform: .updatesInBackground) - #expect(driver.feedURLString(for: updater) == primary.absoluteString) + #expect(driver.feedURLString(for: updater) == endpoints.first?.absoluteString) } @Test func skippingWithdrawnReleaseDoesNotClaimAnUpdateIsAvailable() { diff --git a/scripts/publish_api.py b/scripts/publish_api.py index 6dab9e2..8f63806 100644 --- a/scripts/publish_api.py +++ b/scripts/publish_api.py @@ -15,8 +15,9 @@ from pathlib import Path DEFAULT_ENDPOINTS = ( - "https://xstats-apps.12306.work/api/v1/releases/current", - "https://x-stats.china.12306.work/api/v1/releases/current", + "https://apps.12306.work/api/v1/apps/xstats/releases/current", + "https://apps-api.xiai.me/api/v1/apps/xstats/releases/current", + "https://apps.china.12306.work/api/v1/apps/xstats/releases/current", ) @@ -27,9 +28,9 @@ MAXIMUM_FEED_BYTES = 256 * 1024 def sparkle_feed_url(endpoint: str, query: str = "") -> str: parts = urlsplit(endpoint) - if not parts.path.endswith("/api/v1/releases/current"): + if parts.path != "/api/v1/apps/xstats/releases/current": raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}") - return urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", query, "")) + return urlunsplit((parts.scheme, parts.netloc, "/api/v1/apps/xstats/update/appcast.xml", query, "")) def check_sparkle_endpoints(endpoints: list[str]) -> None: @@ -70,6 +71,9 @@ def publish(appcast: Path, endpoints: list[str], token: str) -> None: raise ValueError("XSTATS_RELEASE_TOKEN 不能为空") if not endpoints: raise ValueError("至少需要一个版本发布接口") + # 先校验全部发布路径,避免后续地址配置错误时已切换部分区域的版本。 + for endpoint in endpoints: + sparkle_feed_url(endpoint) body = appcast.read_bytes() manifest = json.loads(body) if not isinstance(manifest, dict) or not manifest.get("version"): diff --git a/scripts/publish_api_test.py b/scripts/publish_api_test.py index 659b1c3..dc50159 100644 --- a/scripts/publish_api_test.py +++ b/scripts/publish_api_test.py @@ -18,21 +18,37 @@ import publish_api class PublishAPITest(unittest.TestCase): - def test_defaults_to_both_regional_endpoints(self) -> None: + def test_defaults_to_all_regional_endpoints(self) -> None: self.assertEqual(publish_api.configured_endpoints({}), [ - "https://xstats-apps.12306.work/api/v1/releases/current", - "https://x-stats.china.12306.work/api/v1/releases/current", + "https://apps.12306.work/api/v1/apps/xstats/releases/current", + "https://apps-api.xiai.me/api/v1/apps/xstats/releases/current", + "https://apps.china.12306.work/api/v1/apps/xstats/releases/current", ]) - def test_sparkle_gate_checks_both_regions_without_installation_data(self) -> None: + def test_sparkle_feed_uses_fixed_xstats_path(self) -> None: + endpoint = "https://example.test/api/v1/apps/xstats/releases/current?ignored=1#fragment" + self.assertEqual(publish_api.sparkle_feed_url(endpoint, "current_version=release-probe"), + "https://example.test/api/v1/apps/xstats/update/appcast.xml?current_version=release-probe") + + def test_sparkle_feed_rejects_unrecognized_release_paths(self) -> None: + for path in ("/releases/current", "/api/v1/releases/current", + "/api/v1/apps/another-app/releases/current", "/proxy/api/v1/apps/xstats/releases/current", + "/api/v1/apps/releases/current", + "/api/v1/apps/xstats/releases/current/", "/api/v1/apps/xstats/update/check", + "/api/v1/apps/UPPER/releases/current", "/api/v1/apps/xstats/other/releases/current"): + with self.subTest(path=path), self.assertRaises(ValueError): + publish_api.sparkle_feed_url(f"https://example.test{path}") + + def test_sparkle_gate_checks_all_endpoints_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", + "https://apps.12306.work/api/v1/apps/xstats/update/appcast.xml", + "https://apps-api.xiai.me/api/v1/apps/xstats/update/appcast.xml", + "https://apps.china.12306.work/api/v1/apps/xstats/update/appcast.xml", ]) for call in send.call_args_list: self.assertNotIn("installation_id", call.args[0].full_url) @@ -49,7 +65,7 @@ class PublishAPITest(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml" feed.write_bytes(b"signed") - served = [b"signed", b"signed"] + served = [b"signed"] * 3 responses = [contextlib.nullcontext(type("Response", (), {"status": 200, "read": lambda self, limit, body=body: body})()) for body in served] with patch("publish_api.urllib.request.urlopen", side_effect=responses) as send, \ @@ -57,8 +73,9 @@ class PublishAPITest(unittest.TestCase): publish_api.verify_sparkle_feeds(feed, 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?current_version=release-probe&installation_id=" + "0" * 64, - "https://x-stats.china.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, + "https://apps.12306.work/api/v1/apps/xstats/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, + "https://apps-api.xiai.me/api/v1/apps/xstats/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, + "https://apps.china.12306.work/api/v1/apps/xstats/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, ]) for call in send.call_args_list: self.assertFalse(call.args[0].has_header("Authorization")) @@ -70,9 +87,9 @@ class PublishAPITest(unittest.TestCase): stale = type("Response", (), {"status": 200, "read": lambda self, limit: b"previous"})() ok = type("Response", (), {"status": 200, "read": lambda self, limit: b"signed"})() with patch("publish_api.urllib.request.urlopen", - side_effect=[contextlib.nullcontext(ok), contextlib.nullcontext(stale)]), \ + side_effect=[contextlib.nullcontext(ok), contextlib.nullcontext(ok), contextlib.nullcontext(stale)]), \ contextlib.redirect_stdout(io.StringIO()): - with self.assertRaisesRegex(RuntimeError, "x-stats.china.12306.work"): + with self.assertRaisesRegex(RuntimeError, "apps.china.12306.work"): publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS)) def test_sparkle_gate_rejects_old_server_or_cached_validation(self) -> None: @@ -82,6 +99,20 @@ class PublishAPITest(unittest.TestCase): with self.assertRaises(RuntimeError): publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS)) + def test_publish_validates_all_paths_before_sending_any_request(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "appcast.json" + path.write_text(json.dumps({"version": "1.0.0"}), encoding="utf-8") + valid = "https://example.test/api/v1/apps/xstats/releases/current" + for invalid in ("/api/v1/releases/current", "/api/v1/apps/another-app/releases/current", + "/proxy/api/v1/apps/xstats/releases/current"): + with self.subTest(path=invalid), patch("publish_api.urllib.request.urlopen") as send, \ + contextlib.redirect_stdout(io.StringIO()): + send.return_value.__enter__.return_value.status = 204 + with self.assertRaises(ValueError): + publish_api.publish(path, [valid, f"https://example.test{invalid}"], "release-secret") + send.assert_not_called() + def test_sends_manifest_with_bearer_token(self) -> None: received = [] @@ -117,15 +148,16 @@ class PublishAPITest(unittest.TestCase): } path = Path(directory) / "appcast.json" path.write_text(json.dumps(manifest), encoding="utf-8") - base = f"http://127.0.0.1:{server.server_port}" + endpoints = [f"http://{host}:{server.server_port}/api/v1/apps/xstats/releases/current" + for host in ("127.0.0.1", "localhost", "127.0.0.1")] with contextlib.redirect_stdout(io.StringIO()): - publish_api.publish(path, [f"{base}/global", f"{base}/china"], "release-secret") + publish_api.publish(path, endpoints, "release-secret") finally: server.shutdown() thread.join() server.server_close() - self.assertEqual([item["path"] for item in received], ["/global", "/china"]) + self.assertEqual([item["path"] for item in received], ["/api/v1/apps/xstats/releases/current"] * 3) for item in received: self.assertEqual(item["authorization"], "Bearer release-secret") self.assertEqual(item["content_type"], "application/json") diff --git a/web/privacy.html b/web/privacy.html index 5afedd8..31541d5 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -24,7 +24,7 @@

← 返回 XStats

隐私政策

-

最后更新:2026-09-28。本页说明 XStats 与区域更新服务会处理哪些数据。

+

最后更新:2026-10-02。本页说明 XStats 与区域更新服务会处理哪些数据。

@@ -36,7 +36,7 @@
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
  • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器
  • 连接探测打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭
  • -
  • 检查更新按设置的检查策略发送当前版本和安装标识的 SHA-256,并读取版本清单;中国地区优先请求 x-stats.china.12306.work,其他地区优先请求 xstats-apps.12306.work,失败时才串行回退;关闭自动检查更新后不再自动请求
  • +
  • 检查更新按设置的检查策略发送当前版本和安装标识的 SHA-256,并读取版本清单;中国地区依次尝试 apps.china.12306.work、apps.12306.work、apps-api.xiai.me,其他地区依次尝试 apps.12306.work、apps-api.xiai.me、apps.china.12306.work;仅失败时串行尝试下一入口,成功或全部失败后停止;关闭自动检查更新后不再自动请求