diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 62d7953..17b8aa1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -203,10 +203,14 @@ popover / page or the System page is open, every five minutes when the menu bar low-battery hint or a Mac without a battery), otherwise not at all. `AppModel.bluetoothDemand` derives that from the same visibility state as `demand`. -`UpdateController` posts the current version and a hashed random installation ID to -`https://getopenstats.com/api/v1/update/check` at launch and daily. The original 32-byte random value is -generated with `SecRandomCopyBytes` and remains in the device-only Keychain; no hardware serial number is -used. The response has the same release manifest fields previously read from the static appcast (version, +`UpdateController` posts the current version and a hashed random installation ID at launch and daily. +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 original 32-byte +random value is generated with `SecRandomCopyBytes` and remains in the device-only Keychain; no hardware +serial number is used. 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 @@ -222,8 +226,8 @@ helper, re-registers the bundled version, and verifies the protocol before privi 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 artifacts first and then -submits the generated appcast through `Scripts/publish_api.py`, keeping the static and API manifests based -on the same release metadata. +submits the generated appcast through `Scripts/publish_api.py` to both regional services, keeping the +static and API manifests based on the same release metadata. `server/api/Dockerfile` cross-compiles a CGO-free binary for amd64 and arm64, then runs it as the distroless `nonroot` user with `/data` as the writable SQLite volume. When a branch push changes diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4584cdd..8e8c179 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,8 +36,9 @@ XSTATS_RELEASE_TOKEN='本地测试令牌' go run . 默认监听 `:8080`,数据库为当前目录的 `xstats-api.sqlite`;可通过 `XSTATS_LISTEN` 和 `XSTATS_DATABASE` 覆盖。`GET /stats` 是聚合统计大屏。正式发版前在环境中设置 -`XSTATS_RELEASE_TOKEN`;如接口地址不是默认值,再设置 `XSTATS_API_URL`。发布脚本会在安装包上传完成后 -把 `dist/appcast.json` 提交到 `PUT /api/v1/releases/current`。 +`XSTATS_RELEASE_TOKEN`。发布脚本默认把 `dist/appcast.json` 同时提交到全球与国内服务的 +`PUT /api/v1/releases/current`;可用逗号分隔的 `XSTATS_API_URLS` 覆盖完整列表,旧的 +`XSTATS_API_URL` 仍可覆盖为单个接口。 服务镜像可从仓库根目录构建: diff --git a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift index 53ccbfc..723b9e7 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift @@ -75,9 +75,24 @@ public enum UpdateArchitecture: Sendable, Equatable { } public enum UpdateFeed { - public static let url = URL(string: "https://getopenstats.com/api/v1/update/check")! + 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")! - public static func checkRequest(currentVersion: String, installationID: String) throws -> URLRequest { + 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] + } + + public static func checkRequest( + url: URL, + currentVersion: String, + installationID: String, + operatingSystemVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + ) throws -> URLRequest { struct Body: Encodable { let currentVersion: String let installationID: String @@ -91,7 +106,10 @@ public enum UpdateFeed { var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("XStats", forHTTPHeaderField: "User-Agent") + 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.httpBody = try JSONEncoder().encode(Body(currentVersion: currentVersion, installationID: installationID)) return request } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index 8eaf29c..1c4b5bb 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -106,18 +106,45 @@ public final class UpdateController { private static func fetch(currentVersion: String) async -> Result { do { let installationID = try InstallationIdentity().hashedID() - let request = try UpdateFeed.checkRequest(currentVersion: currentVersion, installationID: installationID) - let (data, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, http.statusCode != 200 { - return .failure(.download(tr("服务器返回 \(http.statusCode)"))) - } - guard let release = UpdateFeed.parse(data) else { return .failure(.download(tr("版本清单格式不正确"))) } - return .success(release) + 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 diff --git a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift index 9d39065..bdab504 100644 --- a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift +++ b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift @@ -18,13 +18,17 @@ import Testing } @Test func buildsAnAnonymousInstallationCheckRequest() throws { + let endpoint = try #require(URL(string: "https://x-stats.china.12306.work/api/v1/update/check")) let request = try UpdateFeed.checkRequest( + url: endpoint, currentVersion: "2026.09.21.02", - installationID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + installationID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + operatingSystemVersion: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 0) ) - #expect(request.url?.absoluteString == "https://getopenstats.com/api/v1/update/check") + #expect(request.url == endpoint) #expect(request.httpMethod == "POST") #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "User-Agent") == "XStats/2026.09.21.02 (macOS 15.7)") let body = try #require(request.httpBody) let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) #expect(json == [ @@ -33,6 +37,15 @@ 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]) + #expect(UpdateFeed.prefersChinaEndpoint(locale: Locale(identifier: "zh_CN"))) + #expect(!UpdateFeed.prefersChinaEndpoint(locale: Locale(identifier: "en_US"))) + } + @Test func parsesAndRejectsBadFeeds() throws { let good = #"{"version":"0.3.0","build":"3","date":"2026-09-20","minimumSystem":"14.0","url":"https://getopenstats.com/download/OpenStats-0.3.0.zip","sha256":"\#(String(repeating: "a", count: 64))","size":123,"dmg":"https://getopenstats.com/download/OpenStats-0.3.0.dmg","notes":["新增在线升级"],"changelog":null}"# let release = try #require(UpdateFeed.parse(Data(good.utf8))) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index ec7543d..48d6c85 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -122,6 +122,28 @@ private func isolatedDefaults() -> UserDefaults { let controller = UpdateController(settings: AppSettings(defaults: isolatedDefaults())) #expect(controller.installBlockedReason != nil) } + + @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/README.en.md b/README.en.md index 848578d..2032797 100644 --- a/README.en.md +++ b/README.en.md @@ -203,7 +203,9 @@ check in Settings → About: Tencent or your router) once a second while network details are open (every 10 seconds in the background), only while the network item is in the menu bar or network details are open. - **Update check and installation statistics**: at launch and once a day, the app sends the current version and the - SHA-256 of a random installation ID to getopenstats.com and reads the version manifest. The server stores only that + SHA-256 of a random installation ID and reads the version manifest. China-region locales prefer + `x-stats.china.12306.work`; all others prefer `xstats-apps.12306.work`. The app falls back serially to the other endpoint + only after a failure and stops after the first success. The server stores only that hash, the current version, first and last check times, and the check count; it does not store a hardware serial number or persist request IPs (an IP is used only for an in-memory one-minute rate limit). The original random value stays in the local Keychain. Turning off automatic update checks stops these automatic requests. When a new version is out it asks; nothing installs without your click. diff --git a/README.ja.md b/README.ja.md index eea5e7d..57acd8d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -156,7 +156,7 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉 ## データとプライバシー 指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。 -公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を getopenstats.com に送り、バージョン情報を取得します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。 +公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を送信してバージョン情報を取得します。中国地域では `x-stats.china.12306.work`、その他では `xstats-apps.12306.work` を優先し、失敗時のみもう一方へ順番にフォールバックして、最初の成功後は停止します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。 これらは設定で無効化できます。Apple Intelligence によるプロセス説明は端末内で行います。 ### WebDAV 設定同期 diff --git a/README.ko.md b/README.ko.md index 0baa286..8668f5d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -156,7 +156,7 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로 ## 데이터와 개인정보 지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다. -공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 getopenstats.com에 보내고 버전 정보를 읽습니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다. +공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 보내고 버전 정보를 읽습니다. 중국 지역은 `x-stats.china.12306.work`, 그 외 지역은 `xstats-apps.12306.work`를 우선 사용하며, 실패한 경우에만 다른 엔드포인트로 순차 전환하고 첫 성공 후 중지합니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다. 이 기능들은 설정에서 끌 수 있습니다. Apple Intelligence 프로세스 설명은 기기 안에서 처리됩니다. ### WebDAV 설정 동기화 diff --git a/README.md b/README.md index 4016a9c..dbc3067 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 - **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。 - **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 -- **检查更新与安装统计**:启动时与之后每天向 getopenstats.com 发送当前版本和随机安装标识的 SHA-256,并读取版本清单;服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机钥匙串中。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。 +- **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机钥匙串中。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。 WebDAV 只向你配置的服务器传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。 diff --git a/Scripts/publish_api.py b/Scripts/publish_api.py index d1f8a15..93f69a2 100644 --- a/Scripts/publish_api.py +++ b/Scripts/publish_api.py @@ -8,44 +8,60 @@ import json import os import sys import urllib.request +from collections.abc import Mapping from pathlib import Path -DEFAULT_ENDPOINT = "https://getopenstats.com/api/v1/releases/current" +DEFAULT_ENDPOINTS = ( + "https://xstats-apps.12306.work/api/v1/releases/current", + "https://x-stats.china.12306.work/api/v1/releases/current", +) -def publish(appcast: Path, endpoint: str, token: str) -> None: +def publish(appcast: Path, endpoints: list[str], token: str) -> None: if not token: raise ValueError("XSTATS_RELEASE_TOKEN 不能为空") + if not endpoints: + raise ValueError("至少需要一个版本发布接口") body = appcast.read_bytes() manifest = json.loads(body) if not isinstance(manifest, dict) or not manifest.get("version"): raise ValueError(f"{appcast} 不是有效的版本清单") - request = urllib.request.Request( - endpoint, - data=body, - method="PUT", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "User-Agent": "XStats-Release", - }, - ) - with urllib.request.urlopen(request, timeout=30) as response: - if response.status != 204: - raise RuntimeError(f"发布接口返回 {response.status}") + for endpoint in endpoints: + request = urllib.request.Request( + endpoint, + data=body, + method="PUT", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "XStats-Release", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 204: + raise RuntimeError(f"{endpoint} 返回 {response.status}") + print(f"✅ 版本清单已发布到 {endpoint}") + + +def configured_endpoints(environment: Mapping[str, str]) -> list[str]: + configured = environment.get("XSTATS_API_URLS", "") + if configured: + return [item.strip() for item in configured.split(",") if item.strip()] + if endpoint := environment.get("XSTATS_API_URL"): + return [endpoint] + return list(DEFAULT_ENDPOINTS) def main() -> None: if len(sys.argv) != 2: raise SystemExit(f"用法:{sys.argv[0]} ") - endpoint = os.environ.get("XSTATS_API_URL", DEFAULT_ENDPOINT) + endpoints = configured_endpoints(os.environ) token = os.environ.get("XSTATS_RELEASE_TOKEN", "") try: - publish(Path(sys.argv[1]), endpoint, token) + publish(Path(sys.argv[1]), endpoints, token) except Exception as error: raise SystemExit(f"发布版本到 API 失败:{error}") from error - print(f"✅ 版本清单已发布到 {endpoint}") if __name__ == "__main__": diff --git a/Scripts/publish_api_test.py b/Scripts/publish_api_test.py index aad5e0f..c254b6c 100644 --- a/Scripts/publish_api_test.py +++ b/Scripts/publish_api_test.py @@ -2,6 +2,8 @@ # Copyright (C) 2026 ysicing # SPDX-License-Identifier: AGPL-3.0-or-later +import contextlib +import io import json import tempfile import threading @@ -13,15 +15,23 @@ import publish_api class PublishAPITest(unittest.TestCase): + def test_defaults_to_both_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", + ]) + def test_sends_manifest_with_bearer_token(self) -> None: - received = {} + received = [] class Handler(BaseHTTPRequestHandler): def do_PUT(self) -> None: - received["path"] = self.path - received["authorization"] = self.headers.get("Authorization") - received["content_type"] = self.headers.get("Content-Type") - received["body"] = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + received.append({ + "path": self.path, + "authorization": self.headers.get("Authorization"), + "content_type": self.headers.get("Content-Type"), + "body": json.loads(self.rfile.read(int(self.headers["Content-Length"]))), + }) self.send_response(204) self.end_headers() @@ -35,16 +45,19 @@ class PublishAPITest(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "appcast.json" path.write_text('{"version":"2026.09.21.03"}', encoding="utf-8") - publish_api.publish(path, f"http://127.0.0.1:{server.server_port}/api/v1/releases/current", "release-secret") + 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") finally: server.shutdown() thread.join() server.server_close() - self.assertEqual(received["path"], "/api/v1/releases/current") - self.assertEqual(received["authorization"], "Bearer release-secret") - self.assertEqual(received["content_type"], "application/json") - self.assertEqual(received["body"], {"version": "2026.09.21.03"}) + self.assertEqual([item["path"] for item in received], ["/global", "/china"]) + 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"}) if __name__ == "__main__": diff --git a/web/privacy.html b/web/privacy.html index 3603689..0f5e3f7 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -25,7 +25,7 @@

← 返回 XStats

隐私政策

-

最后更新:2026-09-21。本页说明 XStats 与 getopenstats.com 会处理哪些数据。

+

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

@@ -36,7 +36,7 @@
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
  • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器
  • 连接探测打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭
  • -
  • 检查更新启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;关闭自动检查更新后不再自动请求
  • +
  • 检查更新启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;中国地区优先请求 x-stats.china.12306.work,其他地区优先请求 xstats-apps.12306.work,失败时才串行回退;关闭自动检查更新后不再自动请求
  • @@ -48,7 +48,7 @@
  • 不保存的内容随机 ID 原值、硬件序列号、任何监控数据、进程列表、清理记录与历史记录;请求 IP 只用于一分钟内的内存限流,不持久化
  • 用途只用于计算独立安装数、24 小时活跃数和已安装版本分布,不用于广告或跨服务追踪
  • 停止发送在「设置 · 关于」关闭自动检查更新后,不再自动发送;手动检查更新仍会产生一次请求
  • -
  • 设置同步只连接你自己配置的 WebDAV 服务器;不会把设置、密码、监控指标或历史记录发送到 getopenstats.com
  • +
  • 设置同步只连接你自己配置的 WebDAV 服务器;不会把设置、密码、监控指标或历史记录发送到区域更新服务