feat(update): 支持双区域更新与固定 UA

This commit is contained in:
ysicing committed 2026-09-21 19:04:13 +08:00
1 parent 53340115c1
commit e3e3febdbb
13 files changed
+171 -55

No files matched your search

+10 -6
View File
@@ -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/<app-version> (macOS <system-version>)` 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
+3 -2
View File
@@ -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` 仍可覆盖为单个接口。
服务镜像可从仓库根目录构建:
@@ -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
}
@@ -106,18 +106,45 @@ public final class UpdateController {
private static func fetch(currentVersion: String) async -> Result<UpdateRelease, UpdateError> {
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<UpdateRelease, UpdateError> {
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
@@ -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)))
@@ -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 {
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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 設定同期
+1 -1
View File
@@ -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 설정 동기화
+1 -1
View File
@@ -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 凭据。
+34 -18
View File
@@ -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]} <appcast.json>")
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__":
+23 -10
View File
@@ -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__":
+3 -3
View File
@@ -25,7 +25,7 @@
<div class="section-head">
<p><a href="./">← 返回 XStats</a></p>
<h2>隐私政策</h2>
<p>最后更新:2026-09-21。本页说明 XStats 与 getopenstats.com 会处理哪些数据。</p>
<p>最后更新:2026-09-21。本页说明 XStats 与区域更新服务会处理哪些数据。</p>
</div>
<div class="section-head">
@@ -36,7 +36,7 @@
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器</span></li>
<li><b>连接探测</b><span>打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭</span></li>
<li><b>检查更新</b><span>启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;关闭自动检查更新后不再自动请求</span></li>
<li><b>检查更新</b><span>启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;中国地区优先请求 x-stats.china.12306.work,其他地区优先请求 xstats-apps.12306.work,失败时才串行回退;关闭自动检查更新后不再自动请求</span></li>
</ul>
<div class="section-head" style="margin-top: var(--space-16)">
@@ -48,7 +48,7 @@
<li><b>不保存的内容</b><span>随机 ID 原值、硬件序列号、任何监控数据、进程列表、清理记录与历史记录;请求 IP 只用于一分钟内的内存限流,不持久化</span></li>
<li><b>用途</b><span>只用于计算独立安装数、24 小时活跃数和已安装版本分布,不用于广告或跨服务追踪</span></li>
<li><b>停止发送</b><span>在「设置 · 关于」关闭自动检查更新后,不再自动发送;手动检查更新仍会产生一次请求</span></li>
<li><b>设置同步</b><span>只连接你自己配置的 WebDAV 服务器;不会把设置、密码、监控指标或历史记录发送到 getopenstats.com</span></li>
<li><b>设置同步</b><span>只连接你自己配置的 WebDAV 服务器;不会把设置、密码、监控指标或历史记录发送到区域更新服务</span></li>
</ul>
<div class="section-head" style="margin-top: var(--space-16)">