feat(widget): 完善日历与 IP 信息并新增今日 Token 小组件

This commit is contained in:
ysicing committed 2026-09-24 16:51:33 +08:00
1 parent 014e7106d0
commit f3f64113f8
18 files changed
+458 -60

No files matched your search

+5
View File
@@ -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:
@@ -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.
@@ -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 لا تط
不上班 يوم عطلة
暂无安排 لا توجد بيانات
暂无法判断 لا يمكن التحديد حاليًا
工作日 يوم عمل
周末 عطلة نهاية الأسبوع
节假日 عطلة رسمية
调休补班 يوم عمل تعويضي
调休放假 إجازة تعويضية
黄道日 يوم ميمون
黑道日 يوم غير ميمون
查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي.
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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.
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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.
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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.
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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 アカウントのプラッ
不上班 休み
暂无安排 予定データなし
暂无法判断 判定できません
工作日 平日
周末 週末
节假日 祝日
调休补班 振替出勤
调休放假 振替休日
黄道日 吉日
黑道日 凶日
查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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 계정 플랫폼이 선택
不上班 쉬는 날
暂无安排 일정 데이터 없음
暂无法判断 아직 판단할 수 없음
工作日 평일
周末 주말
节假日 공휴일
调休补班 대체 근무
调休放假 대체 휴무
黄道日 길일
黑道日 흉일
查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다.
1 # Copyright (C) 2026 ysicing
2 # SPDX-License-Identifier: AGPL-3.0-or-later
3 暂无数据
4 主应用缓存
5 今日消耗的 Token
6 查看今天 Codex 和 Claude 的 Token 用量
7 分
8 查看 Codex 和 Claude 的剩余额度与重置时间
9 显示主应用已查询的公网 IP 纯净度
10 显示主应用已查询的公网 IPv4 和 IPv6
11 查看公网 IP、归属地、ASN 和标记
12 查看今天的日期或本月月历。
13 设备不支持
14 需要 macOS 15.1 或更新版本
1405 不上班
1406 暂无安排
1407 暂无法判断
1408 工作日
1409 周末
1410 节假日
1411 调休补班
1412 调休放假
1413 黄道日
1414 黑道日
1415 查看今天的节日与黄历。
@@ -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 帳號平台與所選來源
不上班 不上班
暂无安排 暫無安排
暂无法判断 暫時無法判斷
工作日 工作日
周末 週末
节假日 節假日
调休补班 調休補班
调休放假 調休放假
黄道日 黃道日
黑道日 黑道日
查看今天的节日与黄历。 查看今天的節日與黃曆。
1 # Copyright (c) 2026 GiantAccel, LLC
3 # SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
4 暂无数据
5 主应用缓存
6 今日消耗的 Token
7 查看今天 Codex 和 Claude 的 Token 用量
8 分
9 查看 Codex 和 Claude 的剩余额度与重置时间
10 显示主应用已查询的公网 IP 纯净度
11 显示主应用已查询的公网 IPv4 和 IPv6
12 查看公网 IP、归属地、ASN 和标记
13 查看今天的日期或本月月历。
14 设备不支持
15 需要 macOS 15.1 或更新版本
1406 不上班
1407 暂无安排
1408 暂无法判断
1409 工作日
1410 周末
1411 节假日
1412 调休补班
1413 调休放假
1414 黄道日
1415 黑道日
1416 查看今天的节日与黄历。
@@ -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:
@@ -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? {
@@ -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)
}
@@ -34,14 +34,38 @@ struct CalendarAlmanac: Identifiable {
}
extension CalendarEngine {
// 逐日类型依据国办发明电〔2025〕7号及 2024 年修订的《全国年节及纪念日放假办法》。
// Tyme 只记录假期区间与补班,不区分法定日和调休放假;其他年份不套用此表。
private static let statutoryHolidays2026: Set<String> = [
"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<String> = [
"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,
@@ -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)
}
@@ -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)
+37 -14
View File
@@ -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)
+194 -35
View File
@@ -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])
}
}
+1
View File
@@ -212,6 +212,7 @@ struct XStatsWidgets: WidgetBundle {
CalendarWidget()
TomorrowWorkWidget()
AIQuotaWidget()
TodayTokensWidget()
IPPurityWidget()
PublicIPWidget()
}