fix: 修复应用扫描取消并校验 Sparkle 更新源
This commit is contained in:
1 parent
1c1e94a15f
commit
bc2bfe8f5c
10 files changed
+303
-9
No files matched your search
+4
-1
@@ -411,7 +411,10 @@ helper, re-registers the bundled version, and verifies the protocol before privi
|
||||
|
||||
`server/api` is one Go program. Fiber exposes `POST /api/v1/update/check`, authenticated
|
||||
`PUT /api/v1/releases/current`, the fixed signed `GET /api/v1/update/appcast.xml`, and the aggregate
|
||||
`GET /stats` dashboard. GORM uses
|
||||
`GET /stats` dashboard. The XML route verifies the original Ed25519 feed signature, RSS structure and
|
||||
release/archive metadata against the current JSON release before recording a successful check. Invalid
|
||||
feeds return 502 without changing telemetry; the legacy JSON endpoint remains independent. Both backends
|
||||
use the client public key and must be updated together if it rotates. Valid XML is returned byte for byte. 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
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ git push
|
||||
```
|
||||
|
||||
发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。
|
||||
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端原样代理版本文件,合并统计并禁止缓存。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
|
||||
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致,轮换发布密钥时同步更新两套后端。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
|
||||
作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新,
|
||||
`scripts/sync_changelog.py` 不处理徽章。
|
||||
|
||||
|
||||
@@ -78,19 +78,40 @@ public enum AppUninstaller {
|
||||
|
||||
/// /Applications 与 ~/Applications 下的应用(含一层子文件夹),按名称排序
|
||||
public static func installedApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set<String> = []) -> [InstalledApp] {
|
||||
(try? installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers, checkCancellation: {})) ?? []
|
||||
}
|
||||
|
||||
/// 可取消的应用列表扫描;遍历中止时抛错,不发布部分列表。
|
||||
public static func scanInstalledApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set<String> = []) throws -> [InstalledApp] {
|
||||
try installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers,
|
||||
checkCancellation: { try Task.checkCancellation() })
|
||||
}
|
||||
|
||||
static func installedApps(in directories: [URL], excluding excludedIdentifiers: Set<String>,
|
||||
checkCancellation: () throws -> Void) throws -> [InstalledApp] {
|
||||
try checkCancellation()
|
||||
var apps: [InstalledApp] = []
|
||||
let manager = FileManager.default
|
||||
for directory in applicationDirectories(home: home) {
|
||||
for directory in directories {
|
||||
try checkCancellation()
|
||||
let entries = (try? manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
|
||||
for entry in entries {
|
||||
try checkCancellation()
|
||||
if entry.pathExtension == "app" {
|
||||
if let app = app(at: entry) { apps.append(app) }
|
||||
} else if (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
|
||||
try checkCancellation()
|
||||
let nested = (try? manager.contentsOfDirectory(at: entry, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
|
||||
apps += nested.filter { $0.pathExtension == "app" }.compactMap(app(at:))
|
||||
for candidate in nested {
|
||||
try checkCancellation()
|
||||
guard candidate.pathExtension == "app" else { continue }
|
||||
if let app = app(at: candidate) { apps.append(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 取消不得继续做筛选、排序,也不能把扫描到的一部分列表当成成功结果。
|
||||
try checkCancellation()
|
||||
return apps
|
||||
.filter { !excludedIdentifiers.contains($0.bundleIdentifier) }
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
|
||||
@@ -38,7 +38,7 @@ public final class UninstallerController {
|
||||
}
|
||||
|
||||
init(currentBundleIdentifier: String?,
|
||||
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { AppUninstaller.installedApps() },
|
||||
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { try AppUninstaller.scanInstalledApps() },
|
||||
findLeftovers: @escaping @Sendable (InstalledApp) throws -> [AppLeftover] = { try AppUninstaller.scanLeftovers(for: $0) },
|
||||
measureSize: @escaping @Sendable (URL) throws -> UInt64 = { try CleanEngine.scanAllocatedSize(of: $0) },
|
||||
recycleFiles: @escaping ([URL], @escaping @Sendable ([URL: URL], String?) -> Void) -> Void = { urls, completion in
|
||||
|
||||
@@ -6,6 +6,45 @@ import Testing
|
||||
@testable import Cleaner
|
||||
|
||||
struct ScanCancellationTests {
|
||||
@Test func cancellationInterruptsNestedApplicationListingWithoutPartialResults() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let nested = root.appendingPathComponent("Utilities")
|
||||
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
for index in 0..<20 {
|
||||
let app = nested.appendingPathComponent("Fixture-\(index).app/Contents")
|
||||
try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
|
||||
let info = ["CFBundleIdentifier": "test.list.\(index)", "CFBundleName": "Fixture-\(index)"]
|
||||
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
|
||||
.write(to: app.appendingPathComponent("Info.plist"))
|
||||
}
|
||||
var checks = 0
|
||||
#expect(throws: CancellationError.self) {
|
||||
try AppUninstaller.installedApps(in: [root], excluding: []) {
|
||||
checks += 1
|
||||
if checks == 7 { throw CancellationError() }
|
||||
}
|
||||
}
|
||||
#expect(checks == 7)
|
||||
let apps = try AppUninstaller.installedApps(in: [root], excluding: ["test.list.0"], checkCancellation: {})
|
||||
#expect(apps.count == 19 && !apps.contains { $0.bundleIdentifier == "test.list.0" })
|
||||
}
|
||||
|
||||
@Test func cancelledTaskDoesNotStartApplicationListing() async {
|
||||
let scan = Task.detached {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
do {
|
||||
_ = try AppUninstaller.scanInstalledApps()
|
||||
return false
|
||||
} catch is CancellationError {
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
#expect(await scan.value)
|
||||
}
|
||||
|
||||
@Test func cancellationInterruptsDirectoryTraversalWithoutReturningPartialSize() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
+4
-1
@@ -198,7 +198,7 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed
|
||||
}
|
||||
return c.JSON(current)
|
||||
})
|
||||
// 固定入口选择当前发布对应的不可变 XML;保留签名字节,不解析重写或在服务端持有私钥。
|
||||
// 固定入口选择当前发布对应的不可变 XML;校验后原样返回,不重写或持有私钥。
|
||||
// 统计与本次 GET 合并,Sparkle 客户端无需再发一份 JSON POST。
|
||||
app.Get("/api/v1/update/appcast.xml", checkLimiter, func(c fiber.Ctx) error {
|
||||
c.Set(fiber.HeaderCacheControl, "no-store")
|
||||
@@ -234,6 +234,9 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed
|
||||
if err != nil || len(data) == 0 || len(data) > maximumFeedBytes {
|
||||
return c.SendStatus(http.StatusBadGateway)
|
||||
}
|
||||
if err := validateSparkleFeed(data, current); err != nil {
|
||||
return c.SendStatus(http.StatusBadGateway)
|
||||
}
|
||||
if err := recordUpdateCheck(database, check); err != nil {
|
||||
return c.SendStatus(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,6 +21,16 @@ type feedTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f feedTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// 已签名的固定夹具避免在 CI 中读取发布私钥;安装包签名只作结构测试。
|
||||
func signedSparkleFixture(t *testing.T, version string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("testdata/sparkle-" + version + ".xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string) {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"version":%q,"build":%q,"date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/XStats-%s.zip","sha256":"%s","size":1000,"notes":["test"]}`, version, build, version, strings.Repeat("a", 64))
|
||||
@@ -37,7 +48,7 @@ func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string)
|
||||
}
|
||||
|
||||
func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
|
||||
bytes := "<?xml version=\"1.0\"?>\n<rss/>\n<!-- sparkle:edSignature test -->\n"
|
||||
bytes := signedSparkleFixture(t, "0.14.1")
|
||||
var urls []string
|
||||
client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) {
|
||||
urls = append(urls, r.URL.String())
|
||||
@@ -54,6 +65,7 @@ func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
|
||||
endpoint := "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id=" + strings.Repeat("b", 64)
|
||||
for index, version := range []string{"0.14.1", "0.14.2"} {
|
||||
publishSparkleFixture(t, app, version, fmt.Sprint(126+index))
|
||||
bytes = signedSparkleFixture(t, version)
|
||||
res, err := app.Test(httptest.NewRequest(http.MethodGet, endpoint, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -106,13 +118,14 @@ func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T) {
|
||||
for _, body := range []string{"", strings.Repeat("x", 256*1024+1)} {
|
||||
for _, body := range []string{"", strings.Repeat("x", 256*1024+1), "<html>CDN error</html>", "<rss>", "<rss/>", strings.Replace(signedSparkleFixture(t, "0.14.1"), "XStats updates", "tampered", 1), signedSparkleFixture(t, "0.14.2")} {
|
||||
calls := 0
|
||||
client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: r}, nil
|
||||
})}
|
||||
app, err := newApplicationWithFeedClient(t.TempDir()+"/xstats.sqlite", "release-secret", client)
|
||||
databasePath := t.TempDir() + "/xstats.sqlite"
|
||||
app, err := newApplicationWithFeedClient(databasePath, "release-secret", client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -138,5 +151,67 @@ func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T)
|
||||
if res.StatusCode != 502 {
|
||||
t.Fatal(res.StatusCode)
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
err = db.Model(&installation{}).Count(&count).Error
|
||||
connection.Close()
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("invalid feed tracked %d: %v", count, err)
|
||||
}
|
||||
legacy := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.12.1","installation_id":"`+strings.Repeat("a", 64)+`"}`))
|
||||
legacy.Header.Set("Content-Type", "application/json")
|
||||
res, err = app.Test(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("legacy update: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保持同一份有效签名,改变发布指针的各项元数据必须被拒绝。
|
||||
func TestSparkleFeedValidatesPublishedMetadataAndSignature(t *testing.T) {
|
||||
feed := signedSparkleFixture(t, "0.14.1")
|
||||
published := release{Version: "0.14.1", Build: "126", MinimumSystem: "14.0", URL: "https://example.test/XStats-0.14.1.zip", Size: 1000, SHA256: strings.Repeat("a", 64)}
|
||||
if err := validateSparkleFeed([]byte(feed), published); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"version", "build", "minimumSystem", "url", "size", "sha256"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
altered := published
|
||||
switch field {
|
||||
case "version":
|
||||
altered.Version = "0.14.2"
|
||||
case "build":
|
||||
altered.Build = "127"
|
||||
case "minimumSystem":
|
||||
altered.MinimumSystem = "15.0"
|
||||
case "url":
|
||||
altered.URL = "https://example.test/other.zip"
|
||||
case "size":
|
||||
altered.Size++
|
||||
case "sha256":
|
||||
altered.SHA256 = strings.Repeat("b", 64)
|
||||
}
|
||||
if validateSparkleFeed([]byte(feed), altered) == nil {
|
||||
t.Fatal("mismatch accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
signatureStart := strings.LastIndex(feed, "<!-- sparkle-signatures:")
|
||||
unsigned := feed[:signatureStart]
|
||||
for _, broken := range []string{unsigned, feed + "<rss/>", strings.Replace(feed, "length:", "invalid:", 1), strings.Replace(feed, "edSignature:", "edSignature:broken", 1), strings.Replace(feed, "XStats updates", "tampered", 1)} {
|
||||
if validateSparkleFeed([]byte(broken), published) == nil {
|
||||
t.Fatal("invalid signature accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 与客户端 App/Info.plist 的 SUPublicEDKey 一致;更换发布密钥时同步更新。
|
||||
const sparklePublicKey = "j/I8zd8BXz4oPldnhocEySXTMmXPxKMyBRwoA4ewLIk="
|
||||
|
||||
type sparkleRSS struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channels []struct {
|
||||
Items []struct {
|
||||
Build string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle version"`
|
||||
Version string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle shortVersionString"`
|
||||
MinimumSystem string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle minimumSystemVersion"`
|
||||
Hardware string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle hardwareRequirements"`
|
||||
SHA256 string `xml:"https://github.com/ysicing/xstats/appcast sha256"`
|
||||
Enclosures []struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Size int64 `xml:"length,attr"`
|
||||
Signature string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle edSignature,attr"`
|
||||
InstallationType string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle installationType,attr"`
|
||||
} `xml:"enclosure"`
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
|
||||
func validateSparkleFeed(data []byte, current release) error {
|
||||
// Sparkle 2.10 将整个 XML 正文签名后附加此注释块;必须对原始字节验签。
|
||||
prefix := []byte("<!-- sparkle-signatures:\n")
|
||||
start := bytes.LastIndex(data, prefix)
|
||||
if start < 0 {
|
||||
return errors.New("missing feed signature")
|
||||
}
|
||||
block := data[start+len(prefix):]
|
||||
end := bytes.Index(block, []byte("-->"))
|
||||
if end < 0 || len(bytes.TrimSpace(block[end+3:])) != 0 {
|
||||
return errors.New("invalid signature block")
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(block[:end])), "\n")
|
||||
if len(lines) != 2 || !strings.HasPrefix(lines[0], "edSignature:") || !strings.HasPrefix(lines[1], "length:") {
|
||||
return errors.New("invalid signature fields")
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(lines[0], "edSignature:")))
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return errors.New("invalid feed signature")
|
||||
}
|
||||
length, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(lines[1], "length:")))
|
||||
if err != nil || length != start {
|
||||
return errors.New("invalid signed length")
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(sparklePublicKey)
|
||||
if err != nil || len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, data[:start], signature) {
|
||||
return errors.New("feed signature verification failed")
|
||||
}
|
||||
var rss sparkleRSS
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data[:start]))
|
||||
if err := decoder.Decode(&rss); err != nil {
|
||||
return err
|
||||
}
|
||||
// Decode 只读第一个元素,拒绝正文末尾的第二个根节点或无效 XML。
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch token := token.(type) {
|
||||
case xml.Comment:
|
||||
case xml.CharData:
|
||||
if len(bytes.TrimSpace(token)) != 0 {
|
||||
return errors.New("unexpected content after feed")
|
||||
}
|
||||
default:
|
||||
return errors.New("unexpected content after feed")
|
||||
}
|
||||
}
|
||||
if rss.XMLName.Space != "" || rss.Version != "2.0" || len(rss.Channels) != 1 || len(rss.Channels[0].Items) != 1 {
|
||||
return errors.New("invalid RSS channel or items")
|
||||
}
|
||||
item := rss.Channels[0].Items[0]
|
||||
if item.Version != current.Version || item.Build != current.Build || item.MinimumSystem != current.MinimumSystem || item.Hardware != "arm64" || item.SHA256 != current.SHA256 || len(item.Enclosures) != 1 {
|
||||
return errors.New("feed does not match published release")
|
||||
}
|
||||
enclosure := item.Enclosures[0]
|
||||
archiveSignature, err := base64.StdEncoding.DecodeString(enclosure.Signature)
|
||||
if err != nil || len(archiveSignature) != ed25519.SignatureSize || enclosure.InstallationType != "application" || enclosure.URL != current.URL || enclosure.Size != current.Size {
|
||||
return errors.New("invalid update enclosure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- sparkle-sign-warning:
|
||||
IMPORTANT: This file was signed by Sparkle. Any modifications to this file requires re-signing this file with generate_appcast or sign_update! The signed signature will be embedded at the end of this file.
|
||||
--><!-- SPDX-License-Identifier: AGPL-3.0-or-later; test metadata only, not an installable update. --><rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:xstats="https://github.com/ysicing/xstats/appcast" version="2.0">
|
||||
<channel>
|
||||
<title>XStats</title>
|
||||
<link>https://github.com/ysicing/xstats</link>
|
||||
<description>XStats updates</description>
|
||||
<item>
|
||||
<title>XStats 0.14.1</title>
|
||||
<sparkle:version>126</sparkle:version>
|
||||
<sparkle:shortVersionString>0.14.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
|
||||
<description xml:lang="zh-Hans" sparkle:format="plain-text">test</description>
|
||||
<xstats:notes-zh-Hans>test</xstats:notes-zh-Hans>
|
||||
<xstats:date>2026-09-30</xstats:date>
|
||||
<xstats:sha256>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</xstats:sha256>
|
||||
<enclosure url="https://example.test/XStats-0.14.1.zip" length="1000" type="application/octet-stream" sparkle:edSignature="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" sparkle:installationType="application"></enclosure>
|
||||
</item>
|
||||
</channel>
|
||||
</rss><!-- sparkle-signatures:
|
||||
edSignature: SgYaNl8guFoJ1FjdrrXpe0Jzj8CbeBCkAQLFdGtZ1Xuczh3YEBiGWI31wQUlF8NyrUsxyh+8jFv5oRJjuiYIBQ==
|
||||
length: 1565
|
||||
-->
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- sparkle-sign-warning:
|
||||
IMPORTANT: This file was signed by Sparkle. Any modifications to this file requires re-signing this file with generate_appcast or sign_update! The signed signature will be embedded at the end of this file.
|
||||
--><!-- SPDX-License-Identifier: AGPL-3.0-or-later; test metadata only, not an installable update. --><rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:xstats="https://github.com/ysicing/xstats/appcast" version="2.0">
|
||||
<channel>
|
||||
<title>XStats</title>
|
||||
<link>https://github.com/ysicing/xstats</link>
|
||||
<description>XStats updates</description>
|
||||
<item>
|
||||
<title>XStats 0.14.2</title>
|
||||
<sparkle:version>127</sparkle:version>
|
||||
<sparkle:shortVersionString>0.14.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
|
||||
<description xml:lang="zh-Hans" sparkle:format="plain-text">test</description>
|
||||
<xstats:notes-zh-Hans>test</xstats:notes-zh-Hans>
|
||||
<xstats:date>2026-09-30</xstats:date>
|
||||
<xstats:sha256>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</xstats:sha256>
|
||||
<enclosure url="https://example.test/XStats-0.14.2.zip" length="1000" type="application/octet-stream" sparkle:edSignature="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" sparkle:installationType="application"></enclosure>
|
||||
</item>
|
||||
</channel>
|
||||
</rss><!-- sparkle-signatures:
|
||||
edSignature: 9yHU+UlcaIppXEsdJkvWywed+IZUDhZiXCugaaBB0tGCIOU+Z5IKv43fYCv928oszMuNYGEao65RvojO8SO8Cw==
|
||||
length: 1565
|
||||
-->
|
||||
Reference in new issue
Block a user