feat(release): 完善版本服务与镜像发布

This commit is contained in:
ysicing committed 2026-09-21 13:06:22 +08:00
1 parent 593112e733
commit ea67453b32
33 files changed
+1546 -180

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
*
!server/api/**
!LICENSE
!LICENSING.md
!ThirdPartyNotices.md
!LICENSES/XStats-API-Dependencies.txt
server/api/*.sqlite*
server/api/api
+64
View File
@@ -0,0 +1,64 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Server image
on:
push:
branches:
- '**'
paths:
- server/**
- .github/workflows/server-image.yml
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Generate image name and tag
id: image
shell: bash
run: |
set -euo pipefail
raw_branch="$GITHUB_REF_NAME"
branch="$(printf '%s' "$raw_branch" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9_.-]+/-/g; s/-+/-/g; s/^[.-]+//; s/[.-]+$//' \
| cut -c1-80)"
if [ -z "$branch" ]; then branch=detached; fi
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/xstats-server"
tag="${branch}-${GITHUB_SHA}"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "### Container image" >> "$GITHUB_STEP_SUMMARY"
echo "\`$image:$tag\`" >> "$GITHUB_STEP_SUMMARY"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: .
file: server/api/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.image.outputs.image }}:${{ steps.image.outputs.tag }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha,scope=xstats-server
cache-to: type=gha,mode=max,scope=xstats-server
+2
View File
@@ -8,3 +8,5 @@ Packages/XStatsKit/Package.resolved
dist/
__pycache__/
docs/superpowers
server/api/*.sqlite*
server/api/api
+20 -2
View File
@@ -26,6 +26,7 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro
menu-bar renderer, app controller, snapshot renderer.
- `Packages/XStatsKit/Tests` — metrics, SMC decoding, cleanup safety, updates, UI logic and
localization.
- `server/api` — the Fiber/GORM update API, SQLite installation counters and server-rendered statistics dashboard.
## Build
@@ -202,8 +203,11 @@ 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` fetches `https://getopenstats.com/download/appcast.json` at launch and daily
(version, date, notes taken from `CHANGELOG.md` by `Scripts/appcast.py`, zip URL, sha256, size). An
`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,
date, notes taken from `CHANGELOG.md` by `Scripts/appcast.py`, zip URL, sha256 and size). An
update is installed only after: sha256 matches, the zip holds exactly one `.app`, its bundle ID and
version match, `SecStaticCodeCheckValidity` passes with a requirement pinned to the running app's
team, and `spctl --assess` accepts it (notarized). The old bundle is renamed into a same-volume
@@ -212,6 +216,20 @@ used when the folder is not writable), and a detached shell waits for the proces
reopening the app. After an update the old helper may still be running; the app unregisters an outdated
helper, re-registers the bundled version, and verifies the protocol before privileged calls resume.
`server/api` is one Go program. Fiber exposes `POST /api/v1/update/check`, authenticated
`PUT /api/v1/releases/current`, and the aggregate `GET /stats` dashboard. GORM uses
`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 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.
`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
`server/**`, `.github/workflows/server-image.yml` builds both platforms and publishes
`ghcr.io/<owner>/xstats-server:<sanitized-branch>-<full-commit-sha>`. Pull requests do not run this workflow.
## WebDAV settings sync
Account login, OAuth callbacks and the old account backend have been removed.
+28 -5
View File
@@ -9,10 +9,10 @@ SPDX-License-Identifier: AGPL-3.0-or-later -->
## 开发环境
需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)、
[Go Task](https://taskfile.dev/)、以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen)。
[Go 1.25+](https://go.dev/)、[Go Task](https://taskfile.dev/)、以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen)。
```bash
brew install go-task xcodegen
brew install go go-task xcodegen
```
## 构建与测试
@@ -22,12 +22,35 @@ task generate # 由 project.yml 生成 Xcode 工程
task build # Release 构建、安装并启动
task build BUMP=0 INSTALL=0 # 只构建,不更新版本或安装
task compile CONFIG=Debug # 只编译 Debug,不安装
task test # 版本脚本回归测试和 Swift 单元测试
task test # 版本脚本、API 和 Swift 单元测试
task open # 生成工程并用 Xcode 打开
task clean # 删除 build/
```
构建默认使用 `Release` 和本机架构。发布脚本通过 `ARCH=arm64` 或 `ARCH=x86_64` 分别构建两种芯片版本。
更新与安装统计 API 位于 `server/api`,直接运行一个 Fiber/GORM 程序:
```bash
cd server/api
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`。
服务镜像可从仓库根目录构建:
```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/<owner>/xstats-server`,标签格式为清洗后的 `<分支>-<完整提交哈希>`。
构建固定使用 `arm64`,仅支持 Apple Silicon Mac。
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中的第一个证书;否则使用项目默认的 ad-hoc 签名。
菜单栏应用没有普通窗口,可以用已安装的应用渲染实时截图:
@@ -58,7 +81,7 @@ task version-next # 预览下一公开版本号
## 发布
正式分发需要 Apple Developer Program 的 **Developer ID Application** 证书、对应私钥,
以及 `notarytool` 公证凭据。发布脚本会分别构建 Apple Silicon 和 Intel 版本,检查签名团队、
以及 `notarytool` 公证凭据。发布脚本会构建 Apple Silicon 版本,检查签名团队、
安全时间戳、Hardened Runtime、公证票据和 Gatekeeper,然后生成 DMG、在线升级包和 Homebrew cask。
先将公证凭据保存到钥匙串:
+112
View File
@@ -0,0 +1,112 @@
XStats API third-party dependency licenses
==========================================
gofiber/fiber v3.5.0
----------------------
MIT License
Copyright (c) 2019-present Fenny and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
libtnb/sqlite v1.2.2
--------------------
MIT License
Copyright (c) 2026-now TreeNewBee
Copyright (c) 2021-2024 glebarez
Copyright (c) 2013-NOW Jinzhu <wosmvp@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
go-gorm/gorm v1.31.2
--------------------
The MIT License (MIT)
Copyright (c) 2013-present Jinzhu <wosmvp@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
modernc.org/sqlite v1.56.0
--------------------------
Copyright (c) 2017 The Sqlite Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,102 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import CryptoKit
import Foundation
import Security
protocol InstallationIDStore {
func read() throws -> Data?
func write(_ value: Data) throws
}
enum InstallationIdentityError: Error, LocalizedError {
case keychain(OSStatus)
case random(OSStatus)
var errorDescription: String? {
switch self {
case .keychain(let status):
(SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)"
case .random(let status):
"Random generator error \(status)"
}
}
}
/// 每次安装首次使用时生成随机值并留存在本机钥匙串;网络侧只使用它的 SHA-256。
public struct InstallationIdentity {
private let store: any InstallationIDStore
private let randomBytes: () throws -> Data
public init() {
self.store = KeychainInstallationIDStore()
self.randomBytes = Self.generateRandomBytes
}
init(store: any InstallationIDStore, randomBytes: @escaping () throws -> Data) {
self.store = store
self.randomBytes = randomBytes
}
/// 返回可上报的稳定哈希;随机原值永不离开钥匙串。
public func hashedID() throws -> String {
let value: Data
if let saved = try store.read() {
value = saved
} else {
value = try randomBytes()
try store.write(value)
}
return SHA256.hash(data: value).map { String(format: "%02x", $0) }.joined()
}
private static func generateRandomBytes() throws -> Data {
var data = Data(count: 32)
let status = data.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
}
guard status == errSecSuccess else { throw InstallationIdentityError.random(status) }
return data
}
}
struct KeychainInstallationIDStore: InstallationIDStore {
private static let service = "work.12306.xstats.installation"
private static let account = "installation-id"
init() {}
func read() throws -> Data? {
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw InstallationIdentityError.keychain(status == errSecSuccess ? errSecDecode : status)
}
return data
}
func write(_ value: Data) throws {
let query = baseQuery
var status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: value] as CFDictionary)
if status == errSecItemNotFound {
var item = query
item[kSecValueData as String] = value
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
status = SecItemAdd(item as CFDictionary, nil)
}
guard status == errSecSuccess else { throw InstallationIdentityError.keychain(status) }
}
private var baseQuery: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
}
}
@@ -1,6 +1,6 @@
import Foundation
/// 官网上的版本清单(https://getopenstats.com/download/appcast.json),由 Scripts/publish_release.sh 生成
/// 更新 API 返回的版本清单;发版时由 Scripts/appcast.py 生成并由 Scripts/publish_release.sh 提交
public struct UpdateRelease: Codable, Sendable, Equatable {
public let version: String
public let build: String
@@ -75,7 +75,26 @@ public enum UpdateArchitecture: Sendable, Equatable {
}
public enum UpdateFeed {
public static let url = URL(string: "https://getopenstats.com/download/appcast.json")!
public static let url = URL(string: "https://getopenstats.com/api/v1/update/check")!
public static func checkRequest(currentVersion: String, installationID: String) throws -> URLRequest {
struct Body: Encodable {
let currentVersion: String
let installationID: String
enum CodingKeys: String, CodingKey {
case currentVersion = "current_version"
case installationID = "installation_id"
}
}
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")
request.httpBody = try JSONEncoder().encode(Body(currentVersion: currentVersion, installationID: installationID))
return request
}
public static func parse(_ data: Data) -> UpdateRelease? {
guard let release = try? JSONDecoder().decode(UpdateRelease.self, from: data),
@@ -78,7 +78,7 @@ public final class UpdateController {
guard !isBusy else { return }
phase = .checking
task = Task {
let result = await Self.fetch()
let result = await Self.fetch(currentVersion: currentVersion)
switch result {
case .success(let feed):
// 只有拿到清单才算检查过:登录时网络常常还没连上,失败后由每小时的定时器重试,而不是等一整天
@@ -103,10 +103,10 @@ public final class UpdateController {
}
}
private static func fetch() async -> Result<UpdateRelease, UpdateError> {
var request = URLRequest(url: UpdateFeed.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.setValue("OpenStats", forHTTPHeaderField: "User-Agent")
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)")))
@@ -0,0 +1,32 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import Updates
@Suite struct InstallationIdentityTests {
@Test func persistsRandomValueAndReturnsOnlyItsHash() throws {
let store = MemoryInstallationIDStore()
var generations = 0
let identity = InstallationIdentity(store: store) {
generations += 1
return Data(0..<32)
}
let first = try identity.hashedID()
let second = try identity.hashedID()
#expect(first == "630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd")
#expect(second == first)
#expect(generations == 1)
#expect(store.value == Data(0..<32))
}
}
private final class MemoryInstallationIDStore: InstallationIDStore, @unchecked Sendable {
var value: Data?
func read() throws -> Data? { value }
func write(_ value: Data) throws { self.value = value }
}
@@ -17,6 +17,22 @@ import Testing
#expect(!UpdateFeed.systemSatisfies("15.0", current: sonoma))
}
@Test func buildsAnAnonymousInstallationCheckRequest() throws {
let request = try UpdateFeed.checkRequest(
currentVersion: "2026.09.21.02",
installationID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
#expect(request.url?.absoluteString == "https://getopenstats.com/api/v1/update/check")
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
let body = try #require(request.httpBody)
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String])
#expect(json == [
"current_version": "2026.09.21.02",
"installation_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
])
}
@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)))
+11 -10
View File
@@ -11,7 +11,7 @@
[![Last commit](https://img.shields.io/github/last-commit/ysicing/xstats?color=black&label=last%20commit)](https://github.com/ysicing/xstats/commits/main)
[![Commit activity](https://img.shields.io/github/commit-activity/m/ysicing/xstats?color=black&label=commits)](https://github.com/ysicing/xstats/graphs/commit-activity)
[![CI](https://github.com/ysicing/xstats/actions/workflows/ci.yml/badge.svg)](https://github.com/ysicing/xstats/actions/workflows/ci.yml)
[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20silicon%20%2F%20Intel-black)](https://github.com/ysicing/xstats/releases)
[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20silicon-black)](https://github.com/ysicing/xstats/releases)
[![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE)
XStats is a macOS menu-bar app that shows what your Mac is doing right now — per-core
@@ -19,7 +19,7 @@ CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and f
lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches, fully
uninstall apps and disable startup items. Network details also check how clean your public IP is —
whether it is flagged as a VPN, proxy, data center or for abuse.
Everything is read on your own Mac, and there is no telemetry. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version.
All monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in the local Keychain. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version.
[Download](https://github.com/ysicing/xstats/releases) ·
[Changelog](CHANGELOG.md) ·
@@ -33,15 +33,13 @@ Everything is read on your own Mac, and there is no telemetry. No XStats account
## Install
Check [GitHub Releases](https://github.com/ysicing/xstats/releases) for published XStats builds.
Choose AppleSilicon or Intel for your Mac. If no build is available, follow the source-build instructions below.
Download the Apple Silicon build from [GitHub Releases](https://github.com/ysicing/xstats/releases).
If no build is available, follow the [development guide](DEVELOPMENT.md) to build from source.
XStats has no website yet. Automatic updates and the existing GeoIP service are unchanged; account login has been replaced with manual WebDAV sync.
The update installer still verifies bundle identity and signatures: packages built for OpenStats cannot replace XStats.
Requires macOS 14 (Sonoma) or later. Developed and tested mainly on Apple silicon; on Intel Macs, Apple
Intelligence process explanations are unavailable, CPU cores are not split into performance and efficiency
groups, and some power and frequency readings may be missing. The interface supports Simplified Chinese,
Requires an Apple silicon Mac with macOS 14 (Sonoma) or later. The interface supports Simplified Chinese,
Traditional Chinese, Japanese, Korean, English, German, Spanish, French and Arabic.
It detects your preferred system language by default. Search and switch languages in Settings → General → Language;
Arabic uses a right-to-left layout.
@@ -192,7 +190,7 @@ unclean exit restores them at the next boot.
Metrics come from the kernel (`host_processor_info`, `host_statistics64`, `sysctl`), IOKit and the
SMC on your own Mac. Preferences live in the app's user defaults and contain nothing personal.
There is no analytics and no telemetry.
Monitoring metrics, history, the hardware serial number and process lists are never uploaded.
Every feature that touches the network can be turned off — the first two in Settings → Network, the update
check in Settings → About:
@@ -204,8 +202,11 @@ check in Settings → About:
- **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud,
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**: at launch and once a day, the app reads a version manifest from getopenstats.com — the
manifest only. When a new version is out it asks; nothing installs without your click.
- **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
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.
WebDAV sync sends only preferences to your configured server. It excludes monitoring data, history and WebDAV credentials.
+6 -7
View File
@@ -16,7 +16,7 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、デ
状態の確認に加えて、ファンの回転数調整、蓋を閉じた状態でのスリープ防止、キャッシュ削除、アプリと関連ファイルの削除、起動項目の管理ができます。
ネットワーク画面では、公開 IP が VPN、プロキシ、データセンター、不正利用などに関連付けられているかも確認できます。
テレメトリーはありません。XStats のアカウントは不要で、自分の WebDAV サーバーを使って設定を手動でバックアップ・復元できます。
監視指標は Mac 内だけで処理され、アップロードされません。XStats のアカウントは不要で、自分の WebDAV サーバーを使って設定を手動でバックアップ・復元できます。更新確認では、匿名のインストール数とバージョン分布を集計するため、現在のバージョンとランダムなインストール ID の SHA-256 を送信します。元のランダム値はローカルのキーチェーンにのみ保存されます。
公開 IP の照会、接続テスト、更新確認などのネットワーク機能は任意です。
[ダウンロード](https://github.com/ysicing/xstats/releases) · [変更履歴(中国語)](CHANGELOG.md) · [開発ガイド(中国語)](DEVELOPMENT.md)
@@ -27,11 +27,10 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、デ
## インストール
[GitHub Releases](https://github.com/ysicing/xstats/releases) で公開されているビルドを確認し、Mac に合わせて AppleSilicon または Intel を選んでください。
まだビルドが公開されていない場合は、下記の手順でソースからビルドできます。
[GitHub Releases](https://github.com/ysicing/xstats/releases) から Apple Silicon 版をダウンロードしてください。
まだビルドが公開されていない場合は、[開発ガイド(中国語)](DEVELOPMENT.md) を参照してソースからビルドできます。
macOS 14(Sonoma)以降が必要です。主に Apple Silicon で開発・検証しています。
Intel Mac では Apple Intelligence によるプロセス説明を利用できず、CPU コアの性能コア/効率コアへの分類や、一部の消費電力・周波数表示にも制限があります。
Apple Silicon Mac と macOS 14(Sonoma)以降が必要です。
アプリは簡体字中国語、繁体字中国語、日本語、韓国語、英語、ドイツ語、スペイン語、フランス語、アラビア語に対応しています。
初期設定ではシステムの優先言語を自動検出します。設定の言語メニューで検索して切り替えられ、アラビア語では右から左のレイアウトを使用します。
@@ -156,8 +155,8 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉
## データとプライバシー
指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。テレメトリー送信はありません。
公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送り、更新確認は getopenstats.com のバージョン情報を取得します。
指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。
公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を getopenstats.com に送り、バージョン情報を取得します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。
これらは設定で無効化できます。Apple Intelligence によるプロセス説明は端末内で行います。
### WebDAV 設定同期
+6 -7
View File
@@ -16,7 +16,7 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도,
팬 속도 조절, 덮개를 닫은 상태에서 실행 유지, 캐시 정리, 앱과 관련 파일 제거, 시작 항목 관리도 지원합니다.
네트워크 화면에서는 공인 IP가 VPN, 프록시, 데이터 센터 또는 악용 기록과 관련되어 있는지 확인할 수 있습니다.
사용 통계를 수집하거나 전송하지 않습니다. XStats 계정 없이 자신의 WebDAV 서버로 설정을 수동 백업하고 복원할 수 있습니다.
모니터링 지표는 Mac 안에서만 처리하며 업로드하지 않습니다. XStats 계정 없이 자신의 WebDAV 서버로 설정을 수동 백업하고 복원할 수 있습니다. 업데이트 확인 시 익명 설치 수와 버전 분포를 집계하기 위해 현재 버전과 무작위 설치 ID의 SHA-256을 전송하며, 원본 무작위 값은 로컬 키체인에만 저장합니다.
공인 IP 조회, 연결 테스트, 업데이트 확인 등의 네트워크 기능은 선택 사항입니다.
[다운로드](https://github.com/ysicing/xstats/releases) · [변경 기록(중국어)](CHANGELOG.md) · [개발 안내(중국어)](DEVELOPMENT.md)
@@ -27,11 +27,10 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도,
## 설치
[GitHub Releases](https://github.com/ysicing/xstats/releases)에서 배포된 빌드를 확인하고 Mac에 맞는 AppleSilicon 또는 Intel 버전을 선택하세요.
아직 배포된 빌드가 없다면 아래 설명에 따라 소스에서 빌드할 수 있습니다.
[GitHub Releases](https://github.com/ysicing/xstats/releases)에서 Apple Silicon 버전을 다운로드하세요.
아직 배포된 빌드가 없다면 [개발 안내(중국어)](DEVELOPMENT.md)를 참고하여 소스에서 빌드할 수 있습니다.
macOS 14(Sonoma) 이상이 필요하며, 주로 Apple Silicon에서 개발하고 검증합니다.
Intel Mac에서는 Apple Intelligence 프로세스 설명을 사용할 수 없고, CPU의 성능 코어/효율 코어 구분과 일부 전력·주파수 표시에도 제한이 있습니다.
Apple Silicon Mac과 macOS 14(Sonoma) 이상이 필요합니다.
앱은 중국어 간체, 중국어 번체, 일본어, 한국어, 영어, 독일어, 스페인어, 프랑스어, 아랍어를 지원합니다.
기본적으로 시스템의 선호 언어를 자동 감지합니다. 설정의 언어 메뉴에서 검색하여 전환할 수 있으며, 아랍어는 오른쪽에서 왼쪽으로 배치됩니다.
@@ -156,8 +155,8 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로
## 데이터와 개인정보
지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 사용 통계를 전송하지 않습니다.
공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보내고, 업데이트 확인은 getopenstats.com의 버전 정보를 읽습니다.
지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다.
공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 getopenstats.com에 보내고 버전 정보를 읽습니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다.
이 기능들은 설정에서 끌 수 있습니다. Apple Intelligence 프로세스 설명은 기기 안에서 처리됩니다.
### WebDAV 설정 동기화
+7 -8
View File
@@ -11,13 +11,13 @@
[![Last commit](https://img.shields.io/github/last-commit/ysicing/xstats?color=black&label=%E6%9C%80%E8%BF%91%E6%8F%90%E4%BA%A4)](https://github.com/ysicing/xstats/commits/main)
[![Commit activity](https://img.shields.io/github/commit-activity/m/ysicing/xstats?color=black&label=%E6%8F%90%E4%BA%A4)](https://github.com/ysicing/xstats/graphs/commit-activity)
[![CI](https://github.com/ysicing/xstats/actions/workflows/ci.yml/badge.svg)](https://github.com/ysicing/xstats/actions/workflows/ci.yml)
[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20%E8%8A%AF%E7%89%87%20%2F%20Intel-black)](https://github.com/ysicing/xstats/releases)
[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20%E8%8A%AF%E7%89%87-black)](https://github.com/ysicing/xstats/releases)
[![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE)
XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、
网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存、彻底卸载应用、停用启动项;
网络详情还能检测公网 IP 的纯净度,看出出口是否被标记为 VPN、代理、机房或有滥用记录。
所有数据都在你自己的 Mac 上读取,没有统计上报。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。
所有监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机钥匙串。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。
[下载](https://github.com/ysicing/xstats/releases) ·
[更新日志](CHANGELOG.md) ·
@@ -31,14 +31,13 @@ XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各
## 安装
在 [GitHub Releases](https://github.com/ysicing/xstats/releases) 查看已发布的 XStats 构建,按 Mac 芯片选择 AppleSilicon 或 Intel。
若尚无构建可下载,请按下文从源码构建。
在 [GitHub Releases](https://github.com/ysicing/xstats/releases) 下载 Apple Silicon 版 XStats。
若尚无构建可下载,请参考 [开发指南](DEVELOPMENT.md) 从源码构建。
XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登录已替换为手动 WebDAV 同步。
更新安装仍严格检查应用身份与签名:旧服务提供的 OpenStats 安装包不能替换 XStats。
需要 macOS 14(Sonoma)或更高版本。主要在 Apple 芯片上开发和测试;Intel 机型上不能用 Apple 智能解释进程,
CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
需要 Apple 芯片和 macOS 14(Sonoma)或更高版本。
界面支持简体中文、繁體中文、日本語、한국어、English、Deutsch、Español、Français 和 العربية。
默认自动检测系统首选语言,可在“设置 · 通用 · 语言”中搜索并切换;阿拉伯语使用从右到左布局。
@@ -206,13 +205,13 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
## 你的数据
指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、`sysctl`)、IOKit 与 SMC。
偏好设置保存在应用自己的 user defaults 中,不含个人信息。没有任何统计与上报。
偏好设置保存在应用自己的 user defaults 中,不含个人信息。监控指标、历史记录、硬件序列号和进程列表不会上传。
会联网的功能都可以关闭。前两项在“设置 → 网络”中,检查更新在“设置 · 关于”中:
- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。
- **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
- **检查更新**:启动时与之后每天向 getopenstats.com 读取一次版本清单,只下载清单本身;发现新版本时提示,由你决定是否安装。
- **检查更新与安装统计**:启动时与之后每天向 getopenstats.com 发送当前版本和随机安装标识的 SHA-256,并读取版本清单;服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机钥匙串中。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。
WebDAV 只向你配置的服务器传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。
+3 -5
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env python3
"""生成应用内在线升级读取的版本清单 appcast.json。
Scripts/appcast.py <版本> <build> <下载地址前缀> <Apple 芯片 zip> <Apple 芯片 dmg> <Intel zip> <Intel dmg> > dist/appcast.json
Scripts/appcast.py <版本> <build> <下载地址前缀> <Apple 芯片 zip> <Apple 芯片 dmg> > dist/appcast.json
顶层的 url / sha256 / size / dmg 是 Apple 芯片版:0.3.0 只有 Apple 芯片版,也只认顶层字段。
Intel 版放在 intel 字段里,0.3.1 起的 Intel 版应用读它。
清单只发布 Apple Silicon 安装包。
更新摘要取自 CHANGELOG.md 中该版本的条目:每条取冒号或句号之前的部分(过短时带上冒号后的内容),最多 10 条。
"""
@@ -63,7 +62,7 @@ def asset(base: str, zip_path: str, dmg_path: str) -> dict:
def main() -> None:
version, build, base, arm_zip, arm_dmg, intel_zip, intel_dmg = sys.argv[1:8]
version, build, base, arm_zip, arm_dmg = sys.argv[1:6]
root = os.path.join(os.path.dirname(__file__), "..")
with open(os.path.join(root, "CHANGELOG.md"), encoding="utf-8") as handle:
date, notes = release_notes(handle.read(), version)
@@ -75,7 +74,6 @@ def main() -> None:
**asset(base, arm_zip, arm_dmg),
"notes": notes,
"changelog": "https://getopenstats.com/#changelog",
"intel": asset(base, intel_zip, intel_dmg),
}
json.dump(feed, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
cd "$(dirname "$0")/.."
compile="$(task --dry compile SIGN_ID= 2>&1)"
grep -q -- "-destination 'generic/platform=macOS' ARCHS=arm64 ONLY_ACTIVE_ARCH=NO" <<< "$compile"
if grep -q 'x86_64' <<< "$compile"; then
echo "默认构建不应包含 x86_64" >&2
exit 1
fi
if grep -q -- '^ -quiet' <<< "$compile"; then
echo "默认构建不应使用会产生 SwiftCompile 伪错误的 -quiet" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
printf 'zip' > "$WORK/XStats-0.6.1-AppleSilicon.zip"
printf 'dmg' > "$WORK/XStats-0.6.1-AppleSilicon.dmg"
python3 Scripts/appcast.py 0.6.1 1 https://example.test \
"$WORK/XStats-0.6.1-AppleSilicon.zip" "$WORK/XStats-0.6.1-AppleSilicon.dmg" > "$WORK/appcast.json"
python3 - "$WORK/appcast.json" <<'PY'
import json
import sys
feed = json.load(open(sys.argv[1], encoding="utf-8"))
assert "intel" not in feed, feed
assert feed["url"].endswith("-AppleSilicon.zip"), feed
assert feed["dmg"].endswith("-AppleSilicon.dmg"), feed
PY
+1 -1
View File
@@ -2,7 +2,7 @@
# 本机只保留一个 XStats:退出正在运行的旧版 → 删除 /Applications 里的旧版 → 把刚编译的新版移进去 → 启动。
# 用移动而不是复制,编译目录里不留第二份;其他位置被系统登记的同名应用会取消登记并提示。
#
# Scripts/install_local.sh build/DerivedData/Build/Products/Release/XStats.app
# Scripts/install_local.sh build/DerivedData-arm64/Build/Products/Release/XStats.app
set -euo pipefail
cd "$(dirname "$0")/.."
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
"""把发布清单提交给 XStats API。"""
import json
import os
import sys
import urllib.request
from pathlib import Path
DEFAULT_ENDPOINT = "https://getopenstats.com/api/v1/releases/current"
def publish(appcast: Path, endpoint: str, token: str) -> None:
if not token:
raise ValueError("XSTATS_RELEASE_TOKEN 不能为空")
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}")
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)
token = os.environ.get("XSTATS_RELEASE_TOKEN", "")
try:
publish(Path(sys.argv[1]), endpoint, token)
except Exception as error:
raise SystemExit(f"发布版本到 API 失败:{error}") from error
print(f"✅ 版本清单已发布到 {endpoint}")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import publish_api
class PublishAPITest(unittest.TestCase):
def test_sends_manifest_with_bearer_token(self) -> None:
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"])))
self.send_response(204)
self.end_headers()
def log_message(self, *_: object) -> None:
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
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")
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"})
if __name__ == "__main__":
unittest.main()
+13 -18
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# 发布已公证的版本:Apple 芯片版与 Intel 版的 DMG、在线升级包上传到官网 /download/,最后更新版本清单 appcast.json(已安装的应用据此提示升级),
# 发布已公证的 Apple Silicon 版 DMG、在线升级包到官网 /download/,最后更新静态清单并提交版本 API(已安装的应用据此提示升级),
# cask 提交到 gentpan/homebrew-tap,并校验线上文件。
#
# ./Scripts/release.sh # 先打包、公证
@@ -12,35 +12,30 @@ KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}"
ROOT="${SITE_ROOT:-/var/www/getopenstats.com}"
TAP="${TAP:-gentpan/homebrew-tap}"
VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
CHIPS=(AppleSilicon Intel)
APPCAST="dist/appcast.json"
CASK="dist/xstats.rb"
SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST")
for chip in "${CHIPS[@]}"; do
for ext in dmg zip; do
file="dist/XStats-${VERSION}-${chip}.${ext}"
[ -f "$file" ] || { echo "缺少 $file,先运行 ./Scripts/release.sh" >&2; exit 1; }
done
for ext in dmg zip; do
file="dist/XStats-${VERSION}-AppleSilicon.${ext}"
[ -f "$file" ] || { echo "缺少 $file,先运行 ./Scripts/release.sh" >&2; exit 1; }
done
for file in "$APPCAST" "$CASK"; do
[ -f "$file" ] || { echo "缺少 $file,先运行 ./Scripts/release.sh" >&2; exit 1; }
done
python3 - "$APPCAST" "$VERSION" "dist/XStats-${VERSION}-AppleSilicon.zip" "dist/XStats-${VERSION}-Intel.zip" <<'PY' \
python3 - "$APPCAST" "$VERSION" "dist/XStats-${VERSION}-AppleSilicon.zip" <<'PY' \
|| { echo "$APPCAST 与升级包不一致" >&2; exit 1; }
import hashlib, json, sys
feed = json.load(open(sys.argv[1]))
digest = lambda path: hashlib.sha256(open(path, "rb").read()).hexdigest()
assert feed["version"] == sys.argv[2] and feed["notes"], feed
assert feed["sha256"] == digest(sys.argv[3]) and feed["url"].endswith("-AppleSilicon.zip"), feed
assert feed["intel"]["sha256"] == digest(sys.argv[4]) and feed["intel"]["url"].endswith("-Intel.zip"), feed
assert "intel" not in feed, feed
PY
grep -q "version \"${VERSION}\"" "$CASK" || { echo "$CASK 的版本不是 ${VERSION}" >&2; exit 1; }
for chip in "${CHIPS[@]}"; do
dmg="dist/XStats-${VERSION}-${chip}.dmg"
xcrun stapler validate "$dmg" >/dev/null || { echo "$dmg 没有装订公证票据" >&2; exit 1; }
grep -q "$(shasum -a 256 "$dmg" | cut -d' ' -f1)" "$CASK" || { echo "$CASK 里的 sha256 与 $dmg 不一致" >&2; exit 1; }
done
dmg="dist/XStats-${VERSION}-AppleSilicon.dmg"
xcrun stapler validate "$dmg" >/dev/null || { echo "$dmg 没有装订公证票据" >&2; exit 1; }
grep -q "$(shasum -a 256 "$dmg" | cut -d' ' -f1)" "$CASK" || { echo "$CASK 里的 sha256 与 $dmg 不一致" >&2; exit 1; }
# 上传:先传临时名再改名,下载中途不会拿到半个文件
"${SSH[@]}" "sudo install -d -o \$(id -un) -m 755 $ROOT/download"
@@ -54,12 +49,12 @@ upload() {
[ "$online" = "$(shasum -a 256 "$1" | cut -d' ' -f1)" ] || { echo "线上 ${name} 校验不一致:$online" >&2; exit 1; }
echo "✅ https://getopenstats.com/download/${name}"
}
for chip in "${CHIPS[@]}"; do
upload "dist/XStats-${VERSION}-${chip}.dmg"
upload "dist/XStats-${VERSION}-${chip}.zip"
done
upload "dist/XStats-${VERSION}-AppleSilicon.dmg"
upload "dist/XStats-${VERSION}-AppleSilicon.zip"
# 版本清单最后发布:安装包都已就位后,已安装的应用才会看到新版本
upload "$APPCAST"
# 新版客户端从 API 读取同一份清单;Token 只从环境变量读取,不出现在命令参数里。
python3 Scripts/publish_api.py "$APPCAST"
# Homebrew tap
WORK="$(mktemp -d)"
+66 -81
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# 发布一个可分发的版本:Apple 芯片版与 Intel 版各自 Developer ID 签名 → 公证 → 装订 → DMG(签名、公证、装订)
# 发布一个可分发的 Apple Silicon 版本:Developer ID 签名 → 公证 → 装订 → DMG(签名、公证、装订)
# → 在线升级包 → 版本清单 → Homebrew cask。
#
# ./Scripts/release.sh
@@ -19,10 +19,7 @@ DIST="${DIST:-dist}"
NOTARY_PROFILE="${NOTARY_PROFILE:-GiantAccel}"
SIGN_ID="${SIGN_ID:-$(security find-identity -v -p codesigning 2>/dev/null \
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/' || true)}"
# 编译架构与文件名里的芯片名:XStats-2026.09.21.01-AppleSilicon.dmg、XStats-2026.09.21.01-Intel.dmg
ARCHS=(arm64 x86_64)
chip() { [ "$1" = arm64 ] && echo AppleSilicon || echo Intel; }
app_for() { echo "build/DerivedData-$1/Build/Products/Release/XStats.app"; }
APP="build/DerivedData-arm64/Build/Products/Release/XStats.app"
# 在线升级的更新摘要取自下一版本的更新日志:发版前把 “## 未发布” 改成 “## 版本 · 日期”
NEXT_VERSION="$(./Scripts/version.sh next)"
@@ -35,7 +32,7 @@ if [ -z "$SIGN_ID" ]; then
fi
TEAM_ID="$(echo "$SIGN_ID" | sed -nE 's/.*\(([A-Z0-9]+)\)$/\1/p')"
# 两种芯片共用一个公开版本号和内部构建号:先推进一次,各自构建时不再推进
# 发布前推进一次公开版本号和内部构建号
./Scripts/version.sh build >/dev/null
VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
BUILD="$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: *"?([0-9]+)"?.*/\1/p' project.yml | head -1)"
@@ -52,94 +49,86 @@ trap 'rm -rf "$WORK"' EXIT
rm -rf "$DIST"
mkdir -p "$DIST"
for arch in "${ARCHS[@]}"; do
APP="$(app_for "$arch")"
NAME="XStats-${VERSION}-$(chip "$arch")"
echo
echo "==== $(chip "$arch")(${arch})===="
NAME="XStats-${VERSION}-AppleSilicon"
echo
echo "==== Apple Silicon(arm64)===="
# ---- 构建 ------------------------------------------------------------------
# ---- 构建 ------------------------------------------------------------------
rm -rf "$APP"
task build CONFIG=Release INSTALL=0 BUMP=0 ARCH="$arch" SIGN_ID="$SIGN_ID"
rm -rf "$APP"
task build CONFIG=Release INSTALL=0 BUMP=0 SIGN_ID="$SIGN_ID"
for binary in "$APP/Contents/MacOS/XStats" "$APP/Contents/MacOS/XStatsHelper" \
"$APP/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget"; do
[ "$(lipo -archs "$binary")" = "$arch" ] \
|| { echo "error: $binary 的架构是 $(lipo -archs "$binary"),应当只有 $arch" >&2; exit 1; }
done
codesign --verify --deep --strict --verbose=2 "$APP"
for binary in "$APP" "$APP/Contents/MacOS/XStatsHelper" "$APP/Contents/PlugIns/XStatsWidget.appex"; do
details="$(codesign -dvv "$binary" 2>&1)"
echo "$details" | grep -q "TeamIdentifier=${TEAM_ID}" \
|| { echo "error: $binary 未使用团队 ${TEAM_ID} 签名" >&2; exit 1; }
echo "$details" | grep -q "Timestamp=" \
|| { echo "error: $binary 缺少安全时间戳" >&2; exit 1; }
echo "$details" | grep -Eq "flags=.*runtime" \
|| { echo "error: $binary 未启用 Hardened Runtime" >&2; exit 1; }
done
# ---- 公证 App --------------------------------------------------------------
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
# ditto 而不是 zip:保留包内的符号链接与扩展属性
ditto -c -k --keepParent "$APP" "$WORK/$NAME-notarize.zip"
notarize "$WORK/$NAME-notarize.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
# 拒绝发布 Gatekeeper 在用户机器上仍会拒绝的包(Intel 版的签名与公证票据同样能在本机校验)
if ! spctl -a -vv "$APP" 2>&1 | grep -q accepted; then
echo "error: Gatekeeper 仍然拒绝该 App,停止发布。" >&2
spctl -a -vv "$APP" || true
exit 1
fi
fi
# ---- DMG -------------------------------------------------------------------
rm -rf "$WORK/dmg"
mkdir -p "$WORK/dmg"
ditto "$APP" "$WORK/dmg/XStats.app"
ln -s /Applications "$WORK/dmg/Applications"
hdiutil create -volname "XStats ${VERSION}" -srcfolder "$WORK/dmg" -ov -format UDZO "$DIST/$NAME.dmg" >/dev/null
codesign --force --timestamp --sign "$SIGN_ID" "$DIST/$NAME.dmg"
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
notarize "$DIST/$NAME.dmg"
xcrun stapler staple "$DIST/$NAME.dmg"
spctl -a -t open --context context:primary-signature -vv "$DIST/$NAME.dmg"
fi
# ---- 在线升级 ----------------------------------------------------------------
# 应用内升级下载已装订票据的 .app 压缩包
ditto -c -k --keepParent "$APP" "$DIST/$NAME.zip"
for binary in "$APP/Contents/MacOS/XStats" "$APP/Contents/MacOS/XStatsHelper" \
"$APP/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget"; do
[ "$(lipo -archs "$binary")" = arm64 ] \
|| { echo "error: $binary 的架构是 $(lipo -archs "$binary"),应当只有 arm64" >&2; exit 1; }
done
codesign --verify --deep --strict --verbose=2 "$APP"
for binary in "$APP" "$APP/Contents/MacOS/XStatsHelper" "$APP/Contents/PlugIns/XStatsWidget.appex"; do
details="$(codesign -dvv "$binary" 2>&1)"
echo "$details" | grep -q "TeamIdentifier=${TEAM_ID}" \
|| { echo "error: $binary 未使用团队 ${TEAM_ID} 签名" >&2; exit 1; }
echo "$details" | grep -q "Timestamp=" \
|| { echo "error: $binary 缺少安全时间戳" >&2; exit 1; }
echo "$details" | grep -Eq "flags=.*runtime" \
|| { echo "error: $binary 未启用 Hardened Runtime" >&2; exit 1; }
done
# 版本清单:顶层是 Apple 芯片版(0.3.0 只认顶层字段),intel 字段是 Intel 版
# ---- 公证 App --------------------------------------------------------------
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
# ditto 而不是 zip:保留包内的符号链接与扩展属性
ditto -c -k --keepParent "$APP" "$WORK/$NAME-notarize.zip"
notarize "$WORK/$NAME-notarize.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
if ! spctl -a -vv "$APP" 2>&1 | grep -q accepted; then
echo "error: Gatekeeper 仍然拒绝该 App,停止发布。" >&2
spctl -a -vv "$APP" || true
exit 1
fi
fi
# ---- DMG -------------------------------------------------------------------
rm -rf "$WORK/dmg"
mkdir -p "$WORK/dmg"
ditto "$APP" "$WORK/dmg/XStats.app"
ln -s /Applications "$WORK/dmg/Applications"
hdiutil create -volname "XStats ${VERSION}" -srcfolder "$WORK/dmg" -ov -format UDZO "$DIST/$NAME.dmg" >/dev/null
codesign --force --timestamp --sign "$SIGN_ID" "$DIST/$NAME.dmg"
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
notarize "$DIST/$NAME.dmg"
xcrun stapler staple "$DIST/$NAME.dmg"
spctl -a -t open --context context:primary-signature -vv "$DIST/$NAME.dmg"
fi
# ---- 在线升级 ----------------------------------------------------------------
# 应用内升级下载已装订票据的 .app 压缩包
ditto -c -k --keepParent "$APP" "$DIST/$NAME.zip"
# 版本清单只包含 Apple Silicon 安装包
python3 Scripts/appcast.py "$VERSION" "$BUILD" "$DOWNLOAD_BASE" \
"$DIST/XStats-${VERSION}-AppleSilicon.zip" "$DIST/XStats-${VERSION}-AppleSilicon.dmg" \
"$DIST/XStats-${VERSION}-Intel.zip" "$DIST/XStats-${VERSION}-Intel.dmg" > "$DIST/appcast.json"
"$DIST/XStats-${VERSION}-AppleSilicon.zip" "$DIST/XStats-${VERSION}-AppleSilicon.dmg" > "$DIST/appcast.json"
# ---- Homebrew cask ------------------------------------------------------------
SHA_ARM="$(shasum -a 256 "$DIST/XStats-${VERSION}-AppleSilicon.dmg" | cut -d' ' -f1)"
SHA_INTEL="$(shasum -a 256 "$DIST/XStats-${VERSION}-Intel.dmg" | cut -d' ' -f1)"
cat > "$DIST/xstats.rb" <<CASK
cask "xstats" do
arch arm: "AppleSilicon", intel: "Intel"
version "${VERSION}"
sha256 arm: "${SHA_ARM}",
intel: "${SHA_INTEL}"
sha256 "${SHA_ARM}"
url "${DOWNLOAD_BASE}/XStats-#{version}-#{arch}.dmg"
url "${DOWNLOAD_BASE}/XStats-#{version}-AppleSilicon.dmg"
name "XStats"
desc "Menu bar system monitor with fan control, keep-awake and cleanup"
homepage "https://github.com/ysicing/xstats"
# 应用内置在线升级,brew upgrade 默认不再重复升级
auto_updates true
depends_on arch: :arm64
depends_on macos: :sonoma
app "XStats.app"
@@ -151,15 +140,11 @@ cask "xstats" do
end
CASK
# 本机只保留一份:把本机芯片的发布版装到 /Applications,另一种芯片的编译产物由安装脚本一并清掉
native="$(uname -m)"
./Scripts/install_local.sh "$(app_for "$native")"
# 本机只保留一份:把发布版装到 /Applications
./Scripts/install_local.sh "$APP"
echo
for arch in "${ARCHS[@]}"; do
NAME="XStats-${VERSION}-$(chip "$arch")"
echo "✅ ${DIST}/${NAME}.dmg SHA-256 $(shasum -a 256 "$DIST/$NAME.dmg" | cut -d' ' -f1)"
done
echo "✅ ${DIST}/${NAME}.dmg SHA-256 $(shasum -a 256 "$DIST/$NAME.dmg" | cut -d' ' -f1)"
echo " 在线升级:${DIST}/*.zip · ${DIST}/appcast.json"
echo " Homebrew cask:${DIST}/xstats.rb"
if [ "${SKIP_NOTARIZE:-0}" = "1" ]; then
+12 -6
View File
@@ -7,8 +7,7 @@ vars:
PROJECT: XStats.xcodeproj
SCHEME: XStats
CONFIG: '{{default "Release" .CONFIG}}'
ARCH: '{{default "" .ARCH}}'
DERIVED: '{{if .ARCH}}build/DerivedData-{{.ARCH}}{{else}}build/DerivedData{{end}}'
DERIVED: build/DerivedData-arm64
APP: '{{.DERIVED}}/Build/Products/{{.CONFIG}}/XStats.app'
BUMP: '{{default "1" .BUMP}}'
INSTALL: '{{default "1" .INSTALL}}'
@@ -36,7 +35,6 @@ tasks:
- task: compile
vars:
CONFIG: '{{.CONFIG}}'
ARCH: '{{.ARCH}}'
SIGN_ID: '{{.SIGN_ID}}'
- |
if [ "{{.INSTALL}}" != "0" ]; then ./Scripts/install_local.sh "{{.APP}}"; fi
@@ -48,8 +46,7 @@ tasks:
- |
xcodebuild -project "{{.PROJECT}}" -scheme "{{.SCHEME}}" -configuration "{{.CONFIG}}" \
-derivedDataPath "{{.DERIVED}}" \
{{if .ARCH}}-destination 'generic/platform=macOS' ARCHS={{.ARCH}} ONLY_ACTIVE_ARCH=NO{{else}}-destination 'platform=macOS'{{end}} \
-quiet \
-destination 'generic/platform=macOS' ARCHS=arm64 ONLY_ACTIVE_ARCH=NO \
{{if .TEAM_ID}}CODE_SIGN_IDENTITY="{{.SIGN_ID}}" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM={{.TEAM_ID}}{{if eq .CONFIG "Release"}} OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO{{end}}{{end}} \
build
@@ -73,11 +70,20 @@ tasks:
ignore_error: true
test:
desc: 运行版本脚本和 Swift 单元测试
desc: 运行版本脚本、API 和 Swift 单元测试
cmds:
- bash Scripts/version_test.sh
- bash Scripts/arm64_release_test.sh
- python3 Scripts/publish_api_test.py
- task: test-api
- task: test-swift
test-api:
internal: true
dir: server/api
cmds:
- go test ./...
test-swift:
internal: true
dir: Packages/XStatsKit
+11
View File
@@ -12,6 +12,17 @@ XStats 基于 [gentpan/OpenStats](https://github.com/gentpan/OpenStats) 开发
`Assets/flags-svg/` 中的国旗 SVG(应用内为 `Scripts/render_flags.sh` 渲染的 PNG)来自
[lipis/flag-icons](https://github.com/lipis/flag-icons),MIT License,Copyright (c) 2013 Panayiotis Lipiridis。
## XStats API Go 依赖
`server/api` 使用以下与 AGPL-3.0-or-later 兼容的依赖:
- [gofiber/fiber](https://github.com/gofiber/fiber) v3.5.0,MIT License,Copyright (c) 2019-present Fenny and Contributors
- [libtnb/sqlite](https://github.com/libtnb/sqlite) v1.2.2,MIT License,Copyright (c) TreeNewBee、glebarez、Jinzhu
- [go-gorm/gorm](https://github.com/go-gorm/gorm) v1.31.2,MIT License,Copyright (c) 2013-present Jinzhu
- [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) v1.56.0(由 libtnb/sqlite 引入),BSD-3-Clause,Copyright (c) 2017 The Sqlite Authors
随二进制分发时需要保留的完整文本见 [LICENSES/XStats-API-Dependencies.txt](LICENSES/XStats-API-Dependencies.txt)。
## 保留的登录品牌标志资源
账号登录入口已移除。以下为暂时保留的旧资源及原使用说明。
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1.7
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM --platform=$BUILDPLATFORM golang:1-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY server/api/go.mod server/api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY server/api/ ./
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/xstats-api . \
&& mkdir -p /out/data
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /data
COPY --from=build --chown=65532:65532 /out/data /data
COPY --from=build /out/xstats-api /usr/local/bin/xstats-api
COPY LICENSE LICENSING.md ThirdPartyNotices.md /licenses/
COPY LICENSES/XStats-API-Dependencies.txt /licenses/LICENSES/
ENV XSTATS_LISTEN=:8080 \
XSTATS_DATABASE=/data/xstats-api.sqlite
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/xstats-api"]
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import "testing"
func TestLoadConfigRequiresReleaseToken(t *testing.T) {
_, err := loadConfig(func(string) string { return "" })
if err == nil {
t.Fatal("loadConfig accepted an empty release token")
}
}
func TestLoadConfigRejectsWhitespaceReleaseToken(t *testing.T) {
_, err := loadConfig(func(key string) string {
if key == "XSTATS_RELEASE_TOKEN" {
return " "
}
return ""
})
if err == nil {
t.Fatal("loadConfig accepted a whitespace-only release token")
}
}
func TestLoadConfigUsesExplicitValues(t *testing.T) {
values := map[string]string{
"XSTATS_LISTEN": "127.0.0.1:9090",
"XSTATS_DATABASE": "/var/lib/xstats/stats.sqlite",
"XSTATS_RELEASE_TOKEN": "secret",
}
config, err := loadConfig(func(key string) string { return values[key] })
if err != nil {
t.Fatal(err)
}
if config.Listen != "127.0.0.1:9090" || config.Database != "/var/lib/xstats/stats.sqlite" || config.ReleaseToken != "secret" {
t.Fatalf("unexpected config: %#v", config)
}
}
+142
View File
@@ -0,0 +1,142 @@
<!--
Copyright (C) 2026 ysicing
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>XStats 安装统计</title>
<style>
:root {
color-scheme: dark;
--ink: #f5f2e9;
--muted: #9ca39e;
--line: rgba(245, 242, 233, .14);
--panel: rgba(18, 23, 21, .78);
--signal: #b9ff5a;
--amber: #ffbc42;
--void: #080b0a;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at 82% 10%, rgba(185, 255, 90, .08), transparent 28rem),
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px),
var(--void);
background-size: auto, 44px 44px, 44px 44px, auto;
font-family: "Avenir Next Condensed", "PingFang SC", sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: .2;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E");
}
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 64px 0 80px; }
header { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; margin-bottom: 42px; }
.eyebrow, .timestamp, .label, .version-meta {
font-family: "SFMono-Regular", Menlo, monospace;
letter-spacing: .12em;
text-transform: uppercase;
}
.eyebrow { color: var(--signal); font-size: 12px; }
h1 { max-width: 760px; margin: 8px 0 0; font-family: "Iowan Old Style", "Songti SC", serif; font-size: clamp(46px, 7vw, 94px); font-weight: 400; line-height: .92; letter-spacing: -.055em; }
.timestamp { color: var(--muted); font-size: 11px; text-align: right; }
.metrics { display: grid; grid-template-columns: 1.3fr 1fr 1fr; border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(16px); }
.metric { position: relative; min-height: 190px; padding: 28px; border-right: 1px solid var(--line); overflow: hidden; }
.metric:last-child { border-right: 0; }
.metric:first-child::after { content: "01"; position: absolute; right: 18px; bottom: -24px; color: rgba(185,255,90,.06); font: 140px/1 "SFMono-Regular", monospace; }
.label { color: var(--muted); font-size: 11px; }
.value { display: block; margin-top: 34px; color: var(--signal); font-family: "SFMono-Regular", Menlo, monospace; font-size: clamp(42px, 6vw, 72px); line-height: 1; font-variant-numeric: tabular-nums; }
.unit { color: var(--muted); font-size: 15px; }
.versions { margin-top: 22px; padding: 28px; border: 1px solid var(--line); background: rgba(10, 14, 12, .72); }
.section-head { display: flex; justify-content: space-between; gap: 20px; align-items: baseline; margin-bottom: 24px; }
h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: .02em; }
.current { color: var(--amber); }
.version-row { display: grid; grid-template-columns: minmax(160px, 1fr) 4fr 70px; gap: 20px; align-items: center; padding: 15px 0; border-top: 1px solid var(--line); }
.version-name { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; }
.track { height: 8px; background: rgba(245,242,233,.08); overflow: hidden; }
.fill { height: 100%; min-width: 2px; width: max(2px, calc(var(--percent) * 1%)); background: linear-gradient(90deg, var(--signal), #f0ffbd); box-shadow: 0 0 18px rgba(185,255,90,.25); }
.version-meta { color: var(--muted); font-size: 10px; text-align: right; }
.empty { padding: 36px 0 12px; color: var(--muted); }
footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 20px; color: var(--muted); font-size: 12px; }
footer a { color: var(--signal); text-underline-offset: 3px; }
@media (max-width: 760px) {
main { width: min(100% - 24px, 1180px); padding-top: 36px; }
header { grid-template-columns: 1fr; }
.timestamp { text-align: left; }
.metrics { grid-template-columns: 1fr; }
.metric { min-height: 145px; border-right: 0; border-bottom: 1px solid var(--line); }
.metric:last-child { border-bottom: 0; }
.value { margin-top: 22px; }
.version-row { grid-template-columns: 1fr 60px; }
.track { grid-column: 1 / -1; grid-row: 2; }
}
@media (prefers-reduced-motion: no-preference) {
header, .metrics, .versions { animation: arrive .55s both; }
.metrics { animation-delay: .08s; }
.versions { animation-delay: .16s; }
@keyframes arrive { from { opacity: 0; transform: translateY(12px); } }
}
</style>
</head>
<body>
<main>
<header>
<div>
<div class="eyebrow">XStats / installation telemetry</div>
<h1>安装与版本<br>运行态势</h1>
</div>
<div class="timestamp">快照生成于<br>{{.GeneratedAt}}</div>
</header>
<section class="metrics" aria-label="安装统计摘要" data-total-installations="{{.TotalInstallations}}">
<article class="metric">
<div class="label">累计安装</div>
<strong class="value">{{.TotalInstallations}}</strong>
<span class="unit">独立安装</span>
</article>
<article class="metric">
<div class="label">24 小时活跃</div>
<strong class="value">{{.ActiveInstallations}}</strong>
<span class="unit">最近检查更新</span>
</article>
<article class="metric">
<div class="label">累计检查</div>
<strong class="value">{{.TotalChecks}}</strong>
<span class="unit">更新请求</span>
</article>
</section>
<section class="versions" aria-labelledby="versions-title">
<div class="section-head">
<h2 id="versions-title">已安装版本分布</h2>
{{if .CurrentVersion}}<div class="version-meta">当前发布 <span class="current">{{.CurrentVersion}}</span></div>{{end}}
</div>
{{range .Versions}}
<div class="version-row">
<div class="version-name">{{.Version}}</div>
<div class="track" aria-label="占比 {{.Percent}}%"><div class="fill" style="--percent: {{.Percent}}"></div></div>
<div class="version-meta">{{.Count}} / {{.Percent}}%</div>
</div>
{{else}}
<div class="empty">还没有安装检查数据。</div>
{{end}}
</section>
<footer>
<span>只统计匿名安装标识与当前版本,不采集设备信息和监控指标。</span>
<span><a href="https://github.com/ysicing/xstats">对应源码</a> · 页面每 60 秒刷新</span>
</footer>
</main>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
module github.com/ysicing/xstats/server/api
go 1.25.0
require (
github.com/gofiber/fiber/v3 v3.5.0
github.com/libtnb/sqlite v1.2.2
gorm.io/gorm v1.31.2
)
require (
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gofiber/schema v1.8.3 // indirect
github.com/gofiber/utils/v2 v2.4.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.73.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.56.0 // indirect
)
+104
View File
@@ -0,0 +1,104 @@
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8=
github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w=
github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc=
github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng=
github.com/gofiber/utils/v2 v2.4.1 h1:E2X9G8O5Mn7b2GDb0JU3IUk42Rw2npuhhepIbuJQ2po=
github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l5RYjamW4=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/libtnb/sqlite v1.2.2 h1:Ku5hAPP5B3A4kQcDn4Z3qyNMafKzuAbnFZX4oD9wR4I=
github.com/libtnb/sqlite v1.2.2/go.mod h1:JkAuxM7HHo0tc7dQENjIGpp/yIfrLX5nKr5ME9wuvmI=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8=
github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+251
View File
@@ -0,0 +1,251 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"crypto/subtle"
"embed"
"errors"
"html/template"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
"github.com/libtnb/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
var installationIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
func validHTTPS(raw string, optional bool) bool {
if raw == "" {
return optional
}
parsed, err := url.Parse(raw)
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
}
//go:embed dashboard.html
var dashboardFiles embed.FS
var dashboardTemplate = template.Must(template.ParseFS(dashboardFiles, "dashboard.html"))
type release struct {
ID uint `json:"-" gorm:"primaryKey"`
Version string `json:"version" gorm:"uniqueIndex;size:64;not null"`
Build string `json:"build" gorm:"size:64;not null"`
Date string `json:"date" gorm:"size:32;not null"`
MinimumSystem string `json:"minimumSystem" gorm:"size:32;not null"`
URL string `json:"url" gorm:"not null"`
SHA256 string `json:"sha256" gorm:"size:64;not null"`
Size int64 `json:"size" gorm:"not null"`
DMG string `json:"dmg,omitempty"`
Notes []string `json:"notes" gorm:"serializer:json"`
Changelog string `json:"changelog,omitempty"`
IsCurrent bool `json:"-" gorm:"index;not null"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
type installation struct {
ID string `gorm:"primaryKey;size:64"`
CurrentVersion string `gorm:"size:64;not null"`
Checks uint64 `gorm:"not null"`
FirstSeenAt time.Time `gorm:"not null"`
LastSeenAt time.Time `gorm:"index;not null"`
}
type updateCheck struct {
CurrentVersion string `json:"current_version"`
InstallationID string `json:"installation_id"`
}
type versionStat struct {
Version string
Count int64
Percent int
}
type dashboardData struct {
GeneratedAt string
TotalInstallations int64
ActiveInstallations int64
TotalChecks int64
CurrentVersion string
Versions []versionStat
}
type config struct {
Listen string
Database string
ReleaseToken string
}
func loadConfig(getenv func(string) string) (config, error) {
configuration := config{
Listen: getenv("XSTATS_LISTEN"),
Database: getenv("XSTATS_DATABASE"),
ReleaseToken: strings.TrimSpace(getenv("XSTATS_RELEASE_TOKEN")),
}
if configuration.Listen == "" {
configuration.Listen = ":8080"
}
if configuration.Database == "" {
configuration.Database = "xstats-api.sqlite"
}
if configuration.ReleaseToken == "" {
return config{}, errors.New("XSTATS_RELEASE_TOKEN is required")
}
return configuration, nil
}
func newApplication(databasePath string, releaseToken string) (*fiber.App, error) {
databaseLogger := gormlogger.New(log.Default(), gormlogger.Config{
SlowThreshold: 500 * time.Millisecond,
LogLevel: gormlogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
})
database, err := gorm.Open(
sqlite.Open(databasePath+"?_txlock=immediate&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"),
&gorm.Config{Logger: databaseLogger},
)
if err != nil {
return nil, err
}
if err := database.AutoMigrate(&release{}, &installation{}); err != nil {
return nil, err
}
sqlDatabase, err := database.DB()
if err != nil {
return nil, err
}
sqlDatabase.SetMaxOpenConns(1)
sqlDatabase.SetMaxIdleConns(1)
app := fiber.New(fiber.Config{
BodyLimit: 64 * 1024,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
})
app.Use(func(c fiber.Ctx) error {
c.Set(fiber.HeaderLink, `<https://github.com/ysicing/xstats>; rel="source"`)
return c.Next()
})
app.Put("/api/v1/releases/current", func(c fiber.Ctx) error {
provided := strings.TrimPrefix(c.Get(fiber.HeaderAuthorization), "Bearer ")
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(releaseToken)) != 1 {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
}
var candidate release
if err := c.Bind().Body(&candidate); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if candidate.Version == "" || candidate.Build == "" || candidate.Date == "" ||
candidate.MinimumSystem == "" || !validHTTPS(candidate.URL, false) || !validHTTPS(candidate.DMG, true) ||
!validHTTPS(candidate.Changelog, true) || !installationIDPattern.MatchString(candidate.SHA256) ||
candidate.Size <= 0 || len(candidate.Notes) == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid release"})
}
err := database.Transaction(func(transaction *gorm.DB) error {
if err := transaction.Model(&release{}).Where("is_current = ?", true).Update("is_current", false).Error; err != nil {
return err
}
candidate.IsCurrent = true
return transaction.Where("version = ?", candidate.Version).Assign(candidate).FirstOrCreate(&candidate).Error
})
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not publish release"})
}
return c.SendStatus(http.StatusNoContent)
})
checkLimiter := limiter.New(limiter.Config{Max: 30, Expiration: time.Minute})
app.Post("/api/v1/update/check", checkLimiter, func(c fiber.Ctx) error {
var check updateCheck
if err := c.Bind().Body(&check); err != nil || check.CurrentVersion == "" || len(check.CurrentVersion) > 64 ||
!installationIDPattern.MatchString(check.InstallationID) {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
now := time.Now().UTC()
if err := database.Transaction(func(transaction *gorm.DB) error {
var item installation
result := transaction.First(&item, "id = ?", check.InstallationID)
if result.Error != nil && result.Error != gorm.ErrRecordNotFound {
return result.Error
}
if result.Error == gorm.ErrRecordNotFound {
item = installation{ID: check.InstallationID, CurrentVersion: check.CurrentVersion, Checks: 1, FirstSeenAt: now, LastSeenAt: now}
return transaction.Create(&item).Error
}
return transaction.Model(&item).Updates(map[string]any{
"current_version": check.CurrentVersion,
"last_seen_at": now,
"checks": gorm.Expr("checks + 1"),
}).Error
}); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not record update check"})
}
var current release
if err := database.Where("is_current = ?", true).First(&current).Error; err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "no release published"})
}
return c.JSON(current)
})
app.Get("/stats", func(c fiber.Ctx) error {
var data dashboardData
data.GeneratedAt = time.Now().Format("2006-01-02 15:04:05 MST")
if err := database.Model(&installation{}).Count(&data.TotalInstallations).Error; err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
if err := database.Model(&installation{}).Where("last_seen_at >= ?", time.Now().UTC().Add(-24*time.Hour)).
Count(&data.ActiveInstallations).Error; err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
if err := database.Model(&installation{}).Select("COALESCE(SUM(checks), 0)").Scan(&data.TotalChecks).Error; err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
var current release
if err := database.Where("is_current = ?", true).First(&current).Error; err == nil {
data.CurrentVersion = current.Version
}
if err := database.Model(&installation{}).
Select("current_version AS version, COUNT(*) AS count").
Group("current_version").Order("count DESC, current_version DESC").Scan(&data.Versions).Error; err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
for index := range data.Versions {
if data.TotalInstallations > 0 {
data.Versions[index].Percent = int(data.Versions[index].Count * 100 / data.TotalInstallations)
}
}
c.Type("html", "utf-8")
return dashboardTemplate.ExecuteTemplate(c.Response().BodyWriter(), "dashboard.html", data)
})
return app, nil
}
func main() {
configuration, err := loadConfig(os.Getenv)
if err != nil {
log.Fatal(err)
}
app, err := newApplication(configuration.Database, configuration.ReleaseToken)
if err != nil {
log.Fatal(err)
}
log.Printf("XStats API listening on %s", configuration.Listen)
if err := app.Listen(configuration.Listen, fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
log.Fatal(err)
}
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPublishReleaseRejectsMissingToken(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(`{"version":"2026.09.21.01"}`))
request.Header.Set("Content-Type", "application/json")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusUnauthorized)
}
}
func TestPublishReleaseRejectsIncompleteManifest(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(`{"version":"2026.09.21.03"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer release-secret")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest)
}
}
func TestPublishReleaseRejectsInsecureAssetURL(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
body := `{
"version":"2026.09.21.03","build":"108","date":"2026-09-21","minimumSystem":"14.0",
"url":"https://getopenstats.com/download/XStats.zip",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"size":123456,"dmg":"http://example.test/XStats.dmg","notes":["新增安装统计"]
}`
request := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer release-secret")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest)
}
}
func TestPublishedReleaseIsReturnedByUpdateCheck(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
publishBody := `{
"version":"2026.09.21.03",
"build":"108",
"date":"2026-09-21",
"minimumSystem":"14.0",
"url":"https://getopenstats.com/download/XStats-2026.09.21.03-AppleSilicon.zip",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"size":123456,
"dmg":"https://getopenstats.com/download/XStats-2026.09.21.03-AppleSilicon.dmg",
"notes":["新增安装统计"],
"changelog":"https://getopenstats.com/#changelog"
}`
publish := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(publishBody))
publish.Header.Set("Content-Type", "application/json")
publish.Header.Set("Authorization", "Bearer release-secret")
publishResponse, err := app.Test(publish)
if err != nil {
t.Fatal(err)
}
if publishResponse.StatusCode != http.StatusNoContent {
t.Fatalf("publish status = %d, want %d", publishResponse.StatusCode, http.StatusNoContent)
}
check := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{
"current_version":"2026.09.21.02",
"installation_id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}`))
check.Header.Set("Content-Type", "application/json")
checkResponse, err := app.Test(check)
if err != nil {
t.Fatal(err)
}
if checkResponse.StatusCode != http.StatusOK {
t.Fatalf("check status = %d, want %d", checkResponse.StatusCode, http.StatusOK)
}
var release struct {
Version string `json:"version"`
Build string `json:"build"`
SHA256 string `json:"sha256"`
Notes []string `json:"notes"`
}
if err := json.NewDecoder(checkResponse.Body).Decode(&release); err != nil {
t.Fatal(err)
}
if release.Version != "2026.09.21.03" || release.Build != "108" ||
release.SHA256 != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ||
len(release.Notes) != 1 || release.Notes[0] != "新增安装统计" {
t.Fatalf("unexpected release: %#v", release)
}
}
func TestUpdateCheckRejectsInvalidInstallationID(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{
"current_version":"2026.09.21.02",
"installation_id":"hardware-serial"
}`))
request.Header.Set("Content-Type", "application/json")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest)
}
}
func TestUpdateCheckRejectsOversizedVersion(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
body := `{"current_version":"` + strings.Repeat("9", 65) + `","installation_id":"` + strings.Repeat("a", 64) + `"}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusBadRequest)
}
}
func TestDashboardAggregatesInstallationsWithoutExposingIdentifiers(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
publish := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(`{
"version":"2026.09.21.03","build":"108","date":"2026-09-21","minimumSystem":"14.0",
"url":"https://getopenstats.com/download/XStats.zip",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"size":123456,"notes":["新增安装统计"]
}`))
publish.Header.Set("Content-Type", "application/json")
publish.Header.Set("Authorization", "Bearer release-secret")
if response, err := app.Test(publish); err != nil || response.StatusCode != http.StatusNoContent {
t.Fatalf("publish response = %v, err = %v", response, err)
}
checks := []string{
`{"current_version":"2026.09.21.01","installation_id":"1111111111111111111111111111111111111111111111111111111111111111"}`,
`{"current_version":"2026.09.21.01","installation_id":"1111111111111111111111111111111111111111111111111111111111111111"}`,
`{"current_version":"2026.09.21.02","installation_id":"2222222222222222222222222222222222222222222222222222222222222222"}`,
}
for _, body := range checks {
request := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response, err := app.Test(request)
if err != nil || response.StatusCode != http.StatusOK {
t.Fatalf("check response = %v, err = %v", response, err)
}
}
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/stats", nil))
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
html := string(body)
for _, want := range []string{`data-total-installations="2"`, "2026.09.21.01", "2026.09.21.02"} {
if !strings.Contains(html, want) {
t.Fatalf("dashboard does not contain %q", want)
}
}
if strings.Contains(html, "1111111111111111111111111111111111111111111111111111111111111111") {
t.Fatal("dashboard exposes an installation identifier")
}
}
func TestResponsesOfferCorrespondingSource(t *testing.T) {
app, err := newApplication(t.TempDir()+"/xstats.sqlite", "release-secret")
if err != nil {
t.Fatal(err)
}
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/stats", nil))
if err != nil {
t.Fatal(err)
}
want := `<https://github.com/ysicing/xstats>; rel="source"`
if got := response.Header.Get("Link"); got != want {
t.Fatalf("Link = %q, want %q", got, want)
}
}
+2 -7
View File
@@ -155,7 +155,7 @@
<a class="btn btn--primary" href="#download">下载 for Mac</a>
<a class="btn btn--secondary" href="#changelog">更新日志</a>
</div>
<p class="hero__meta">macOS 14 及以上 · Apple 芯片与 Intel 芯片 · 已签名并经 Apple 公证</p>
<p class="hero__meta">macOS 14 及以上 · Apple 芯片 · 已签名并经 Apple 公证</p>
<p class="hero__hint">点右上角菜单栏里的 CPU、内存或网速,看看每一项的详情 ↗</p>
<button class="hero__link" id="heroOpenWindow" type="button">或者打开 OpenStats 主窗口</button>
</div>
@@ -343,11 +343,6 @@
<p>M1、M2、M3、M4、M5 系列的 Mac</p>
<a class="btn btn--primary" href="download/OpenStats-0.6.1-AppleSilicon.dmg">下载 Apple 芯片版</a>
</li>
<li class="download__option">
<h3>Intel 芯片</h3>
<p>能升级到 macOS 14 的 Intel Mac(大多为 2018 年及以后的机型)</p>
<a class="btn btn--primary" href="download/OpenStats-0.6.1-Intel.dmg">下载 Intel 版</a>
</li>
</ul>
<p class="download__hint">不确定是哪种?打开苹果菜单 › 关于本机,看“芯片”或“处理器”那一行。</p>
<p class="hero__meta">版本 0.6.1 · macOS 14 Sonoma 及以上 · 简体中文与 English 界面 · <a href="#changelog">这个版本更新了什么</a></p>
@@ -613,7 +608,7 @@
</details>
<details class="faq">
<summary>支持 Intel Mac 吗?</summary>
<p>支持,在上方下载 Intel 版即可,需要 macOS 14 Sonoma 或更高版本。用 Apple 智能解释进程只能在 Apple 芯片上使用;Intel 机型的 CPU 不分性能核与能效核,部分功耗与频率读数也可能不显示。开发主要在 Apple 芯片上进行,Intel 机型遇到风扇或温度读数不对,欢迎到 GitHub 反馈。</p>
<p>不支持。当前版本仅提供 Apple Silicon(arm64)安装包,需要 macOS 14 Sonoma 或更高版本。</p>
</details>
</div>
</section>
+16 -17
View File
@@ -23,38 +23,37 @@
<section class="band" id="privacy">
<div class="shell narrow">
<div class="section-head">
<p><a href="./">← 返回 OpenStats</a></p>
<p><a href="./">← 返回 XStats</a></p>
<h2>隐私政策</h2>
<p>最后更新:2026-09-15。OpenStats 由 GiantAccel, LLC 开发,本页说明应用与 getopenstats.com 会处理哪些数据。</p>
<p>最后更新:2026-09-21。本页说明 XStats 与 getopenstats.com 会处理哪些数据。</p>
</div>
<div class="section-head">
<h2>监控数据不离开你的 Mac</h2>
<p>CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,没有统计上报,也没有广告或第三方分析 SDK。</p>
<p>CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,也不会进入安装统计;应用没有广告或第三方分析 SDK。</p>
</div>
<ul class="fact-list">
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 OpenStats 的服务器</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>启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识</span></li>
<li><b>检查更新</b><span>启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;关闭自动检查更新后不再自动请求</span></li>
</ul>
<div class="section-head" style="margin-top: var(--space-16)">
<h2>可选的账号与设置同步</h2>
<p>不登录也能使用全部功能。登录后,应用会把偏好设置保存到 getopenstats.com,让你在其他 Mac 上登录时自动恢复。</p>
<h2>匿名安装统计</h2>
<p>首次检查更新时,XStats 生成一个随机安装 ID,原值只保存在这台 Mac 的钥匙串中。服务器收到并保存的只有它的 SHA-256。</p>
</div>
<ul class="fact-list">
<li><b>登录方式</b><span>GitHub、Google 或 Apple。我们只从它们那里拿到一个用户标识、邮箱、姓名与头像地址,不会取得你在这些平台上的其他数据,也不会代你发布任何内容</span></li>
<li><b>保存的内容</b><span>邮箱、姓名、头像地址,以及一份设置文档:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。另记录每台登录设备的名称与上次同步时间</span></li>
<li><b>不保存的内容</b><span>任何监控数据、进程列表、IP 地址、硬件序列号、清理记录与历史记录</span></li>
<li><b>存放位置</b><span>getopenstats.com 的服务器,经 HTTPS 传输。登录令牌保存在你 Mac 的钥匙串里</span></li>
<li><b>删除</b><span>在「设置 · 账号与同步」里可以随时退出登录;「删除云端数据」会立即删除账号、所有登录方式与云端设置,无法恢复</span></li>
<li><b>不会做的事</b><span>不出售或共享你的数据,不用于广告,不发送营销邮件</span></li>
<li><b>保存的内容</b><span>安装 ID 哈希、当前版本、首次与最近检查时间、累计检查次数</span></li>
<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>
</ul>
<div class="section-head" style="margin-top: var(--space-16)">
<h2>联系</h2>
<p>对隐私有任何疑问,请到 <a href="https://github.com/gentpan/OpenStats/issues">GitHub 仓库</a>提交 issue。本政策有变化时会更新本页并注明日期。</p>
<p>对隐私有任何疑问,请到 <a href="https://github.com/ysicing/xstats/issues">GitHub 仓库</a>提交 issue。本政策有变化时会更新本页并注明日期。</p>
</div>
</div>
</section>
@@ -62,9 +61,9 @@
<footer class="footer">
<div class="shell footer__inner">
<span class="footer__brand"><img src="assets/icon.png" alt="" width="20" height="20">OpenStats</span>
<span class="footer__fine">© 2026 GiantAccel, LLC</span>
<span class="footer__fine">OpenStats 是独立的第三方应用,与 Apple 没有隶属关系。</span>
<span class="footer__brand"><img src="assets/icon.png" alt="" width="20" height="20">XStats</span>
<span class="footer__fine">© 2026 ysicing</span>
<span class="footer__fine">XStats 是独立的第三方应用,与 Apple 没有隶属关系。</span>
</div>
</footer>