diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f64df42..5dd444c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,6 +18,11 @@ independently, so it remains useful when the app is not running. AI, IP, and calendar widgets instead read a credential-free App Group snapshot. The main app precomputes an eight-day holiday and almanac window; missing coverage is shown as unavailable, not guessed from weekdays. +The daily Token widget receives only per-provider totals and the local day, never session logs; +its displayed count resets at local midnight even if the main app has not refreshed yet. +The tomorrow-work widget uses the official 2026 holiday schedule to distinguish statutory +holidays, adjusted days off, ordinary weekends, and makeup workdays; later years need their +own confirmed schedule before receiving those specific labels. Things that are easy to get wrong and are handled on purpose: diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index 61e0469..5327eec 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -36,6 +36,11 @@ extension Translations { 不上班 Day off 暂无安排 No schedule data 暂无法判断 Cannot determine yet +工作日 Weekday +周末 Weekend +节假日 Public holiday +调休补班 Makeup workday +调休放假 Adjusted day off 黄道日 Auspicious day 黑道日 Inauspicious day 查看今天的节日与黄历。 See today's festivals and almanac. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index df8371b..4597a42 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 لا توجد بيانات بعد 主应用缓存 ذاكرة XStats المؤقتة +今日消耗的 Token الرموز المستهلكة اليوم +查看今天 Codex 和 Claude 的 Token 用量 عرض رموز Codex وClaude المستخدمة اليوم 分 نقطة 查看 Codex 和 Claude 的剩余额度与重置时间 عرض الحصة المتبقية لـ Codex وClaude ووقت إعادة التعيين 显示主应用已查询的公网 IP 纯净度 عرض نقاء عنوان IP العام الذي استعلم عنه XStats 显示主应用已查询的公网 IPv4 和 IPv6 عرض عنواني IPv4 وIPv6 العامين اللذين استعلم عنهما XStats +查看公网 IP、归属地、ASN 和标记 عرض عنوان IP العام والموقع وASN والعلامات 查看今天的日期或本月月历。 عرض تاريخ اليوم أو تقويم هذا الشهر. 设备不支持 الجهاز غير مدعوم 需要 macOS 15.1 或更新版本 يتطلب macOS 15.1 أو أحدث @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط 不上班 يوم عطلة 暂无安排 لا توجد بيانات 暂无法判断 لا يمكن التحديد حاليًا +工作日 يوم عمل +周末 عطلة نهاية الأسبوع +节假日 عطلة رسمية +调休补班 يوم عمل تعويضي +调休放假 إجازة تعويضية 黄道日 يوم ميمون 黑道日 يوم غير ميمون 查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 243b8d7..d196b9d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 Noch keine Daten 主应用缓存 XStats-Cache +今日消耗的 Token Heute verbrauchte Tokens +查看今天 Codex 和 Claude 的 Token 用量 Heutigen Token-Verbrauch von Codex und Claude anzeigen 分 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 +查看公网 IP、归属地、ASN 和标记 Öffentliche IP, Standort, ASN und Markierungen anzeigen 查看今天的日期或本月月历。 Heutiges Datum oder Monatskalender anzeigen. 设备不支持 Gerät nicht unterstützt 需要 macOS 15.1 或更新版本 Erfordert macOS 15.1 oder neuer @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st 不上班 Frei 暂无安排 Keine Daten 暂无法判断 Derzeit nicht bestimmbar +工作日 Werktag +周末 Wochenende +节假日 Feiertag +调休补班 Ausgleichsarbeitstag +调休放假 Freizeitausgleich 黄道日 Günstiger Tag 黑道日 Ungünstiger Tag 查看今天的节日与黄历。 Heutige Feiertage und Almanach anzeigen. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 13d5ded..6a76cf4 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 Aún no hay datos 主应用缓存 Caché de XStats +今日消耗的 Token Tokens consumidos hoy +查看今天 Codex 和 Claude 的 Token 用量 Ver los tokens usados hoy por Codex y Claude 分 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 +查看公网 IP、归属地、ASN 和标记 Ver la IP pública, ubicación, ASN y etiquetas 查看今天的日期或本月月历。 Ver la fecha de hoy o el calendario del mes. 设备不支持 Dispositivo no compatible 需要 macOS 15.1 或更新版本 Requiere macOS 15.1 o posterior @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A 不上班 Día libre 暂无安排 Sin datos 暂无法判断 Aún no se puede determinar +工作日 Día laborable +周末 Fin de semana +节假日 Festivo +调休补班 Jornada de compensación +调休放假 Descanso compensatorio 黄道日 Día propicio 黑道日 Día desfavorable 查看今天的节日与黄历。 Consulta las festividades y el almanaque de hoy. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 7ab0991..a6ed784 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 Aucune donnée pour le moment 主应用缓存 Cache de XStats +今日消耗的 Token Tokens consommés aujourd’hui +查看今天 Codex 和 Claude 的 Token 用量 Afficher les tokens utilisés aujourd’hui par Codex et Claude 分 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 +查看公网 IP、归属地、ASN 和标记 Voir l’IP publique, la localisation, l’ASN et les indicateurs 查看今天的日期或本月月历。 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 @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne 不上班 Repos 暂无安排 Aucune donnée 暂无法判断 Impossible à déterminer pour le moment +工作日 Jour ouvré +周末 Week-end +节假日 Jour férié +调休补班 Travail de rattrapage +调休放假 Repos compensatoire 黄道日 Jour favorable 黑道日 Jour défavorable 查看今天的节日与黄历。 Consultez les fêtes et l'almanach du jour. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 6d9ae42..514799d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 データがありません 主应用缓存 XStatsのキャッシュ +今日消耗的 Token 今日使用した Token +查看今天 Codex 和 Claude 的 Token 用量 今日の Codex と Claude の Token 使用量を表示 分 点 查看 Codex 和 Claude 的剩余额度与重置时间 CodexとClaudeの残り枠とリセット時刻を表示 显示主应用已查询的公网 IP 纯净度 XStatsが取得した公開IPの信頼度を表示 显示主应用已查询的公网 IPv4 和 IPv6 XStatsが取得した公開IPv4とIPv6を表示 +查看公网 IP、归属地、ASN 和标记 公開IP、所在地、ASN、タグを表示 查看今天的日期或本月月历。 今日の日付または今月のカレンダーを表示します。 设备不支持 このデバイスは非対応 需要 macOS 15.1 或更新版本 macOS 15.1以降が必要 @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ 不上班 休み 暂无安排 予定データなし 暂无法判断 判定できません +工作日 平日 +周末 週末 +节假日 祝日 +调休补班 振替出勤 +调休放假 振替休日 黄道日 吉日 黑道日 凶日 查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 6c495be..8747329 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -2,10 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later 暂无数据 데이터 없음 主应用缓存 XStats 캐시 +今日消耗的 Token 오늘 사용한 Token +查看今天 Codex 和 Claude 的 Token 用量 오늘 Codex와 Claude의 Token 사용량 보기 分 점 查看 Codex 和 Claude 的剩余额度与重置时间 Codex와 Claude의 남은 한도와 초기화 시간 표시 显示主应用已查询的公网 IP 纯净度 XStats가 조회한 공인 IP 신뢰도 표시 显示主应用已查询的公网 IPv4 和 IPv6 XStats가 조회한 공인 IPv4 및 IPv6 표시 +查看公网 IP、归属地、ASN 和标记 공인 IP, 위치, ASN 및 태그 보기 查看今天的日期或本月月历。 오늘 날짜 또는 이번 달 달력을 표시합니다. 设备不支持 기기에서 지원하지 않음 需要 macOS 15.1 或更新版本 macOS 15.1 이상 필요 @@ -1402,6 +1405,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택 不上班 쉬는 날 暂无安排 일정 데이터 없음 暂无法判断 아직 판단할 수 없음 +工作日 평일 +周末 주말 +节假日 공휴일 +调休补班 대체 근무 +调休放假 대체 휴무 黄道日 길일 黑道日 흉일 查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index f35cebe..20775ba 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -3,10 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later AND MIT 暂无数据 暫無資料 主应用缓存 主應用程式快取 +今日消耗的 Token 今日消耗的 Token +查看今天 Codex 和 Claude 的 Token 用量 查看今天 Codex 和 Claude 的 Token 用量 分 分 查看 Codex 和 Claude 的剩余额度与重置时间 查看 Codex 和 Claude 的剩餘額度與重置時間 显示主应用已查询的公网 IP 纯净度 顯示主應用程式已查詢的公網 IP 純淨度 显示主应用已查询的公网 IPv4 和 IPv6 顯示主應用程式已查詢的公網 IPv4 和 IPv6 +查看公网 IP、归属地、ASN 和标记 查看公網 IP、歸屬地、ASN 和標記 查看今天的日期或本月月历。 查看今天的日期或本月月曆。 设备不支持 裝置不支援 需要 macOS 15.1 或更新版本 需要 macOS 15.1 或更新版本 @@ -1403,6 +1406,11 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源 不上班 不上班 暂无安排 暫無安排 暂无法判断 暫時無法判斷 +工作日 工作日 +周末 週末 +节假日 節假日 +调休补班 調休補班 +调休放假 調休放假 黄道日 黃道日 黑道日 黑道日 查看今天的节日与黄历。 查看今天的節日與黃曆。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index fffa395..0c947d4 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -39,12 +39,15 @@ 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 +今日消耗的 Token Tokens used today +查看今天 Codex 和 Claude 的 Token 用量 View today's Codex and Claude token 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 +查看公网 IP、归属地、ASN 和标记 View public IP, location, ASN, and tags 查看今天的日期或本月月历。 View today's date or this month's calendar. 服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics 重置: Resets: diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift index e47fa89..ae2acf2 100644 --- a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift +++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift @@ -3,17 +3,30 @@ import Foundation -/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机 Token 日志。 +/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机会话日志。 public struct WidgetSnapshot: Codable, Equatable, Sendable { + public struct DailyTokenUsage: Codable, Equatable, Sendable { + public let provider: String + public let day: Date + public let tokens: Int + + public init(provider: String, day: Date, tokens: Int) { + self.provider = provider + self.day = day + self.tokens = tokens + } + } + public struct CalendarSummary: Codable, Equatable, Sendable { public enum Schedule: String, Codable, Sendable { - case work, dayOff, makeupWork, unknown + case work, weekend, holiday, makeupWork, makeupDayOff, unknown + case dayOff // 兼容旧版 App Group 摘要;新版会在下次同步时重算。 /// 数据未覆盖的年份不能按普通工作日推断,避免给出错误的二元答案。 public var needsWork: Bool? { switch self { case .work, .makeupWork: true - case .dayOff: false + case .weekend, .holiday, .makeupDayOff, .dayOff: false case .unknown: nil } } @@ -68,17 +81,35 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public let ip: String public let countryCode: String? public let city: String? + public let region: String? + public let cityEnglish: String? + public let regionEnglish: String? + public let asn: String? public let organization: String? + public let isNative: Bool? + public let ipType: String? + /// nil 表示尚无风险查询结果;空数组表示已查询且未检出标记。 + public let riskFlags: [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?) { + organization: String?, purityScore: Int?, purityGrade: String?, + region: String? = nil, cityEnglish: String? = nil, regionEnglish: String? = nil, + asn: String? = nil, isNative: Bool? = nil, ipType: String? = nil, + riskFlags: [String]? = nil) { self.family = family self.ip = ip self.countryCode = countryCode self.city = city + self.region = region + self.cityEnglish = cityEnglish + self.regionEnglish = regionEnglish + self.asn = asn self.organization = organization + self.isNative = isNative + self.ipType = ipType + self.riskFlags = riskFlags self.purityScore = purityScore self.purityGrade = purityGrade } @@ -86,6 +117,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public var aiEnabled: Bool public var quotas: [Quota] + /// 仅共享每个来源的当日合计;可选以兼容旧版 App Group 摘要。 + public var dailyTokens: [DailyTokenUsage]? public var publicIPEnabled: Bool public var addresses: [Address] public var language: String @@ -94,12 +127,14 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { /// 可选以兼容旧版主应用写入的 App Group 摘要。 public var calendarDays: [CalendarSummary]? - public init(aiEnabled: Bool = false, quotas: [Quota] = [], publicIPEnabled: Bool = false, + public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil, + publicIPEnabled: Bool = false, addresses: [Address] = [], language: String = "system", calendarFirstWeekday: Int = 2, showsLunar: Bool = true, calendarDays: [CalendarSummary]? = nil) { self.aiEnabled = aiEnabled self.quotas = quotas + self.dailyTokens = dailyTokens self.publicIPEnabled = publicIPEnabled self.addresses = addresses self.language = language @@ -114,6 +149,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { return quotas.filter { $0.resetsAt.map { $0 > date } ?? true } } + /// 旧日快照保留来源,但数值归零;Widget 不得把昨天的使用量当作今天的结果。 + public func todayTokens(for provider: String, at date: Date = .now, + calendar: Calendar = .autoupdatingCurrent) -> Int? { + guard aiEnabled, let usage = dailyTokens?.first(where: { $0.provider == provider }) else { return nil } + return calendar.isDate(usage.day, inSameDayAs: date) ? usage.tokens : 0 + } + public var visibleAddresses: [Address] { publicIPEnabled ? addresses : [] } public func calendarSummary(for date: Date, calendar: Calendar = .autoupdatingCurrent) -> CalendarSummary? { diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 34363d6..3864d64 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -347,6 +347,7 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.settings.language _ = model.settings.calendarFirstWeekday _ = model.settings.calendarFeatures + _ = model.aiUsage.states _ = model.aiUsage.quotaStates _ = model.network.publicResults } onChange: { [weak self] in @@ -366,7 +367,8 @@ public final class AppController: NSObject, NSApplicationDelegate { let todayKey = CalendarEngine.today(at: today)?.id // 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。 let calendarDays: [WidgetSnapshot.CalendarSummary] = { - if previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey { + if previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey, + previous.calendarDays?.contains(where: { $0.schedule == .dayOff }) == false { return previous.calendarDays ?? [] } return (0..<8).compactMap { offset in @@ -385,6 +387,12 @@ public final class AppController: NSObject, NSApplicationDelegate { fetchedAt: quota.fetchedAt, isStale: state.isStale) } } : [] + let dailyTokens: [WidgetSnapshot.DailyTokenUsage]? = settings.aiUsageEnabled + ? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.compactMap { provider in + guard let report = model.aiUsage.state(for: provider).snapshot?.localUsage else { return nil } + let tokens = LocalUsageReport.total(report.selected(days: 1, now: today, calendar: calendar)).total + return WidgetSnapshot.DailyTokenUsage(provider: provider.rawValue, day: today, tokens: tokens) + } : nil let addresses: [WidgetSnapshot.Address] = settings.publicIPLookup ? IPFamily.allCases.compactMap { family in guard let result = model.network.publicResults[family], @@ -392,16 +400,22 @@ public final class AppController: NSObject, NSApplicationDelegate { 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) + purityScore: result.purity?.score, purityGrade: result.purity?.grade, + region: result.region, cityEnglish: result.cityEnglish, + regionEnglish: result.regionEnglish, asn: result.asn, + isNative: result.isNative, ipType: result.ipType, + riskFlags: result.risk?.flags) } : [] let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas, + dailyTokens: dailyTokens, publicIPEnabled: settings.publicIPLookup, addresses: addresses, language: settings.language.rawValue, calendarFirstWeekday: settings.calendarFirstWeekday, showsLunar: settings.calendarFeatures.contains(.lunar), calendarDays: calendarDays) guard snapshot != previous, widgetStore.save(snapshot) else { return } - for kind in ["work.12306.xstats.widget.aiQuota", "work.12306.xstats.widget.ipPurity", + for kind in ["work.12306.xstats.widget.aiQuota", "work.12306.xstats.widget.todayTokens", + "work.12306.xstats.widget.ipPurity", "work.12306.xstats.widget.publicIP"] { WidgetCenter.shared.reloadTimelines(ofKind: kind) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift index 28ff68d..8c8b338 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift @@ -34,14 +34,38 @@ struct CalendarAlmanac: Identifiable { } extension CalendarEngine { + // 逐日类型依据国办发明电〔2025〕7号及 2024 年修订的《全国年节及纪念日放假办法》。 + // Tyme 只记录假期区间与补班,不区分法定日和调休放假;其他年份不套用此表。 + private static let statutoryHolidays2026: Set = [ + "2026-01-01", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19", + "2026-04-05", "2026-05-01", "2026-05-02", "2026-06-19", "2026-09-25", + "2026-10-01", "2026-10-02", "2026-10-03" + ] + private static let adjustedDaysOff2026: Set = [ + "2026-01-02", "2026-02-20", "2026-02-23", "2026-04-06", + "2026-05-04", "2026-05-05", "2026-10-05", "2026-10-06", "2026-10-07" + ] + static func widgetSummary(for day: CalendarDay) -> WidgetSnapshot.CalendarSummary { let schedule: WidgetSnapshot.CalendarSummary.Schedule if !hasHolidayData(year: day.year) { schedule = .unknown } else if let holiday = day.holiday { - schedule = holiday.isWork ? .makeupWork : .dayOff + if holiday.isWork { + schedule = .makeupWork + } else if day.year == 2026 { + if statutoryHolidays2026.contains(day.id) { + schedule = .holiday + } else if adjustedDaysOff2026.contains(day.id) { + schedule = .makeupDayOff + } else { + schedule = day.isWeekend ? .weekend : .unknown + } + } else { + schedule = .dayOff + } } else { - schedule = day.isWeekend ? .dayOff : .work + schedule = day.isWeekend ? .weekend : .work } let almanac = almanac(for: day) return .init(dateKey: day.id, festivals: day.festivals, diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift index 8915424..f34f999 100644 --- a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift +++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift @@ -52,9 +52,52 @@ import WidgetData #expect(try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) == snapshot) } +@Test func publicAddressSnapshotKeepsLocationASAndFlagsWithoutBreakingOldData() throws { + let enriched = Data(#"{"family":"v4","ip":"203.0.113.8","countryCode":"CN","city":"上海","region":"上海","cityEnglish":"Shanghai","regionEnglish":"Shanghai","organization":"Example","purityScore":85,"purityGrade":"A","asn":"AS4134","isNative":true,"ipType":"Residential IP","riskFlags":["proxy"]}"#.utf8) + let address = try JSONDecoder().decode(WidgetSnapshot.Address.self, from: enriched) + let encoded = try JSONEncoder().encode(address) + let fields = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(fields["region"] as? String == "上海") + #expect(fields["cityEnglish"] as? String == "Shanghai") + #expect(fields["regionEnglish"] as? String == "Shanghai") + #expect(fields["asn"] as? String == "AS4134") + #expect(fields["isNative"] as? Bool == true) + #expect(fields["ipType"] as? String == "Residential IP") + #expect(fields["riskFlags"] as? [String] == ["proxy"]) + + let old = Data(#"{"family":"v4","ip":"203.0.113.8","countryCode":"CN","city":null,"organization":null,"purityScore":null,"purityGrade":null}"#.utf8) + #expect(try JSONDecoder().decode(WidgetSnapshot.Address.self, from: old).ip == "203.0.113.8") +} + @Test func tomorrowWorkAnswerDoesNotGuessWithoutHolidayCoverage() { #expect(WidgetSnapshot.CalendarSummary.Schedule.work.needsWork == true) #expect(WidgetSnapshot.CalendarSummary.Schedule.makeupWork.needsWork == true) + #expect(WidgetSnapshot.CalendarSummary.Schedule.weekend.needsWork == false) + #expect(WidgetSnapshot.CalendarSummary.Schedule.holiday.needsWork == false) + #expect(WidgetSnapshot.CalendarSummary.Schedule.makeupDayOff.needsWork == false) #expect(WidgetSnapshot.CalendarSummary.Schedule.dayOff.needsWork == false) #expect(WidgetSnapshot.CalendarSummary.Schedule.unknown.needsWork == nil) } + +@Test func todayTokenUsageResetsAtLocalMidnightAndRespectsModuleSwitch() throws { + let scannedDay = try #require(ISO8601DateFormatter().date(from: "2026-09-24T15:00:00Z")) + let nextDay = try #require(ISO8601DateFormatter().date(from: "2026-09-24T16:00:00Z")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let usage = WidgetSnapshot.DailyTokenUsage(provider: "codex", day: scannedDay, tokens: 12_345) + var snapshot = WidgetSnapshot(aiEnabled: true, dailyTokens: [usage]) + + #expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == 12_345) + #expect(snapshot.todayTokens(for: "codex", at: nextDay, calendar: shanghai) == 0) + #expect(snapshot.todayTokens(for: "claude", at: scannedDay, calendar: shanghai) == nil) + let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(restored.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == 12_345) + snapshot.aiEnabled = false + #expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == nil) +} + +@Test func oldWidgetSnapshotDecodesWithoutDailyTokens() throws { + let legacy = Data(#"{"aiEnabled":true,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8) + let snapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy) + #expect(snapshot.todayTokens(for: "codex") == nil) +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift index b4fdaba..ca7f989 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift @@ -41,17 +41,40 @@ struct CalendarTests { @Test func widgetSummaryKeepsTomorrowScheduleAndAlmanacSeparate() throws { let work = try #require(CalendarEngine.day(year: 2026, month: 9, day: 20, timeZone: zone)) let holiday = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25, timeZone: zone)) + let holidayExtension = try #require(CalendarEngine.day(year: 2026, month: 10, day: 2, timeZone: zone)) + let weekend = try #require(CalendarEngine.day(year: 2026, month: 9, day: 26, timeZone: zone)) + let adjustedDayOff = try #require(CalendarEngine.day(year: 2026, month: 10, day: 5, timeZone: zone)) let ordinary = try #require(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone)) let unknown = try #require(CalendarEngine.day(year: 2027, month: 1, day: 4, timeZone: zone)) #expect(CalendarEngine.widgetSummary(for: work).schedule == .makeupWork) - #expect(CalendarEngine.widgetSummary(for: holiday).schedule == .dayOff) + #expect(CalendarEngine.widgetSummary(for: holiday).schedule.rawValue == "holiday") + #expect(CalendarEngine.widgetSummary(for: holidayExtension).schedule.rawValue == "holiday") + #expect(CalendarEngine.widgetSummary(for: weekend).schedule.rawValue == "weekend") + #expect(CalendarEngine.widgetSummary(for: adjustedDayOff).schedule.rawValue == "makeupDayOff") #expect(CalendarEngine.widgetSummary(for: ordinary).schedule == .work) #expect(CalendarEngine.widgetSummary(for: unknown).schedule == .unknown) #expect(CalendarEngine.widgetSummary(for: holiday).festivals.contains("中秋节")) #expect(CalendarEngine.widgetSummary(for: holiday).recommends.isEmpty == false) } + @Test func official2026ScheduleSeparatesStatutoryWeekendAndAdjustedRest() throws { + let cases: [(Int, Int, String)] = [ + (1, 2, "makeupDayOff"), + (2, 18, "holiday"), + (2, 20, "makeupDayOff"), + (4, 5, "holiday"), + (4, 6, "makeupDayOff"), + (5, 2, "holiday"), + (10, 3, "holiday"), + (10, 4, "weekend") + ] + for (month, day, expected) in cases { + let date = try #require(CalendarEngine.day(year: 2026, month: month, day: day, timeZone: zone)) + #expect(CalendarEngine.widgetSummary(for: date).schedule.rawValue == expected) + } + } + @Test func termsAndSeasonalBoundaries() throws { #expect(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone)?.solarTerm == "秋分") #expect(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone)?.solarTerm == nil) diff --git a/Widget/CalendarWidget.swift b/Widget/CalendarWidget.swift index 76a0df8..7678fd9 100644 --- a/Widget/CalendarWidget.swift +++ b/Widget/CalendarWidget.swift @@ -168,36 +168,59 @@ private struct TomorrowWorkWidgetView: View { let entry: CalendarWidgetEntry private var needsWork: Bool? { entry.tomorrow?.schedule.needsWork } + private var tomorrowDate: Date { + Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1, to: entry.date) ?? entry.date + } + + private var scheduleTitle: String? { + guard let schedule = entry.tomorrow?.schedule else { return nil } + return switch schedule { + case .work: tr("工作日") + case .weekend: tr("周末") + case .holiday: tr("节假日") + case .makeupWork: tr("调休补班") + case .makeupDayOff: tr("调休放假") + case .dayOff: tr("放假") + case .unknown: nil + } + } var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { Text(tr("明天上班吗")) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) + Text(tomorrowDate, format: .dateTime.month(.abbreviated).day().weekday(.abbreviated)) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.8) Spacer(minLength: 0) if let needsWork { - VStack(spacing: 8) { - Image(systemName: needsWork ? "checkmark.circle.fill" : "xmark.circle.fill") - .font(.system(size: 46, weight: .medium)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(needsWork ? .green : .primary) - .accessibilityHidden(true) - Text(tr(needsWork ? "上班" : "不上班")) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .lineLimit(1) - .minimumScaleFactor(0.8) + Text(tr(needsWork ? "上班" : "不上班")) + .font(.system(size: 27, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.8) + if let scheduleTitle { + VStack(alignment: .leading, spacing: 1) { + Text(scheduleTitle) + if entry.tomorrow?.schedule == .holiday, let holidayName = entry.tomorrow?.holidayName { + Text(tr(holidayName)) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) } - .frame(maxWidth: .infinity) } else { Text(tr("暂无法判断")) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(16) + .padding(14) .containerBackground(.background, for: .widget) .environment(\.locale, L10n.locale) .accessibilityElement(children: .combine) diff --git a/Widget/SharedDataWidgets.swift b/Widget/SharedDataWidgets.swift index 56e1f3c..5adbbf5 100644 --- a/Widget/SharedDataWidgets.swift +++ b/Widget/SharedDataWidgets.swift @@ -18,9 +18,13 @@ struct SharedDataProvider: TimelineProvider { aiEnabled: true, quotas: [.init(provider: "codex", kind: "weekly", remainingPercent: 68, resetsAt: now.addingTimeInterval(2 * 24 * 3600), fetchedAt: now, isStale: false)], + dailyTokens: [.init(provider: "codex", day: now, tokens: 28_400), + .init(provider: "claude", day: now, tokens: 12_600)], publicIPEnabled: true, - addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: nil, - organization: nil, purityScore: 85, purityGrade: "A")])) + addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: "上海", + organization: "Example Network", purityScore: 85, purityGrade: "A", + region: "上海", cityEnglish: "Shanghai", regionEnglish: "Shanghai", + asn: "AS4134", isNative: true, ipType: "Residential IP", riskFlags: [])])) } func getSnapshot(in context: Context, completion: @escaping (SharedDataEntry) -> Void) { @@ -33,7 +37,8 @@ struct SharedDataProvider: TimelineProvider { 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)))) + let nextMidnight = Calendar.autoupdatingCurrent.dateInterval(of: .day, for: entry.date)?.end + completion(Timeline(entries: [entry], policy: .after(min(nextReset ?? periodic, periodic, nextMidnight ?? periodic)))) } private func read() -> SharedDataEntry { @@ -145,6 +150,58 @@ struct AIQuotaWidget: Widget { } } +private struct TodayTokensWidgetView: View { + let entry: SharedDataEntry + + private var sources: [(name: String, tokens: Int)] { + ["codex", "claude"].compactMap { provider in + guard let tokens = entry.snapshot.todayTokens(for: provider, at: entry.date) else { return nil } + return (provider == "codex" ? "Codex" : "Claude", tokens) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + WidgetHeading(title: tr("今日消耗的 Token"), symbol: "sparkles") + if sources.isEmpty { + WidgetEmptyState(enabled: entry.snapshot.aiEnabled) + } else { + let total = sources.reduce(0) { $0 + $1.tokens } + Text(total.formatted(.number.notation(.compactName).locale(L10n.locale))) + .font(.system(size: 36, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityLabel(total.formatted(.number.locale(L10n.locale)) + " Tokens") + Spacer(minLength: 0) + ForEach(sources, id: \.name) { source in + HStack { + Text(source.name) + Spacer(minLength: 4) + Text(source.tokens.formatted(.number.notation(.compactName).locale(L10n.locale))) + .monospacedDigit() + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding() + .containerBackground(.background, for: .widget) + .environment(\.locale, L10n.locale) + } +} + +struct TodayTokensWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "work.12306.xstats.widget.todayTokens", provider: SharedDataProvider()) { entry in + TodayTokensWidgetView(entry: entry) + } + .configurationDisplayName(tr("今日消耗的 Token")) + .description(tr("查看今天 Codex 和 Claude 的 Token 用量")) + .supportedFamilies([.systemSmall]) + } +} + private struct IPPurityWidgetView: View { @Environment(\.widgetFamily) private var family let entry: SharedDataEntry @@ -158,9 +215,10 @@ private struct IPPurityWidgetView: View { 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) + VStack(alignment: .leading, spacing: 8) { + ForEach(addresses, id: \.family) { address in + purityRow(address, compact: addresses.count > 1) + } } } else { purityColumn(first) @@ -178,28 +236,68 @@ private struct IPPurityWidgetView: View { .environment(\.locale, L10n.locale) } + private func purityRow(_ address: WidgetSnapshot.Address, compact: Bool) -> some View { + HStack(alignment: .top, spacing: 16) { + score(for: address, compact: compact) + .frame(width: 90, alignment: .leading) + addressDetails(for: address, compact: compact) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + 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) + score(for: address, compact: false) + addressDetails(for: address, compact: false) } .frame(maxWidth: .infinity, alignment: .leading) } + + private func score(for address: WidgetSnapshot.Address, compact: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(address.purityScore ?? 0)") + .font(.system(size: compact ? 27 : 36, weight: .semibold, design: .rounded)) + Text(tr("分")) + .font(.caption) + .foregroundStyle(.secondary) + if let grade = address.purityGrade { + Text(grade).font(.caption.weight(.bold)).foregroundStyle(.secondary) + } + } + .lineLimit(1) + } + + private func addressDetails(for address: WidgetSnapshot.Address, compact: Bool) -> some View { + VStack(alignment: .leading, spacing: compact ? 2 : 5) { + if compact { + HStack(spacing: 5) { + Text(address.family == "v4" ? "IPv4" : "IPv6") + .foregroundStyle(.secondary) + Text(address.ip) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + .font(.caption2) + } else { + Text(address.family == "v4" ? "IPv4" : "IPv6") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + Text(address.ip) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .minimumScaleFactor(0.7) + } + let labels = ipWidgetTags(for: address) + if !labels.isEmpty { + Text(labels.joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } } struct IPPurityWidget: Widget { @@ -222,37 +320,98 @@ private struct PublicIPWidgetView: View { 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) } + HStack(alignment: .top, spacing: 12) { + ForEach(entry.snapshot.visibleAddresses, id: \.family) { address in addressColumn(address) } + } } else { - addressRow(first) + addressColumn(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) { + private func addressColumn(_ address: WidgetSnapshot.Address) -> some View { + VStack(alignment: .leading, spacing: 5) { 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)) + .font(.system(size: family == .systemMedium ? 15 : 18, 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) + .truncationMode(.middle) + .minimumScaleFactor(0.7) + .help(address.ip) + if let location = location(for: address) { + Text(location) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let asn = address.asn { + Text(asn) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + let tags = ipWidgetTags(for: address) + if !tags.isEmpty { + Text(tags.joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func location(for address: WidgetSnapshot.Address) -> String? { + var parts: [String] = [] + if let code = address.countryCode { + parts.append(L10n.locale.localizedString(forRegionCode: code) ?? code) + } + let region = L10n.usesEnglishNames ? (address.regionEnglish ?? address.region) : address.region + let city = L10n.usesEnglishNames ? (address.cityEnglish ?? address.city) : address.city + for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} + +private func ipWidgetTags(for address: WidgetSnapshot.Address) -> [String] { + if let flags = address.riskFlags, !flags.isEmpty { + return Array(flags.prefix(2)).map { flag in + switch flag { + case "vpn": "VPN" + case "proxy": tr("代理") + case "residentialProxy": tr("住宅代理") + case "tor": "Tor" + case "relay": tr("中继") + case "datacenter": tr("机房") + case "hosting": tr("托管") + case "abuser": tr("滥用记录") + default: flag } } } + var labels: [String] = [] + if let isNative = address.isNative { labels.append(tr(isNative ? "原生 IP" : "广播 IP")) } + if let ipType = address.ipType { + switch ipType.lowercased() { + case "residential ip": labels.append(tr("住宅 IP")) + case "datacenter ip", "hosting ip": labels.append(tr("机房 IP")) + case "mobile ip": labels.append(tr("移动网络 IP")) + case "business ip": labels.append(tr("企业 IP")) + default: break + } + } + if labels.isEmpty, address.riskFlags != nil { labels.append(tr("未检出")) } + return labels } struct PublicIPWidget: Widget { @@ -261,7 +420,7 @@ struct PublicIPWidget: Widget { PublicIPWidgetView(entry: entry) } .configurationDisplayName(tr("公网 IP")) - .description(tr("显示主应用已查询的公网 IPv4 和 IPv6")) + .description(tr("查看公网 IP、归属地、ASN 和标记")) .supportedFamilies([.systemSmall, .systemMedium]) } } diff --git a/Widget/XStatsWidget.swift b/Widget/XStatsWidget.swift index 4539243..1935e3f 100644 --- a/Widget/XStatsWidget.swift +++ b/Widget/XStatsWidget.swift @@ -212,6 +212,7 @@ struct XStatsWidgets: WidgetBundle { CalendarWidget() TomorrowWorkWidget() AIQuotaWidget() + TodayTokensWidget() IPPurityWidget() PublicIPWidget() }