feat(widget): 新增桌面小组件并修复更新扩展退出

This commit is contained in:
ysicing committed 2026-09-24 15:12:03 +08:00
1 parent 9f4080354c
commit 2cd65e50dd
29 files changed
+798 -9

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright (C) 2026 ysicing
SPDX-License-Identifier: AGPL-3.0-or-later -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.work.12306.xstats</string>
</array>
</dict>
</plist>
+9 -1
View File
@@ -52,7 +52,14 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
镜像发布到 `ghcr.io/<owner>/xstats-server`,标签格式为清洗后的 `<分支>-<完整提交哈希>`。
构建固定使用 `arm64`,仅支持 Apple Silicon Mac。
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中的第一个证书;否则使用项目默认的 ad-hoc 签名。
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。主应用未查询过数据时,小组件显示空状态。
主应用与扩展的 Bundle ID 分别是 `work.12306.xstats.app` 和 `work.12306.xstats.app.widget`;
带 App Group 能力的 Developer ID provisioning profile 要分别安装,并通过
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
若钥匙串里有多张同名证书,应确保所选证书包含在两个 profile 中;可用 `SIGN_ID` 指定对应证书的 SHA-1 指纹。
菜单栏应用没有普通窗口,可以用已安装的应用渲染实时截图:
@@ -166,6 +173,7 @@ git push
- `Packages/XStatsKit/Sources/Updates`:版本清单、下载校验和应用替换。
- `Packages/XStatsKit/Sources/HelperShared`:应用与辅助工具共用的 XPC 协议和维护命令。
- `Packages/XStatsKit/Sources/WebDAVSync`:WebDAV 同步和钥匙串密码存储。
- `Packages/XStatsKit/Sources/WidgetData`:主应用与桌面小组件共享的无凭据展示摘要。
- `Packages/XStatsKit/Sources/XStatsUI`:界面、设置、弹窗和菜单栏渲染。
- `Packages/XStatsKit/Tests`:Swift 单元测试。
+4 -1
View File
@@ -14,6 +14,7 @@ let package = Package(
.library(name: "Cleaner", targets: ["Cleaner"]),
.library(name: "Updates", targets: ["Updates"]),
.library(name: "WebDAVSync", targets: ["WebDAVSync"]),
.library(name: "WidgetData", targets: ["WidgetData"]),
.library(name: "XStatsUI", targets: ["XStatsUI"]),
],
dependencies: [
@@ -28,7 +29,8 @@ let package = Package(
.target(name: "Cleaner", dependencies: ["Localization"]),
.target(name: "Updates", dependencies: ["Localization"]),
.target(name: "WebDAVSync", dependencies: ["Localization"]),
.target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync",
.target(name: "WidgetData"),
.target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync", "WidgetData",
.product(name: "Tyme4Swift", package: "tyme4swift")],
resources: [.copy("Resources/Flags"), .copy("Resources/Logos"), .copy("Resources/Legal")]),
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
@@ -39,6 +41,7 @@ let package = Package(
.testTarget(name: "XStatsUITests", dependencies: ["XStatsUI", "Metrics", "WebDAVSync", "Localization", "Updates"]),
.testTarget(name: "LocalizationTests", dependencies: ["Localization"]),
.testTarget(name: "AIUsageTests", dependencies: ["AIUsage"]),
.testTarget(name: "WidgetDataTests", dependencies: ["WidgetData"]),
],
swiftLanguageModes: [.v6]
)
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 لا توجد بيانات بعد
主应用缓存 ذاكرة XStats المؤقتة
分 نقطة
查看 Codex 和 Claude 的剩余额度与重置时间 عرض الحصة المتبقية لـ Codex وClaude ووقت إعادة التعيين
显示主应用已查询的公网 IP 纯净度 عرض نقاء عنوان IP العام الذي استعلم عنه XStats
显示主应用已查询的公网 IPv4 和 IPv6 عرض عنواني IPv4 وIPv6 العامين اللذين استعلم عنهما XStats
查看今天的日期或本月月历。 عرض تاريخ اليوم أو تقويم هذا الشهر.
设备不支持 الجهاز غير مدعوم
需要 macOS 15.1 或更新版本 يتطلب macOS 15.1 أو أحدث
支持 مدعوم
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 Noch keine Daten
主应用缓存 XStats-Cache
分 Punkte
查看 Codex 和 Claude 的剩余额度与重置时间 Verbleibendes Codex- und Claude-Kontingent mit Rücksetzzeit anzeigen
显示主应用已查询的公网 IP 纯净度 Von XStats ermittelte IP-Reputation anzeigen
显示主应用已查询的公网 IPv4 和 IPv6 Von XStats ermittelte öffentliche IPv4 und IPv6 anzeigen
查看今天的日期或本月月历。 Heutiges Datum oder Monatskalender anzeigen.
设备不支持 Gerät nicht unterstützt
需要 macOS 15.1 或更新版本 Erfordert macOS 15.1 oder neuer
支持 Unterstützt
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 Aún no hay datos
主应用缓存 Caché de XStats
分 puntos
查看 Codex 和 Claude 的剩余额度与重置时间 Ver cuotas restantes de Codex y Claude y cuándo se restablecen
显示主应用已查询的公网 IP 纯净度 Mostrar la reputación de IP consultada por XStats
显示主应用已查询的公网 IPv4 和 IPv6 Mostrar las IPv4 e IPv6 públicas consultadas por XStats
查看今天的日期或本月月历。 Ver la fecha de hoy o el calendario del mes.
设备不支持 Dispositivo no compatible
需要 macOS 15.1 或更新版本 Requiere macOS 15.1 o posterior
支持 Compatible
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 Aucune donnée pour le moment
主应用缓存 Cache de XStats
分 points
查看 Codex 和 Claude 的剩余额度与重置时间 Afficher les quotas restants de Codex et Claude et leur réinitialisation
显示主应用已查询的公网 IP 纯净度 Afficher la réputation IP consultée par XStats
显示主应用已查询的公网 IPv4 和 IPv6 Afficher les adresses IPv4 et IPv6 publiques consultées par XStats
查看今天的日期或本月月历。 Afficher la date du jour ou le calendrier du mois.
设备不支持 Appareil non compatible
需要 macOS 15.1 或更新版本 Nécessite macOS 15.1 ou une version ultérieure
支持 Compatible
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 データがありません
主应用缓存 XStatsのキャッシュ
分 点
查看 Codex 和 Claude 的剩余额度与重置时间 CodexとClaudeの残り枠とリセット時刻を表示
显示主应用已查询的公网 IP 纯净度 XStatsが取得した公開IPの信頼度を表示
显示主应用已查询的公网 IPv4 和 IPv6 XStatsが取得した公開IPv4とIPv6を表示
查看今天的日期或本月月历。 今日の日付または今月のカレンダーを表示します。
设备不支持 このデバイスは非対応
需要 macOS 15.1 或更新版本 macOS 15.1以降が必要
支持 対応
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,5 +1,12 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
暂无数据 데이터 없음
主应用缓存 XStats 캐시
分 점
查看 Codex 和 Claude 的剩余额度与重置时间 Codex와 Claude의 남은 한도와 초기화 시간 표시
显示主应用已查询的公网 IP 纯净度 XStats가 조회한 공인 IP 신뢰도 표시
显示主应用已查询的公网 IPv4 和 IPv6 XStats가 조회한 공인 IPv4 및 IPv6 표시
查看今天的日期或本月月历。 오늘 날짜 또는 이번 달 달력을 표시합니다.
设备不支持 기기에서 지원하지 않음
需要 macOS 15.1 或更新版本 macOS 15.1 이상 필요
支持 지원됨
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 分
6 查看 Codex 和 Claude 的剩余额度与重置时间
7 显示主应用已查询的公网 IP 纯净度
8 显示主应用已查询的公网 IPv4 和 IPv6
9 查看今天的日期或本月月历。
10 设备不支持
11 需要 macOS 15.1 或更新版本
12 支持
@@ -1,6 +1,13 @@
# Copyright (c) 2026 GiantAccel, LLC
# XStats modifications Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
暂无数据 暫無資料
主应用缓存 主應用程式快取
分 分
查看 Codex 和 Claude 的剩余额度与重置时间 查看 Codex 和 Claude 的剩餘額度與重置時間
显示主应用已查询的公网 IP 纯净度 顯示主應用程式已查詢的公網 IP 純淨度
显示主应用已查询的公网 IPv4 和 IPv6 顯示主應用程式已查詢的公網 IPv4 和 IPv6
查看今天的日期或本月月历。 查看今天的日期或本月月曆。
设备不支持 裝置不支援
需要 macOS 15.1 或更新版本 需要 macOS 15.1 或更新版本
支持 支援
1 # Copyright (c) 2026 GiantAccel, LLC
2 # XStats modifications Copyright (C) 2026 ysicing
3 # SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
4 暂无数据
5 主应用缓存
6 分
7 查看 Codex 和 Claude 的剩余额度与重置时间
8 显示主应用已查询的公网 IP 纯净度
9 显示主应用已查询的公网 IPv4 和 IPv6
10 查看今天的日期或本月月历。
11 设备不支持
12 需要 macOS 15.1 或更新版本
13 支持
@@ -39,6 +39,13 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Local Codex / Claude Code
启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage.
统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials.
订阅额度 Subscription usage
暂无数据 No data yet
主应用缓存 From XStats cache
分 points
查看 Codex 和 Claude 的剩余额度与重置时间 View remaining Codex and Claude quota and reset times
显示主应用已查询的公网 IP 纯净度 Show IP cleanliness already checked by the main app
显示主应用已查询的公网 IPv4 和 IPv6 Show public IPv4 and IPv6 already checked by the main app
查看今天的日期或本月月历。 View today's date or this month's calendar.
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
@@ -1,4 +1,5 @@
import CryptoKit
import Darwin
import Foundation
import Localization
import Security
@@ -112,6 +113,7 @@ public enum UpdateInstaller {
/// 旧版先改名为备份,再把新版移到原位置;移动失败时还原旧版。两者在同一卷上,改名是原子的。
public static func replace(_ current: URL, with candidate: URL, backupDirectory: URL) throws {
try stopWidgetExtension(in: current)
let manager = FileManager.default
let backup = backupDirectory.appendingPathComponent("previous-\(current.lastPathComponent)")
try? manager.removeItem(at: backup)
@@ -129,6 +131,36 @@ public enum UpdateInstaller {
try? manager.removeItem(at: backup)
}
/// 仅结束属于这份应用的旧小组件进程,避免替换后 WidgetKit 继续向旧进程索取组件清单。
public static func stopWidgetExtension(in app: URL) throws {
let executable = app.appendingPathComponent("Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget").path
var resolved = [CChar](repeating: 0, count: 4096)
guard realpath(executable, &resolved) != nil else { return }
let expectedPath = String(decoding: resolved.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }, as: UTF8.self)
let capacity = max(1, Int(proc_listallpids(nil, 0)) + 128)
var pids = [pid_t](repeating: 0, count: capacity)
let count = pids.withUnsafeMutableBytes { proc_listallpids($0.baseAddress, Int32($0.count)) }
guard count >= 0 else { throw UpdateError.install(tr("应用正在运行,请先退出")) }
for pid in pids.prefix(Int(count)) where pid > 0 {
var path = [CChar](repeating: 0, count: 4096)
guard proc_pidpath(pid, &path, UInt32(path.count)) > 0,
String(decoding: path.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }, as: UTF8.self) == expectedPath else {
continue
}
if kill(pid, SIGTERM) != 0 && errno != ESRCH {
throw UpdateError.install(tr("应用正在运行,请先退出") + " (XStatsWidget)")
}
for _ in 0..<50 {
if kill(pid, 0) != 0 { break }
usleep(100_000)
}
if kill(pid, 0) == 0 {
throw UpdateError.install(tr("应用正在运行,请先退出") + " (XStatsWidget)")
}
}
}
/// 当前用户能否直接改写应用所在目录(管理员账户对 /Applications 通常可以)
public static func canReplaceInPlace(_ app: URL) -> Bool {
let parent = app.deletingLastPathComponent().path
@@ -0,0 +1,98 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机 Token 日志。
public struct WidgetSnapshot: Codable, Equatable, Sendable {
public struct Quota: Codable, Equatable, Sendable {
public let provider: String
public let kind: String
public let remainingPercent: Double
public let resetsAt: Date?
public let fetchedAt: Date
public let isStale: Bool
public init(provider: String, kind: String, remainingPercent: Double, resetsAt: Date?,
fetchedAt: Date, isStale: Bool) {
self.provider = provider
self.kind = kind
self.remainingPercent = remainingPercent
self.resetsAt = resetsAt
self.fetchedAt = fetchedAt
self.isStale = isStale
}
}
public struct Address: Codable, Equatable, Sendable {
public let family: String
public let ip: String
public let countryCode: String?
public let city: String?
public let organization: String?
public let purityScore: Int?
public let purityGrade: String?
public init(family: String, ip: String, countryCode: String?, city: String?,
organization: String?, purityScore: Int?, purityGrade: String?) {
self.family = family
self.ip = ip
self.countryCode = countryCode
self.city = city
self.organization = organization
self.purityScore = purityScore
self.purityGrade = purityGrade
}
}
public var aiEnabled: Bool
public var quotas: [Quota]
public var publicIPEnabled: Bool
public var addresses: [Address]
public var language: String
public var calendarFirstWeekday: Int
public var showsLunar: Bool
public init(aiEnabled: Bool = false, quotas: [Quota] = [], publicIPEnabled: Bool = false,
addresses: [Address] = [], language: String = "system",
calendarFirstWeekday: Int = 2, showsLunar: Bool = true) {
self.aiEnabled = aiEnabled
self.quotas = quotas
self.publicIPEnabled = publicIPEnabled
self.addresses = addresses
self.language = language
self.calendarFirstWeekday = calendarFirstWeekday
self.showsLunar = showsLunar
}
/// 已过重置时间的缓存不再代表当前额度,Widget 不应继续显示旧百分比。
public func currentQuotas(at date: Date = .now) -> [Quota] {
guard aiEnabled else { return [] }
return quotas.filter { $0.resetsAt.map { $0 > date } ?? true }
}
public var visibleAddresses: [Address] { publicIPEnabled ? addresses : [] }
}
public struct WidgetSnapshotStore {
public static let appGroup = "group.work.12306.xstats"
private static let key = "widgetSnapshotV1"
private let defaults: UserDefaults?
public init(defaults: UserDefaults? = UserDefaults(suiteName: appGroup)) {
self.defaults = defaults
}
public func load() -> WidgetSnapshot {
guard let data = defaults?.data(forKey: Self.key),
let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { return WidgetSnapshot() }
return snapshot
}
@discardableResult
public func save(_ snapshot: WidgetSnapshot) -> Bool {
guard let defaults, let data = try? JSONEncoder().encode(snapshot) else { return false }
defaults.set(data, forKey: Self.key)
return true
}
}
@@ -8,10 +8,13 @@ import AIUsage
import Localization
import Metrics
import SwiftUI
import WidgetData
import WidgetKit
@MainActor
public final class AppController: NSObject, NSApplicationDelegate {
private let model = AppModel(quotaCacheURL: AIQuotaCacheStore.defaultURL)
private let widgetStore = WidgetSnapshotStore()
private var menuBar: MenuBarController!
private var calendarMenuBar: CalendarMenuBarController!
private var mainWindow: MainWindowController!
@@ -88,6 +91,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
observeProbeSettings()
observeAIUsageSchedule()
observeAIUsageState()
observeWidgetState()
syncWidgetSnapshot()
model.aiUsage.start()
applyAppearance()
updateNetworkVisibility()
@@ -327,6 +332,64 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
}
/// Widget 只读共享的展示摘要;所有凭据读取和网络查询仍留在主应用。
private func observeWidgetState() {
withObservationTracking {
_ = model.settings.aiUsageEnabled
_ = model.settings.aiUsageSources
_ = model.settings.publicIPLookup
_ = model.settings.language
_ = model.settings.calendarFirstWeekday
_ = model.settings.calendarFeatures
_ = model.aiUsage.quotaStates
_ = model.network.publicResults
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.syncWidgetSnapshot()
self.observeWidgetState()
}
}
}
private func syncWidgetSnapshot() {
let settings = model.settings
let quotas: [WidgetSnapshot.Quota] = settings.aiUsageEnabled
? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.flatMap { provider -> [WidgetSnapshot.Quota] in
let state = model.aiUsage.quotaState(for: provider)
guard let quota = state.snapshot else { return [] }
return quota.windows.map { window in
WidgetSnapshot.Quota(provider: provider.rawValue, kind: window.kind.rawValue,
remainingPercent: window.remainingPercent, resetsAt: window.resetsAt,
fetchedAt: quota.fetchedAt, isStale: state.isStale)
}
} : []
let addresses: [WidgetSnapshot.Address] = settings.publicIPLookup
? IPFamily.allCases.compactMap { family in
guard let result = model.network.publicResults[family],
let ip = family == .v4 ? result.ipv4 : result.ipv6 else { return nil }
return WidgetSnapshot.Address(family: family.rawValue, ip: ip,
countryCode: result.countryCode, city: result.city,
organization: result.organization,
purityScore: result.purity?.score, purityGrade: result.purity?.grade)
} : []
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
language: settings.language.rawValue,
calendarFirstWeekday: settings.calendarFirstWeekday,
showsLunar: settings.calendarFeatures.contains(.lunar))
let previous = widgetStore.load()
guard snapshot != previous, widgetStore.save(snapshot) else { return }
for kind in ["work.12306.xstats.widget.aiQuota", "work.12306.xstats.widget.ipPurity",
"work.12306.xstats.widget.publicIP"] {
WidgetCenter.shared.reloadTimelines(ofKind: kind)
}
if snapshot.language != previous.language || snapshot.calendarFirstWeekday != previous.calendarFirstWeekday
|| snapshot.showsLunar != previous.showsLunar {
WidgetCenter.shared.reloadTimelines(ofKind: "work.12306.xstats.widget.calendar")
}
}
private var appliedLanguage: AppLanguage?
/// 切换语言后重建应用菜单、菜单栏图标文字与已打开的弹窗
@@ -199,6 +199,7 @@ public final class UpdateController {
if UpdateInstaller.canReplaceInPlace(app) {
try await Task.detached { try UpdateInstaller.replace(app, with: candidate, backupDirectory: work) }.value
} else {
try await Task.detached { try UpdateInstaller.stopWidgetExtension(in: app) }.value
try await replaceWithAdministratorPrompt(app, with: candidate)
}
skippedVersion = nil
@@ -1,3 +1,4 @@
import Darwin
import Foundation
import Testing
@testable import Updates
@@ -121,6 +122,43 @@ import Testing
#expect(String(decoding: try Data(contentsOf: current.appendingPathComponent("v")), as: UTF8.self) == "new")
}
@Test func replacingAppStopsItsOldWidgetExtension() throws {
let dir = try scratch()
defer { try? FileManager.default.removeItem(at: dir) }
let current = dir.appendingPathComponent("XStats.app")
let candidate = dir.appendingPathComponent("New.app")
let executable = current.appendingPathComponent("Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget")
let otherExecutable = dir.appendingPathComponent("Other.app/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget")
try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/sleep"), to: executable)
try FileManager.default.createDirectory(at: otherExecutable.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/sleep"), to: otherExecutable)
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true)
let process = Process()
process.executableURL = executable
process.arguments = ["30"]
try process.run()
let otherProcess = Process()
otherProcess.executableURL = otherExecutable
otherProcess.arguments = ["30"]
try otherProcess.run()
defer {
if process.isRunning { process.terminate() }
process.waitUntilExit()
if otherProcess.isRunning { otherProcess.terminate() }
otherProcess.waitUntilExit()
}
var runningPath = [CChar](repeating: 0, count: 4096)
let pathLength = proc_pidpath(process.processIdentifier, &runningPath, UInt32(runningPath.count))
#expect(pathLength > 0)
try UpdateInstaller.replace(current, with: candidate, backupDirectory: dir)
#expect(!process.isRunning, "替换应用前应退出旧版小组件扩展")
#expect(otherProcess.isRunning, "不得退出其他应用的同名进程")
}
@Test func extractsSingleApp() throws {
let dir = try scratch()
defer { try? FileManager.default.removeItem(at: dir) }
@@ -0,0 +1,32 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
import WidgetData
@Test func snapshotRoundTripAndDisabledModules() throws {
let suite = "WidgetSnapshotTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = WidgetSnapshotStore(defaults: defaults)
let now = Date()
let snapshot = WidgetSnapshot(
aiEnabled: true,
quotas: [.init(provider: "codex", kind: "weekly", remainingPercent: 37, resetsAt: now.addingTimeInterval(60),
fetchedAt: now, isStale: false)],
publicIPEnabled: true,
addresses: [.init(family: "v4", ip: "203.0.113.1", countryCode: "CN", city: nil,
organization: nil, purityScore: 82, purityGrade: "A")],
language: "japanese", calendarFirstWeekday: 1, showsLunar: false)
#expect(store.save(snapshot))
#expect(store.load() == snapshot)
#expect(snapshot.currentQuotas(at: now).count == 1)
#expect(snapshot.currentQuotas(at: now.addingTimeInterval(61)).isEmpty)
var disabled = snapshot
disabled.aiEnabled = false
disabled.publicIPEnabled = false
#expect(disabled.currentQuotas(at: now).isEmpty)
#expect(disabled.visibleAddresses.isEmpty)
}
+1
View File
@@ -30,6 +30,7 @@ XStats shows CPU, GPU, memory, disk, network, battery, temperature and fan activ
- **System tools:** fan control, keep-awake, cache cleanup, app removal and startup-item management, with previews and confirmation for destructive actions.
- **Network tools:** inspect connections, DNS and public IPs; optionally check IP reputation and connectivity.
- **Optional modules:** a separate menu-bar calendar, plus local Codex / Claude Code session-token statistics and subscription quota checks.
- **Desktop widgets:** system overview, AI quotas, calendar, IP cleanliness and public IP. AI and IP data come only from the main app's local cache.
## Install
+1
View File
@@ -30,6 +30,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
- **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
- **デスクトップウィジェット**:システム概要、AI 利用枠、カレンダー、IP の信頼度、公開 IP。AI と IP のデータはメインアプリのローカルキャッシュから読み取ります。
## インストール
+1
View File
@@ -30,6 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
## 설치
+1
View File
@@ -30,6 +30,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
- **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
- **桌面小组件**:系统概览、AI 额度、日历、IP 纯净度和公网 IP;AI 与 IP 数据只读取主应用的本地缓存。
## 安装
+16 -2
View File
@@ -16,10 +16,14 @@ vars:
if [ -n "${SIGN_ID:-}" ]; then
printf '%s\n' "$SIGN_ID"
else
security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/' || true
security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application/ { print $2; exit }' || true
fi
TEAM_ID:
sh: echo '{{.SIGN_ID}}' | sed -nE 's/.*\(([A-Z0-9]+)\)$/\1/p'
sh: |
if [ -n '{{.SIGN_ID}}' ]; then
security find-identity -v -p codesigning 2>/dev/null \
| grep -F '{{.SIGN_ID}}' | head -1 | sed -nE 's/.*\(([A-Z0-9]+)\)"$/\1/p' || true
fi
tasks:
generate:
@@ -30,6 +34,15 @@ tasks:
build:
desc: 编译 App(含辅助工具)并安装
cmds:
- |
if [ "{{.INSTALL}}" != "0" ] && pgrep -x XStatsWidget >/dev/null; then
pkill -x XStatsWidget 2>/dev/null || true
for _ in $(seq 1 25); do pgrep -x XStatsWidget >/dev/null || break; sleep 0.2; done
if pgrep -x XStatsWidget >/dev/null; then
echo "error: XStatsWidget 尚未退出,无法安全构建安装" >&2
exit 1
fi
fi
- |
if [ "{{.BUMP}}" != "0" ]; then ./scripts/version.sh build; fi
- task: compile
@@ -47,6 +60,7 @@ tasks:
xcodebuild -project "{{.PROJECT}}" -scheme "{{.SCHEME}}" -configuration "{{.CONFIG}}" \
-derivedDataPath "{{.DERIVED}}" \
-destination 'generic/platform=macOS' ARCHS=arm64 ONLY_ACTIVE_ARCH=NO \
XSTATS_APP_PROFILE="${XSTATS_APP_PROFILE:-}" XSTATS_WIDGET_PROFILE="${XSTATS_WIDGET_PROFILE:-}" \
{{if .TEAM_ID}}CODE_SIGN_IDENTITY="{{.SIGN_ID}}" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM={{.TEAM_ID}}{{if eq .CONFIG "Release"}} OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO{{end}}{{end}} \
build
+138
View File
@@ -0,0 +1,138 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
import WidgetData
import WidgetKit
struct CalendarWidgetEntry: TimelineEntry {
let date: Date
let firstWeekday: Int
let showsLunar: Bool
}
struct CalendarWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> CalendarWidgetEntry {
.init(date: .now, firstWeekday: 2, showsLunar: true)
}
func getSnapshot(in context: Context, completion: @escaping (CalendarWidgetEntry) -> Void) {
completion(context.isPreview ? placeholder(in: context) : read())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarWidgetEntry>) -> Void) {
let entry = read()
let now = entry.date
let nextDay = Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1,
to: Calendar.autoupdatingCurrent.startOfDay(for: now))
?? now.addingTimeInterval(24 * 60 * 60)
completion(Timeline(entries: [entry], policy: .after(nextDay)))
}
private func read() -> CalendarWidgetEntry {
let snapshot = WidgetSnapshotStore().load()
L10n.configure(AppLanguage(rawValue: snapshot.language) ?? .system)
return .init(date: .now, firstWeekday: snapshot.calendarFirstWeekday, showsLunar: snapshot.showsLunar)
}
}
private struct CalendarWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: CalendarWidgetEntry
var body: some View {
Group {
if family == .systemMedium { monthView }
else { dayView }
}
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale)
}
private var dayView: some View {
VStack(alignment: .leading, spacing: 4) {
Label(tr("日历"), systemImage: "calendar")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(entry.date, format: .dateTime.day())
.font(.system(size: 56, weight: .medium, design: .rounded))
.minimumScaleFactor(0.7)
Text(entry.date, format: .dateTime.weekday(.wide).month(.wide))
.font(.subheadline)
.lineLimit(1)
if entry.showsLunar {
Text(lunarDate)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding()
}
private var monthView: some View {
var calendar = Calendar.autoupdatingCurrent
calendar.firstWeekday = entry.firstWeekday == 1 ? 1 : 2
let first = calendar.dateInterval(of: .month, for: entry.date)?.start ?? entry.date
let dayCount = calendar.range(of: .day, in: .month, for: entry.date)?.count ?? 30
let offset = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7
let formatter = DateFormatter()
formatter.locale = L10n.locale
let weekdays = formatter.veryShortStandaloneWeekdaySymbols ?? ["日", "一", "二", "三", "四", "五", "六"]
let orderedWeekdays = (0..<7).map { weekdays[($0 + calendar.firstWeekday - 1) % 7] }
return VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.date, format: .dateTime.year().month(.wide))
.font(.headline)
Spacer()
Image(systemName: "calendar")
.foregroundStyle(.secondary)
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 2), count: 7), spacing: 1) {
ForEach(orderedWeekdays.indices, id: \.self) { index in
Text(orderedWeekdays[index])
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(0..<(offset + dayCount), id: \.self) { index in
if index < offset {
Color.clear.frame(height: 16)
} else {
let day = index - offset + 1
Text("\(day)")
.font(.caption2.weight(day == calendar.component(.day, from: entry.date) ? .bold : .regular))
.foregroundStyle(day == calendar.component(.day, from: entry.date) ? .white : .primary)
.frame(maxWidth: .infinity, minHeight: 16)
.background(day == calendar.component(.day, from: entry.date) ? Color.accentColor : .clear,
in: RoundedRectangle(cornerRadius: 5))
}
}
}
Spacer(minLength: 0)
}
.padding(10)
}
private var lunarDate: String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .chinese)
formatter.locale = L10n.locale
formatter.dateFormat = "MMMMd"
return formatter.string(from: entry.date)
}
}
struct CalendarWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "work.12306.xstats.widget.calendar", provider: CalendarWidgetProvider()) { entry in
CalendarWidgetView(entry: entry)
}
.configurationDisplayName(tr("日历"))
.description(tr("查看今天的日期或本月月历。"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
import WidgetData
import WidgetKit
struct SharedDataEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot
}
struct SharedDataProvider: TimelineProvider {
func placeholder(in context: Context) -> SharedDataEntry {
let now = Date()
return SharedDataEntry(date: now, snapshot: WidgetSnapshot(
aiEnabled: true,
quotas: [.init(provider: "codex", kind: "weekly", remainingPercent: 68,
resetsAt: now.addingTimeInterval(2 * 24 * 3600), fetchedAt: now, isStale: false)],
publicIPEnabled: true,
addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: nil,
organization: nil, purityScore: 85, purityGrade: "A")]))
}
func getSnapshot(in context: Context, completion: @escaping (SharedDataEntry) -> Void) {
completion(context.isPreview ? placeholder(in: context) : read())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SharedDataEntry>) -> Void) {
let entry = read()
// 不发起网络请求;到期额度须及时隐藏,主应用写入新缓存时也会主动要求重载。
let nextReset = entry.snapshot.currentQuotas(at: entry.date)
.compactMap(\.resetsAt).min().map { $0.addingTimeInterval(1) }
let periodic = entry.date.addingTimeInterval(15 * 60)
completion(Timeline(entries: [entry], policy: .after(min(nextReset ?? periodic, periodic))))
}
private func read() -> SharedDataEntry {
let snapshot = WidgetSnapshotStore().load()
L10n.configure(AppLanguage(rawValue: snapshot.language) ?? .system)
return SharedDataEntry(date: .now, snapshot: snapshot)
}
}
private struct WidgetHeading: View {
let title: String
let symbol: String
var body: some View {
Label(title, systemImage: symbol)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
private struct WidgetEmptyState: View {
let enabled: Bool
var body: some View {
Text(enabled ? tr("暂无数据") : tr("尚未启用"))
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct AIQuotaWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: SharedDataEntry
private var featured: [WidgetSnapshot.Quota] {
["codex", "claude"].compactMap { provider in
let windows = entry.snapshot.currentQuotas(at: entry.date).filter { $0.provider == provider }
return windows.first(where: { $0.kind == "weekly" })
?? windows.first(where: { $0.kind.hasSuffix("Weekly") })
?? windows.first(where: { $0.kind == "session" })
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
WidgetHeading(title: tr("订阅额度"), symbol: "sparkles")
if let first = featured.first {
if family == .systemMedium {
HStack(alignment: .top, spacing: 16) {
ForEach(featured, id: \.provider) { quota in
quotaColumn(quota)
}
Spacer(minLength: 0)
}
} else {
quotaColumn(first)
}
} else {
WidgetEmptyState(enabled: entry.snapshot.aiEnabled)
}
Spacer(minLength: 0)
}
.padding()
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale)
}
private func quotaColumn(_ quota: WidgetSnapshot.Quota) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(quota.provider == "codex" ? "Codex" : "Claude")
.font(.subheadline.weight(.semibold))
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(Int(quota.remainingPercent.rounded()))%")
.font(.system(size: 27, weight: .semibold, design: .rounded))
.monospacedDigit()
Text(tr("剩余"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ProgressView(value: quota.remainingPercent, total: 100)
.tint(quota.remainingPercent < 20 ? .red : .blue)
if let reset = quota.resetsAt {
Text(tr("重置:") + " " + reset.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
if quota.isStale {
Text(tr("上次额度"))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AIQuotaWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "work.12306.xstats.widget.aiQuota", provider: SharedDataProvider()) { entry in
AIQuotaWidgetView(entry: entry)
}
.configurationDisplayName(tr("订阅额度"))
.description(tr("查看 Codex 和 Claude 的剩余额度与重置时间"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
private struct IPPurityWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: SharedDataEntry
private var addresses: [WidgetSnapshot.Address] {
entry.snapshot.visibleAddresses.filter { $0.purityScore != nil }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
WidgetHeading(title: tr("IP 纯净度"), symbol: "checkmark.shield")
if let first = addresses.first {
if family == .systemMedium {
HStack(alignment: .top, spacing: 16) {
ForEach(addresses, id: \.family) { address in purityColumn(address) }
Spacer(minLength: 0)
}
} else {
purityColumn(first)
}
} else {
WidgetEmptyState(enabled: entry.snapshot.publicIPEnabled)
}
Spacer(minLength: 0)
Text("cleanip.io · " + tr("主应用缓存"))
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding()
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale)
}
private func purityColumn(_ address: WidgetSnapshot.Address) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(address.purityScore ?? 0)")
.font(.system(size: 36, weight: .semibold, design: .rounded))
Text(tr("分"))
.font(.caption)
.foregroundStyle(.secondary)
if let grade = address.purityGrade {
Text(grade).font(.caption.weight(.bold)).foregroundStyle(.secondary)
}
}
Text(address.family == "v4" ? "IPv4" : "IPv6")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(address.ip)
.font(.caption.monospaced())
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct IPPurityWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "work.12306.xstats.widget.ipPurity", provider: SharedDataProvider()) { entry in
IPPurityWidgetView(entry: entry)
}
.configurationDisplayName(tr("IP 纯净度"))
.description(tr("显示主应用已查询的公网 IP 纯净度"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
private struct PublicIPWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: SharedDataEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
WidgetHeading(title: tr("公网 IP"), symbol: "globe")
if let first = entry.snapshot.visibleAddresses.first {
if family == .systemMedium {
ForEach(entry.snapshot.visibleAddresses, id: \.family) { address in addressRow(address) }
} else {
addressRow(first)
}
} else {
WidgetEmptyState(enabled: entry.snapshot.publicIPEnabled)
}
Spacer(minLength: 0)
Text(tr("主应用缓存"))
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding()
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale)
}
private func addressRow(_ address: WidgetSnapshot.Address) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(address.family == "v4" ? "IPv4" : "IPv6")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Text(address.ip)
.font(.system(size: family == .systemMedium ? 18 : 17, weight: .semibold, design: .monospaced))
.lineLimit(1)
.minimumScaleFactor(0.65)
if let location = [address.countryCode, address.city].compactMap({ $0 }).first {
Text(location).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
struct PublicIPWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "work.12306.xstats.widget.publicIP", provider: SharedDataProvider()) { entry in
PublicIPWidgetView(entry: entry)
}
.configurationDisplayName(tr("公网 IP"))
.description(tr("显示主应用已查询的公网 IPv4 和 IPv6"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.work.12306.xstats</string>
</array>
</dict>
</plist>
+4
View File
@@ -209,5 +209,9 @@ struct SystemWidget: Widget {
struct XStatsWidgets: WidgetBundle {
var body: some Widget {
SystemWidget()
CalendarWidget()
AIQuotaWidget()
IPPurityWidget()
PublicIPWidget()
}
}
+13
View File
@@ -52,10 +52,16 @@ targets:
LSUIElement: true
LSApplicationCategoryType: public.app-category.utilities
NSHumanReadableCopyright: © 2026 XStats
entitlements:
path: App/XStats.entitlements
properties:
com.apple.security.application-groups:
- group.work.12306.xstats
settings:
base:
PRODUCT_NAME: XStats
PRODUCT_BUNDLE_IDENTIFIER: work.12306.xstats.app
PROVISIONING_PROFILE_SPECIFIER: $(XSTATS_APP_PROFILE)
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- package: XStatsKit
@@ -88,14 +94,21 @@ targets:
path: Widget/XStatsWidget.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.application-groups:
- group.work.12306.xstats
settings:
base:
PRODUCT_NAME: XStatsWidget
PRODUCT_BUNDLE_IDENTIFIER: work.12306.xstats.app.widget
PROVISIONING_PROFILE_SPECIFIER: $(XSTATS_WIDGET_PROFILE)
SKIP_INSTALL: YES
dependencies:
- package: XStatsKit
product: Metrics
- package: XStatsKit
product: WidgetData
- package: XStatsKit
product: Localization
XStatsHelper:
type: tool
+4 -3
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# 本机只保留一个 XStats:退出正在运行的旧版 → 删除 /Applications 里的旧版 → 把刚编译的新版移进去 → 启动。
# 本机只保留一个 XStats:退出正在运行的旧版及小组件扩展 → 删除 /Applications 里的旧版 → 把刚编译的新版移进去 → 启动。
# 用移动而不是复制,编译目录里不留第二份;其他位置被系统登记的同名应用会取消登记并提示。
#
# scripts/install_local.sh build/DerivedData-arm64/Build/Products/Release/XStats.app
@@ -14,9 +14,10 @@ LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchSe
[ -d "$SOURCE" ] || { echo "error: 找不到 $SOURCE" >&2; exit 1; }
codesign --verify --deep --strict "$SOURCE"
# 结束旧版。辅助工具在连接断开时会自动恢复风扇与睡眠设置,所以直接结束是安全的
# 旧扩展可能在主应用退出后继续运行,必须在替换包之前结束。
# 辅助工具在连接断开时会自动恢复风扇与睡眠设置,所以直接结束是安全的
# (不用 AppleScript 退出,免得弹出“自动化”权限请求)
for process_name in OpenStats XStats; do
for process_name in XStatsWidget OpenStats XStats; do
if pgrep -x "$process_name" >/dev/null; then
pkill -x "$process_name" 2>/dev/null || true
for _ in $(seq 1 25); do pgrep -x "$process_name" >/dev/null || break; sleep 0.2; done
+3 -2
View File
@@ -18,7 +18,7 @@ DOWNLOAD_BASE="${DOWNLOAD_BASE:-https://c.ysicing.net/oss/apps/macOS/XStats}"
DIST="${DIST:-dist}"
NOTARY_PROFILE="${NOTARY_PROFILE:-XStats}"
SIGN_ID="${SIGN_ID:-$(security find-identity -v -p codesigning 2>/dev/null \
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/' || true)}"
| awk '/Developer ID Application/ { print $2; exit }' || true)}"
APP="build/DerivedData-arm64/Build/Products/Release/XStats.app"
# 构建只允许版本元数据有未提交改动;Swift、脚本或其他源码必须来自当前 HEAD。
@@ -28,7 +28,8 @@ if [ -z "$SIGN_ID" ]; then
echo "error: 钥匙串里没有 Developer ID Application 证书,无法发布。" >&2
exit 1
fi
TEAM_ID="$(echo "$SIGN_ID" | sed -nE 's/.*\(([A-Z0-9]+)\)$/\1/p')"
TEAM_ID="$(security find-identity -v -p codesigning 2>/dev/null \
| grep -F "$SIGN_ID" | head -1 | sed -nE 's/.*\(([A-Z0-9]+)\)"$/\1/p' || true)"
# 公开版本号取自 CHANGELOG.md 顶部的正式标题,同时推进一次内部构建号;
# 顶部还是 “## 未发布” 时脚本会在这里失败