diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7fa1469 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/server-image.yml b/.github/workflows/server-image.yml new file mode 100644 index 0000000..503286b --- /dev/null +++ b/.github/workflows/server-image.yml @@ -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 diff --git a/.gitignore b/.gitignore index 8d39122..67607f2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ Packages/XStatsKit/Package.resolved dist/ __pycache__/ docs/superpowers +server/api/*.sqlite* +server/api/api diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f604dc7..62d7953 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//xstats-server:-`. Pull requests do not run this workflow. + ## WebDAV settings sync Account login, OAuth callbacks and the old account backend have been removed. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c160aab..4584cdd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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//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。 先将公证凭据保存到钥匙串: diff --git a/LICENSES/XStats-API-Dependencies.txt b/LICENSES/XStats-API-Dependencies.txt new file mode 100644 index 0000000..c7f99bf --- /dev/null +++ b/LICENSES/XStats-API-Dependencies.txt @@ -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 + +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 + +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. diff --git a/Packages/XStatsKit/Sources/Updates/InstallationIdentity.swift b/Packages/XStatsKit/Sources/Updates/InstallationIdentity.swift new file mode 100644 index 0000000..ca46002 --- /dev/null +++ b/Packages/XStatsKit/Sources/Updates/InstallationIdentity.swift @@ -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, + ] + } +} diff --git a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift index fa9c56a..53ccbfc 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateFeed.swift @@ -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), diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index 50d9217..8eaf29c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -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 { - var request = URLRequest(url: UpdateFeed.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) - request.setValue("OpenStats", forHTTPHeaderField: "User-Agent") + private static func fetch(currentVersion: String) async -> Result { do { + let installationID = try InstallationIdentity().hashedID() + let request = try UpdateFeed.checkRequest(currentVersion: currentVersion, installationID: installationID) let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode != 200 { return .failure(.download(tr("服务器返回 \(http.statusCode)"))) diff --git a/Packages/XStatsKit/Tests/UpdatesTests/InstallationIdentityTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/InstallationIdentityTests.swift new file mode 100644 index 0000000..fd315e3 --- /dev/null +++ b/Packages/XStatsKit/Tests/UpdatesTests/InstallationIdentityTests.swift @@ -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 } +} diff --git a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift index 5a86f7f..9d39065 100644 --- a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift +++ b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift @@ -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))) diff --git a/README.en.md b/README.en.md index 380bbf6..848578d 100644 --- a/README.en.md +++ b/README.en.md @@ -11,7 +11,7 @@ [![Last commit](https://img.shields.io/github/last-commit/ysicing/xstats?color=black&label=last%20commit)](https://github.com/ysicing/xstats/commits/main) [![Commit activity](https://img.shields.io/github/commit-activity/m/ysicing/xstats?color=black&label=commits)](https://github.com/ysicing/xstats/graphs/commit-activity) [![CI](https://github.com/ysicing/xstats/actions/workflows/ci.yml/badge.svg)](https://github.com/ysicing/xstats/actions/workflows/ci.yml) -[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20silicon%20%2F%20Intel-black)](https://github.com/ysicing/xstats/releases) +[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20silicon-black)](https://github.com/ysicing/xstats/releases) [![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE) XStats is a macOS menu-bar app that shows what your Mac is doing right now — per-core @@ -19,7 +19,7 @@ CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and f lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches, fully uninstall apps and disable startup items. Network details also check how clean your public IP is — whether it is flagged as a VPN, proxy, data center or for abuse. -Everything is read on your own Mac, and there is no telemetry. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version. +All monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in the local Keychain. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version. [Download](https://github.com/ysicing/xstats/releases) · [Changelog](CHANGELOG.md) · @@ -33,15 +33,13 @@ Everything is read on your own Mac, and there is no telemetry. No XStats account ## Install -Check [GitHub Releases](https://github.com/ysicing/xstats/releases) for published XStats builds. -Choose AppleSilicon or Intel for your Mac. If no build is available, follow the source-build instructions below. +Download the Apple Silicon build from [GitHub Releases](https://github.com/ysicing/xstats/releases). +If no build is available, follow the [development guide](DEVELOPMENT.md) to build from source. XStats has no website yet. Automatic updates and the existing GeoIP service are unchanged; account login has been replaced with manual WebDAV sync. The update installer still verifies bundle identity and signatures: packages built for OpenStats cannot replace XStats. -Requires macOS 14 (Sonoma) or later. Developed and tested mainly on Apple silicon; on Intel Macs, Apple -Intelligence process explanations are unavailable, CPU cores are not split into performance and efficiency -groups, and some power and frequency readings may be missing. The interface supports Simplified Chinese, +Requires an Apple silicon Mac with macOS 14 (Sonoma) or later. The interface supports Simplified Chinese, Traditional Chinese, Japanese, Korean, English, German, Spanish, French and Arabic. It detects your preferred system language by default. Search and switch languages in Settings → General → Language; Arabic uses a right-to-left layout. @@ -192,7 +190,7 @@ unclean exit restores them at the next boot. Metrics come from the kernel (`host_processor_info`, `host_statistics64`, `sysctl`), IOKit and the SMC on your own Mac. Preferences live in the app's user defaults and contain nothing personal. -There is no analytics and no telemetry. +Monitoring metrics, history, the hardware serial number and process lists are never uploaded. Every feature that touches the network can be turned off — the first two in Settings → Network, the update check in Settings → About: @@ -204,8 +202,11 @@ check in Settings → About: - **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud, Tencent or your router) once a second while network details are open (every 10 seconds in the background), only while the network item is in the menu bar or network details are open. -- **Update check**: at launch and once a day, the app reads a version manifest from getopenstats.com — the - manifest only. When a new version is out it asks; nothing installs without your click. +- **Update check and installation statistics**: at launch and once a day, the app sends the current version and the + SHA-256 of a random installation ID to getopenstats.com and reads the version manifest. The server stores only that + hash, the current version, first and last check times, and the check count; it does not store a hardware serial number + or persist request IPs (an IP is used only for an in-memory one-minute rate limit). The original random value stays in the local Keychain. Turning off automatic update checks stops these + automatic requests. When a new version is out it asks; nothing installs without your click. WebDAV sync sends only preferences to your configured server. It excludes monitoring data, history and WebDAV credentials. diff --git a/README.ja.md b/README.ja.md index 1cf83ec..eea5e7d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 設定同期 diff --git a/README.ko.md b/README.ko.md index dabf665..0baa286 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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 설정 동기화 diff --git a/README.md b/README.md index 742ec4a..4016a9c 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ [![Last commit](https://img.shields.io/github/last-commit/ysicing/xstats?color=black&label=%E6%9C%80%E8%BF%91%E6%8F%90%E4%BA%A4)](https://github.com/ysicing/xstats/commits/main) [![Commit activity](https://img.shields.io/github/commit-activity/m/ysicing/xstats?color=black&label=%E6%8F%90%E4%BA%A4)](https://github.com/ysicing/xstats/graphs/commit-activity) [![CI](https://github.com/ysicing/xstats/actions/workflows/ci.yml/badge.svg)](https://github.com/ysicing/xstats/actions/workflows/ci.yml) -[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20%E8%8A%AF%E7%89%87%20%2F%20Intel-black)](https://github.com/ysicing/xstats/releases) +[![macOS](https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20%E8%8A%AF%E7%89%87-black)](https://github.com/ysicing/xstats/releases) [![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE) XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、 网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存、彻底卸载应用、停用启动项; 网络详情还能检测公网 IP 的纯净度,看出出口是否被标记为 VPN、代理、机房或有滥用记录。 -所有数据都在你自己的 Mac 上读取,没有统计上报。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。 +所有监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机钥匙串。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。 [下载](https://github.com/ysicing/xstats/releases) · [更新日志](CHANGELOG.md) · @@ -31,14 +31,13 @@ XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各 ## 安装 -在 [GitHub Releases](https://github.com/ysicing/xstats/releases) 查看已发布的 XStats 构建,按 Mac 芯片选择 AppleSilicon 或 Intel。 -若尚无构建可下载,请按下文从源码构建。 +在 [GitHub Releases](https://github.com/ysicing/xstats/releases) 下载 Apple Silicon 版 XStats。 +若尚无构建可下载,请参考 [开发指南](DEVELOPMENT.md) 从源码构建。 XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登录已替换为手动 WebDAV 同步。 更新安装仍严格检查应用身份与签名:旧服务提供的 OpenStats 安装包不能替换 XStats。 -需要 macOS 14(Sonoma)或更高版本。主要在 Apple 芯片上开发和测试;Intel 机型上不能用 Apple 智能解释进程, -CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 +需要 Apple 芯片和 macOS 14(Sonoma)或更高版本。 界面支持简体中文、繁體中文、日本語、한국어、English、Deutsch、Español、Français 和 العربية。 默认自动检测系统首选语言,可在“设置 · 通用 · 语言”中搜索并切换;阿拉伯语使用从右到左布局。 @@ -206,13 +205,13 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 ## 你的数据 指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、`sysctl`)、IOKit 与 SMC。 -偏好设置保存在应用自己的 user defaults 中,不含个人信息。没有任何统计与上报。 +偏好设置保存在应用自己的 user defaults 中,不含个人信息。监控指标、历史记录、硬件序列号和进程列表不会上传。 会联网的功能都可以关闭。前两项在“设置 → 网络”中,检查更新在“设置 · 关于”中: - **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。 - **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 -- **检查更新**:启动时与之后每天向 getopenstats.com 读取一次版本清单,只下载清单本身;发现新版本时提示,由你决定是否安装。 +- **检查更新与安装统计**:启动时与之后每天向 getopenstats.com 发送当前版本和随机安装标识的 SHA-256,并读取版本清单;服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机钥匙串中。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。 WebDAV 只向你配置的服务器传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。 diff --git a/Scripts/appcast.py b/Scripts/appcast.py index 864dee9..11431b0 100755 --- a/Scripts/appcast.py +++ b/Scripts/appcast.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """生成应用内在线升级读取的版本清单 appcast.json。 - Scripts/appcast.py <版本> <下载地址前缀> > dist/appcast.json + Scripts/appcast.py <版本> <下载地址前缀> > 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") diff --git a/Scripts/arm64_release_test.sh b/Scripts/arm64_release_test.sh new file mode 100644 index 0000000..5624077 --- /dev/null +++ b/Scripts/arm64_release_test.sh @@ -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 diff --git a/Scripts/install_local.sh b/Scripts/install_local.sh index 50dc937..9a0d5b3 100755 --- a/Scripts/install_local.sh +++ b/Scripts/install_local.sh @@ -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")/.." diff --git a/Scripts/publish_api.py b/Scripts/publish_api.py new file mode 100644 index 0000000..d1f8a15 --- /dev/null +++ b/Scripts/publish_api.py @@ -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]} ") + 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() diff --git a/Scripts/publish_api_test.py b/Scripts/publish_api_test.py new file mode 100644 index 0000000..aad5e0f --- /dev/null +++ b/Scripts/publish_api_test.py @@ -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() diff --git a/Scripts/publish_release.sh b/Scripts/publish_release.sh index 6802dd6..3abf7f3 100755 --- a/Scripts/publish_release.sh +++ b/Scripts/publish_release.sh @@ -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)" diff --git a/Scripts/release.sh b/Scripts/release.sh index d83a0a7..8c2f303 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -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" < + + + + + + + XStats 安装统计 + + + +
+
+
+
XStats / installation telemetry
+

安装与版本
运行态势

+
+
快照生成于
{{.GeneratedAt}}
+
+ +
+
+
累计安装
+ {{.TotalInstallations}} + 独立安装 +
+
+
24 小时活跃
+ {{.ActiveInstallations}} + 最近检查更新 +
+
+
累计检查
+ {{.TotalChecks}} + 更新请求 +
+
+ +
+
+

已安装版本分布

+ {{if .CurrentVersion}}
当前发布 {{.CurrentVersion}}
{{end}} +
+ {{range .Versions}} +
+
{{.Version}}
+
+
{{.Count}} / {{.Percent}}%
+
+ {{else}} +
还没有安装检查数据。
+ {{end}} +
+ +
+ 只统计匿名安装标识与当前版本,不采集设备信息和监控指标。 + 对应源码 · 页面每 60 秒刷新 +
+
+ + diff --git a/server/api/go.mod b/server/api/go.mod new file mode 100644 index 0000000..1182b63 --- /dev/null +++ b/server/api/go.mod @@ -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 +) diff --git a/server/api/go.sum b/server/api/go.sum new file mode 100644 index 0000000..95a40c8 --- /dev/null +++ b/server/api/go.sum @@ -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= diff --git a/server/api/main.go b/server/api/main.go new file mode 100644 index 0000000..3aad8d6 --- /dev/null +++ b/server/api/main.go @@ -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, `; 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) + } +} diff --git a/server/api/main_test.go b/server/api/main_test.go new file mode 100644 index 0000000..1181759 --- /dev/null +++ b/server/api/main_test.go @@ -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 := `; rel="source"` + if got := response.Header.Get("Link"); got != want { + t.Fatalf("Link = %q, want %q", got, want) + } +} diff --git a/web/index.html b/web/index.html index d687900..0cf1b44 100644 --- a/web/index.html +++ b/web/index.html @@ -155,7 +155,7 @@ 下载 for Mac 更新日志 -

macOS 14 及以上 · Apple 芯片与 Intel 芯片 · 已签名并经 Apple 公证

+

macOS 14 及以上 · Apple 芯片 · 已签名并经 Apple 公证

点右上角菜单栏里的 CPU、内存或网速,看看每一项的详情 ↗

@@ -343,11 +343,6 @@

M1、M2、M3、M4、M5 系列的 Mac

下载 Apple 芯片版 -
  • -

    Intel 芯片

    -

    能升级到 macOS 14 的 Intel Mac(大多为 2018 年及以后的机型)

    - 下载 Intel 版 -
  • 不确定是哪种?打开苹果菜单 › 关于本机,看“芯片”或“处理器”那一行。

    版本 0.6.1 · macOS 14 Sonoma 及以上 · 简体中文与 English 界面 · 这个版本更新了什么

    @@ -613,7 +608,7 @@
    支持 Intel Mac 吗? -

    支持,在上方下载 Intel 版即可,需要 macOS 14 Sonoma 或更高版本。用 Apple 智能解释进程只能在 Apple 芯片上使用;Intel 机型的 CPU 不分性能核与能效核,部分功耗与频率读数也可能不显示。开发主要在 Apple 芯片上进行,Intel 机型遇到风扇或温度读数不对,欢迎到 GitHub 反馈。

    +

    不支持。当前版本仅提供 Apple Silicon(arm64)安装包,需要 macOS 14 Sonoma 或更高版本。

    diff --git a/web/privacy.html b/web/privacy.html index 510c9ce..3603689 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -23,38 +23,37 @@
    -

    ← 返回 OpenStats

    +

    ← 返回 XStats

    隐私政策

    -

    最后更新:2026-09-15。OpenStats 由 GiantAccel, LLC 开发,本页说明应用与 getopenstats.com 会处理哪些数据。

    +

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

    监控数据不离开你的 Mac

    -

    CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,没有统计上报,也没有广告或第三方分析 SDK。

    +

    CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,也不会进入安装统计;应用没有广告或第三方分析 SDK。

    • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
    • -
    • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 OpenStats 的服务器
    • +
    • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器
    • 连接探测打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭
    • -
    • 检查更新启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识
    • +
    • 检查更新启动时与每天一次发送当前版本和随机安装标识的 SHA-256,并读取版本清单;关闭自动检查更新后不再自动请求
    -

    可选的账号与设置同步

    -

    不登录也能使用全部功能。登录后,应用会把偏好设置保存到 getopenstats.com,让你在其他 Mac 上登录时自动恢复。

    +

    匿名安装统计

    +

    首次检查更新时,XStats 生成一个随机安装 ID,原值只保存在这台 Mac 的钥匙串中。服务器收到并保存的只有它的 SHA-256。

      -
    • 登录方式GitHub、Google 或 Apple。我们只从它们那里拿到一个用户标识、邮箱、姓名与头像地址,不会取得你在这些平台上的其他数据,也不会代你发布任何内容
    • -
    • 保存的内容邮箱、姓名、头像地址,以及一份设置文档:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。另记录每台登录设备的名称与上次同步时间
    • -
    • 不保存的内容任何监控数据、进程列表、IP 地址、硬件序列号、清理记录与历史记录
    • -
    • 存放位置getopenstats.com 的服务器,经 HTTPS 传输。登录令牌保存在你 Mac 的钥匙串里
    • -
    • 删除在「设置 · 账号与同步」里可以随时退出登录;「删除云端数据」会立即删除账号、所有登录方式与云端设置,无法恢复
    • -
    • 不会做的事不出售或共享你的数据,不用于广告,不发送营销邮件
    • +
    • 保存的内容安装 ID 哈希、当前版本、首次与最近检查时间、累计检查次数
    • +
    • 不保存的内容随机 ID 原值、硬件序列号、任何监控数据、进程列表、清理记录与历史记录;请求 IP 只用于一分钟内的内存限流,不持久化
    • +
    • 用途只用于计算独立安装数、24 小时活跃数和已安装版本分布,不用于广告或跨服务追踪
    • +
    • 停止发送在「设置 · 关于」关闭自动检查更新后,不再自动发送;手动检查更新仍会产生一次请求
    • +
    • 设置同步只连接你自己配置的 WebDAV 服务器;不会把设置、密码、监控指标或历史记录发送到 getopenstats.com

    联系

    -

    对隐私有任何疑问,请到 GitHub 仓库提交 issue。本政策有变化时会更新本页并注明日期。

    +

    对隐私有任何疑问,请到 GitHub 仓库提交 issue。本政策有变化时会更新本页并注明日期。

    @@ -62,9 +61,9 @@