feat(widget): 新增桌面小组件并修复更新扩展退出
This commit is contained in:
1 parent
9f4080354c
commit
2cd65e50dd
29 files changed
+798
-9
No files matched your search
@@ -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
@@ -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 单元测试。
|
||||
|
||||
|
||||
@@ -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,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,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,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,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,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,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 或更新版本
|
||||
支持 支援
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
|
||||
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
|
||||
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
|
||||
- **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
|
||||
- **デスクトップウィジェット**:システム概要、AI 利用枠、カレンダー、IP の信頼度、公開 IP。AI と IP のデータはメインアプリのローカルキャッシュから読み取ります。
|
||||
|
||||
## インストール
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
|
||||
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
|
||||
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
|
||||
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
|
||||
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
|
||||
|
||||
## 설치
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
|
||||
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
|
||||
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
|
||||
- **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
|
||||
- **桌面小组件**:系统概览、AI 额度、日历、IP 纯净度和公网 IP;AI 与 IP 数据只读取主应用的本地缓存。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -209,5 +209,9 @@ struct SystemWidget: Widget {
|
||||
struct XStatsWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SystemWidget()
|
||||
CalendarWidget()
|
||||
AIQuotaWidget()
|
||||
IPPurityWidget()
|
||||
PublicIPWidget()
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
|
||||
@@ -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
@@ -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 顶部的正式标题,同时推进一次内部构建号;
|
||||
# 顶部还是 “## 未发布” 时脚本会在这里失败
|
||||
|
||||
Reference in new issue
Block a user