From 2cd65e50ddaec38b35c8d9e7a638801e4b854523 Mon Sep 17 00:00:00 2001 From: ysicing Date: Thu, 24 Sep 2026 15:12:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=E6=96=B0=E5=A2=9E=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E7=BB=84=E4=BB=B6=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=A9=E5=B1=95=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/XStats.entitlements | 12 + DEVELOPMENT.md | 10 +- Packages/XStatsKit/Package.swift | 5 +- .../Sources/Localization/Resources/ar.tsv | 7 + .../Sources/Localization/Resources/de.tsv | 7 + .../Sources/Localization/Resources/es.tsv | 7 + .../Sources/Localization/Resources/fr.tsv | 7 + .../Sources/Localization/Resources/ja.tsv | 7 + .../Sources/Localization/Resources/ko.tsv | 7 + .../Localization/Resources/zh-Hant.tsv | 7 + .../Sources/Localization/Translations.swift | 7 + .../Sources/Updates/UpdateInstaller.swift | 32 +++ .../Sources/WidgetData/WidgetSnapshot.swift | 98 +++++++ .../XStatsUI/AppKit/AppController.swift | 63 +++++ .../XStatsUI/State/UpdateController.swift | 1 + .../Tests/UpdatesTests/UpdateFeedTests.swift | 38 +++ .../WidgetDataTests/WidgetSnapshotTests.swift | 32 +++ README.en.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + Taskfile.yml | 18 +- Widget/CalendarWidget.swift | 138 +++++++++ Widget/SharedDataWidgets.swift | 267 ++++++++++++++++++ Widget/XStatsWidget.entitlements | 4 + Widget/XStatsWidget.swift | 4 + project.yml | 13 + scripts/install_local.sh | 7 +- scripts/release.sh | 5 +- 29 files changed, 798 insertions(+), 9 deletions(-) create mode 100644 App/XStats.entitlements create mode 100644 Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift create mode 100644 Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift create mode 100644 Widget/CalendarWidget.swift create mode 100644 Widget/SharedDataWidgets.swift 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 顶部的正式标题,同时推进一次内部构建号; # 顶部还是 “## 未发布” 时脚本会在这里失败