diff --git a/App/XStats.entitlements b/App/XStats.entitlements
new file mode 100644
index 0000000..3c7f2ac
--- /dev/null
+++ b/App/XStats.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.work.12306.xstats
+
+
+
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 42049fd..47712b8 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -52,7 +52,14 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
镜像发布到 `ghcr.io//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 单元测试。
diff --git a/Packages/XStatsKit/Package.swift b/Packages/XStatsKit/Package.swift
index c1c66bd..9b5566c 100644
--- a/Packages/XStatsKit/Package.swift
+++ b/Packages/XStatsKit/Package.swift
@@ -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]
)
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
index f5cac66..3f3e435 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
@@ -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 أو أحدث
支持 مدعوم
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
index 298ec03..57833f6 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
index 0100af2..ffc147c 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
index 730e3cb..80b9e03 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
index ddcd13a..09d2ad6 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
@@ -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以降が必要
支持 対応
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
index 9c7e199..c1317e3 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
@@ -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 이상 필요
支持 지원됨
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
index 44a9173..822676a 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
@@ -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 或更新版本
支持 支援
diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift
index 8d18c9e..fffa395 100644
--- a/Packages/XStatsKit/Sources/Localization/Translations.swift
+++ b/Packages/XStatsKit/Sources/Localization/Translations.swift
@@ -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
diff --git a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift
index fe4360b..b136e64 100644
--- a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift
+++ b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift
@@ -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
diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift
new file mode 100644
index 0000000..bf821cc
--- /dev/null
+++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift
@@ -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
+ }
+}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
index 4d35f1b..659ccf3 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
@@ -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?
/// 切换语言后重建应用菜单、菜单栏图标文字与已打开的弹窗
diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift
index 8403490..27c44ff 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift
@@ -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
diff --git a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift
index 3e896a4..d4cb770 100644
--- a/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift
+++ b/Packages/XStatsKit/Tests/UpdatesTests/UpdateFeedTests.swift
@@ -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) }
diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift
new file mode 100644
index 0000000..627900f
--- /dev/null
+++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift
@@ -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)
+}
diff --git a/README.en.md b/README.en.md
index 3fc00ac..5735b8d 100644
--- a/README.en.md
+++ b/README.en.md
@@ -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
diff --git a/README.ja.md b/README.ja.md
index ad9dc5c..25fbdb3 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -30,6 +30,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
- **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
+- **デスクトップウィジェット**:システム概要、AI 利用枠、カレンダー、IP の信頼度、公開 IP。AI と IP のデータはメインアプリのローカルキャッシュから読み取ります。
## インストール
diff --git a/README.ko.md b/README.ko.md
index 6a96593..6782eb4 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -30,6 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
+- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
## 설치
diff --git a/README.md b/README.md
index 3d91a5c..3133cb1 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
- **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
+- **桌面小组件**:系统概览、AI 额度、日历、IP 纯净度和公网 IP;AI 与 IP 数据只读取主应用的本地缓存。
## 安装
diff --git a/Taskfile.yml b/Taskfile.yml
index 65f83a2..118b606 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -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
diff --git a/Widget/CalendarWidget.swift b/Widget/CalendarWidget.swift
new file mode 100644
index 0000000..03e0248
--- /dev/null
+++ b/Widget/CalendarWidget.swift
@@ -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) -> 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])
+ }
+}
diff --git a/Widget/SharedDataWidgets.swift b/Widget/SharedDataWidgets.swift
new file mode 100644
index 0000000..56e1f3c
--- /dev/null
+++ b/Widget/SharedDataWidgets.swift
@@ -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) -> 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])
+ }
+}
diff --git a/Widget/XStatsWidget.entitlements b/Widget/XStatsWidget.entitlements
index 852fa1a..6b791c5 100644
--- a/Widget/XStatsWidget.entitlements
+++ b/Widget/XStatsWidget.entitlements
@@ -4,5 +4,9 @@
com.apple.security.app-sandbox
+ com.apple.security.application-groups
+
+ group.work.12306.xstats
+
diff --git a/Widget/XStatsWidget.swift b/Widget/XStatsWidget.swift
index 202ee18..064d879 100644
--- a/Widget/XStatsWidget.swift
+++ b/Widget/XStatsWidget.swift
@@ -209,5 +209,9 @@ struct SystemWidget: Widget {
struct XStatsWidgets: WidgetBundle {
var body: some Widget {
SystemWidget()
+ CalendarWidget()
+ AIQuotaWidget()
+ IPPurityWidget()
+ PublicIPWidget()
}
}
diff --git a/project.yml b/project.yml
index 1d147cb..24d61e4 100644
--- a/project.yml
+++ b/project.yml
@@ -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
diff --git a/scripts/install_local.sh b/scripts/install_local.sh
index 11f6525..8d0f20c 100755
--- a/scripts/install_local.sh
+++ b/scripts/install_local.sh
@@ -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
diff --git a/scripts/release.sh b/scripts/release.sh
index 645a111..6933031 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -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 顶部的正式标题,同时推进一次内部构建号;
# 顶部还是 “## 未发布” 时脚本会在这里失败