feat(release): 完善版本服务与镜像发布
This commit is contained in:
1 parent
593112e733
commit
ea67453b32
33 files changed
+1546
-180
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -8,3 +8,5 @@ Packages/XStatsKit/Package.resolved
|
||||
dist/
|
||||
__pycache__/
|
||||
docs/superpowers
|
||||
server/api/*.sqlite*
|
||||
server/api/api
|
||||
+20
-2
@@ -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
@@ -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。
|
||||
|
||||
先将公证凭据保存到钥匙串:
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
[](https://github.com/ysicing/xstats/commits/main)
|
||||
[](https://github.com/ysicing/xstats/graphs/commit-activity)
|
||||
[](https://github.com/ysicing/xstats/actions/workflows/ci.yml)
|
||||
[](https://github.com/ysicing/xstats/releases)
|
||||
[](https://github.com/ysicing/xstats/releases)
|
||||
[](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
@@ -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
@@ -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 설정 동기화
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
[](https://github.com/ysicing/xstats/commits/main)
|
||||
[](https://github.com/ysicing/xstats/graphs/commit-activity)
|
||||
[](https://github.com/ysicing/xstats/actions/workflows/ci.yml)
|
||||
[](https://github.com/ysicing/xstats/releases)
|
||||
[](https://github.com/ysicing/xstats/releases)
|
||||
[](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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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")/.."
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 保留的登录品牌标志资源
|
||||
|
||||
账号登录入口已移除。以下为暂时保留的旧资源及原使用说明。
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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(¤t).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(¤t).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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user