feat(widget): 增强日历小组件并新增明天上班提示

This commit is contained in:
ysicing committed 2026-09-24 16:08:10 +08:00
1 parent 2cd65e50dd
commit 014e7106d0
21 files changed
+336 -50

No files matched your search

+3
View File
@@ -15,6 +15,9 @@ fans only when a visible surface needs them. Disk is read at most every 30 s, ba
The sandboxed WidgetKit extension samples CPU, memory, disk, and battery through `Metrics`
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.
Things that are easy to get wrong and are handled on purpose:
+1 -1
View File
@@ -55,7 +55,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。主应用未查询过数据时,小组件显示空状态。
小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。日历节假日与黄历由主应用预计算,Widget 只读共享摘要;主应用未生成数据时,小组件显示空状态。
主应用与扩展的 Bundle ID 分别是 `work.12306.xstats.app` 和 `work.12306.xstats.app.widget`;
带 App Group 能力的 Developer ID provisioning profile 要分别安装,并通过
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
@@ -32,6 +32,15 @@ extension Translations {
休 R
调休上班 Makeup workday
放假 Holiday
上班 Workday
不上班 Day off
暂无安排 No schedule data
暂无法判断 Cannot determine yet
黄道日 Auspicious day
黑道日 Inauspicious day
查看今天的节日与黄历。 See today's festivals and almanac.
明天上班吗 Work tomorrow?
查看明天是否上班或放假。 See whether tomorrow is a workday or holiday.
春节 Spring Festival
元宵节 Lantern Festival
龙头节 Dragon Head-Raising Festival
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 أعاد Sub2API بيانات حص
钥匙串读写失败,请检查权限 تعذرت قراءة سلسلة المفاتيح أو الكتابة إليها؛ تحقق من الأذونات
Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تطابق المصدر المحدد
本机账号可用,当前使用自动额度。 الحساب المحلي متاح؛ تُستخدم الآن بيانات الحصة التي جُلبت تلقائيًا.
上班 يوم عمل
不上班 يوم عطلة
暂无安排 لا توجد بيانات
暂无法判断 لا يمكن التحديد حاليًا
黄道日 يوم ميمون
黑道日 يوم غير ميمون
查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي.
明天上班吗 هل تعمل غدًا؟
查看明天是否上班或放假。 تحقق مما إذا كان الغد يوم عمل أم عطلة.
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API hat unbekannte Kontingentda
钥匙串读写失败,请检查权限 Schlüsselbund konnte nicht gelesen oder beschrieben werden; prüfe die Berechtigungen
Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos stimmt nicht mit der ausgewählten Quelle überein
本机账号可用,当前使用自动额度。 Das lokale Konto ist verfügbar; derzeit werden automatisch abgerufene Kontingentdaten verwendet.
上班 Arbeitstag
不上班 Frei
暂无安排 Keine Daten
暂无法判断 Derzeit nicht bestimmbar
黄道日 Günstiger Tag
黑道日 Ungünstiger Tag
查看今天的节日与黄历。 Heutige Feiertage und Almanach anzeigen.
明天上班吗 Morgen arbeiten?
查看明天是否上班或放假。 Zeigt, ob morgen ein Arbeits- oder freier Tag ist.
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API devolvió datos de cuota no
钥匙串读写失败,请检查权限 No se puede acceder al llavero; revisa los permisos
Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2API no coincide con la fuente seleccionada
本机账号可用,当前使用自动额度。 La cuenta local está disponible; se usan los datos de cuota consultados automáticamente.
上班 Día laborable
不上班 Día libre
暂无安排 Sin datos
暂无法判断 Aún no se puede determinar
黄道日 Día propicio
黑道日 Día desfavorable
查看今天的节日与黄历。 Consulta las festividades y el almanaque de hoy.
明天上班吗 ¿Trabajas mañana?
查看明天是否上班或放假。 Consulta si mañana es laborable o festivo.
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API a renvoyé des données de
钥匙串读写失败,请检查权限 Impossible d’accéder au trousseau ; vérifiez les autorisations
Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne correspond pas à la source sélectionnée
本机账号可用,当前使用自动额度。 Le compte local est disponible ; les données de quota récupérées automatiquement sont utilisées.
上班 Jour travaillé
不上班 Repos
暂无安排 Aucune donnée
暂无法判断 Impossible à déterminer pour le moment
黄道日 Jour favorable
黑道日 Jour défavorable
查看今天的节日与黄历。 Consultez les fêtes et l'almanach du jour.
明天上班吗 Travail demain ?
查看明天是否上班或放假。 Vérifiez si demain est un jour travaillé ou chômé.
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API から認識できないク
钥匙串读写失败,请检查权限 キーチェーンの読み書きに失敗しました。権限を確認してください
Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラットフォームが選択したソースと一致しません
本机账号可用,当前使用自动额度。 ローカルアカウントが利用可能です。現在は自動取得した利用枠を使用しています。
上班 勤務日
不上班 休み
暂无安排 予定データなし
暂无法判断 判定できません
黄道日 吉日
黑道日 凶日
查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。
明天上班吗 明日は出勤?
查看明天是否上班或放假。 明日が勤務日か休日かを確認します。
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API가 인식할 수 없는 할
钥匙串读写失败,请检查权限 키체인을 읽거나 쓸 수 없습니다. 권한을 확인하세요
Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택한 소스와 일치하지 않습니다
本机账号可用,当前使用自动额度。 로컬 계정을 사용할 수 있어 현재 자동 조회한 할당량을 사용합니다.
上班 근무
不上班 쉬는 날
暂无安排 일정 데이터 없음
暂无法判断 아직 판단할 수 없음
黄道日 길일
黑道日 흉일
查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다.
明天上班吗 내일 출근하나요?
查看明天是否上班或放假。 내일이 근무일인지 휴일인지 확인합니다.
1 # Copyright (C) 2026 ysicing
1398 钥匙串读写失败,请检查权限
1399 Sub2API 账号平台与所选来源不匹配
1400 本机账号可用,当前使用自动额度。
1401 上班
1402 不上班
1403 暂无安排
1404 暂无法判断
1405 黄道日
1406 黑道日
1407 查看今天的节日与黄历。
1408 明天上班吗
1409 查看明天是否上班或放假。
@@ -1399,3 +1399,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API 回傳了無法辨識的額
钥匙串读写失败,请检查权限 無法讀寫鑰匙圈,請檢查權限
Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源不符
本机账号可用,当前使用自动额度。 本機帳號可用,目前使用自動額度。
上班 上班
不上班 不上班
暂无安排 暫無安排
暂无法判断 暫時無法判斷
黄道日 黃道日
黑道日 黑道日
查看今天的节日与黄历。 查看今天的節日與黃曆。
明天上班吗 明天要上班嗎
查看明天是否上班或放假。 查看明天是否上班或放假。
1 # Copyright (c) 2026 GiantAccel, LLC
1399 钥匙串读写失败,请检查权限
1400 Sub2API 账号平台与所选来源不匹配
1401 本机账号可用,当前使用自动额度。
1402 上班
1403 不上班
1404 暂无安排
1405 暂无法判断
1406 黄道日
1407 黑道日
1408 查看今天的节日与黄历。
1409 明天上班吗
1410 查看明天是否上班或放假。
@@ -5,6 +5,45 @@ import Foundation
/// 跨进程共享的展示摘要,不包含账号凭据、请求内容或本机 Token 日志。
public struct WidgetSnapshot: Codable, Equatable, Sendable {
public struct CalendarSummary: Codable, Equatable, Sendable {
public enum Schedule: String, Codable, Sendable {
case work, dayOff, makeupWork, unknown
/// 数据未覆盖的年份不能按普通工作日推断,避免给出错误的二元答案。
public var needsWork: Bool? {
switch self {
case .work, .makeupWork: true
case .dayOff: false
case .unknown: nil
}
}
}
public let dateKey: String
public let festivals: [String]
public let solarTerm: String?
public let schedule: Schedule
public let holidayName: String?
public let twelveStar: String?
public let isEcliptic: Bool
public let recommends: [String]
public let avoids: [String]
public init(dateKey: String, festivals: [String], solarTerm: String?,
schedule: Schedule, holidayName: String?, twelveStar: String?, isEcliptic: Bool,
recommends: [String], avoids: [String]) {
self.dateKey = dateKey
self.festivals = festivals
self.solarTerm = solarTerm
self.schedule = schedule
self.holidayName = holidayName
self.twelveStar = twelveStar
self.isEcliptic = isEcliptic
self.recommends = recommends
self.avoids = avoids
}
}
public struct Quota: Codable, Equatable, Sendable {
public let provider: String
public let kind: String
@@ -52,10 +91,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
public var language: String
public var calendarFirstWeekday: Int
public var showsLunar: Bool
/// 可选以兼容旧版主应用写入的 App Group 摘要。
public var calendarDays: [CalendarSummary]?
public init(aiEnabled: Bool = false, quotas: [Quota] = [], publicIPEnabled: Bool = false,
addresses: [Address] = [], language: String = "system",
calendarFirstWeekday: Int = 2, showsLunar: Bool = true) {
calendarFirstWeekday: Int = 2, showsLunar: Bool = true,
calendarDays: [CalendarSummary]? = nil) {
self.aiEnabled = aiEnabled
self.quotas = quotas
self.publicIPEnabled = publicIPEnabled
@@ -63,6 +105,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
self.language = language
self.calendarFirstWeekday = calendarFirstWeekday
self.showsLunar = showsLunar
self.calendarDays = calendarDays
}
/// 已过重置时间的缓存不再代表当前额度,Widget 不应继续显示旧百分比。
@@ -72,6 +115,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
}
public var visibleAddresses: [Address] { publicIPEnabled ? addresses : [] }
public func calendarSummary(for date: Date, calendar: Calendar = .autoupdatingCurrent) -> CalendarSummary? {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil }
let key = String(format: "%04d-%02d-%02d", year, month, day)
return calendarDays?.first { $0.dateKey == key }
}
}
public struct WidgetSnapshotStore {
@@ -23,6 +23,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
private var speedTestWindow: SpeedTestWindowController!
private var egressWindow: EgressWindowController!
private var updateTimer: Timer?
private var widgetTimer: Timer?
private let hotKeys = HotKeyCenter()
private var workspaceObservers: [NSObjectProtocol] = []
@@ -93,6 +94,10 @@ public final class AppController: NSObject, NSApplicationDelegate {
observeAIUsageState()
observeWidgetState()
syncWidgetSnapshot()
widgetTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.syncWidgetSnapshot() }
}
widgetTimer?.tolerance = 5 * 60
model.aiUsage.start()
applyAppearance()
updateNetworkVisibility()
@@ -168,6 +173,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
public func applicationWillTerminate(_ notification: Notification) {
widgetTimer?.invalidate()
calendarMenuBar.stop()
model.aiUsage.stop()
model.keepAwake.releaseForTermination()
@@ -354,6 +360,21 @@ public final class AppController: NSObject, NSApplicationDelegate {
private func syncWidgetSnapshot() {
let settings = model.settings
let previous = widgetStore.load()
let calendar = CalendarEngine.gregorian()
let today = calendar.startOfDay(for: .now)
let todayKey = CalendarEngine.today(at: today)?.id
// 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。
let calendarDays: [WidgetSnapshot.CalendarSummary] = {
if previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey {
return previous.calendarDays ?? []
}
return (0..<8).compactMap { offset in
guard let date = calendar.date(byAdding: .day, value: offset, to: today),
let day = CalendarEngine.today(at: date) else { return nil }
return CalendarEngine.widgetSummary(for: day)
}
}()
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)
@@ -377,16 +398,17 @@ public final class AppController: NSObject, NSApplicationDelegate {
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
language: settings.language.rawValue,
calendarFirstWeekday: settings.calendarFirstWeekday,
showsLunar: settings.calendarFeatures.contains(.lunar))
let previous = widgetStore.load()
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",
"work.12306.xstats.widget.publicIP"] {
WidgetCenter.shared.reloadTimelines(ofKind: kind)
}
if snapshot.language != previous.language || snapshot.calendarFirstWeekday != previous.calendarFirstWeekday
|| snapshot.showsLunar != previous.showsLunar {
|| snapshot.showsLunar != previous.showsLunar || snapshot.calendarDays != previous.calendarDays {
WidgetCenter.shared.reloadTimelines(ofKind: "work.12306.xstats.widget.calendar")
WidgetCenter.shared.reloadTimelines(ofKind: "work.12306.xstats.widget.tomorrowWork")
}
}
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import WidgetData
@preconcurrency import Tyme4Swift
/// 选中日期才计算的黄历值模型,不在生成 42 格月历时重复查询宜忌表。
@@ -33,6 +34,23 @@ struct CalendarAlmanac: Identifiable {
}
extension CalendarEngine {
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
} else {
schedule = day.isWeekend ? .dayOff : .work
}
let almanac = almanac(for: day)
return .init(dateKey: day.id, festivals: day.festivals,
solarTerm: day.solarTerm, schedule: schedule, holidayName: day.holiday?.name,
twelveStar: almanac?.twelveStar, isEcliptic: almanac?.twelveStarIsLucky ?? false,
recommends: Array(almanac?.recommends.prefix(3) ?? []),
avoids: Array(almanac?.avoids.prefix(3) ?? []))
}
static func almanac(for day: CalendarDay) -> CalendarAlmanac? {
guard let solar = try? SolarDay.fromYmd(day.year, day.month, day.day) else { return nil }
let lunar = solar.getLunarDay()
@@ -30,3 +30,31 @@ import WidgetData
#expect(disabled.currentQuotas(at: now).isEmpty)
#expect(disabled.visibleAddresses.isEmpty)
}
@Test func calendarSummaryUsesLocalCivilDayAndOldCacheStillDecodes() throws {
let legacy = Data(#"{"aiEnabled":false,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8)
let oldSnapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy)
#expect(oldSnapshot.calendarDays == nil)
let day = WidgetSnapshot.CalendarSummary(dateKey: "2026-09-25", festivals: ["中秋节"],
solarTerm: nil, schedule: .dayOff, holidayName: "中秋节",
twelveStar: "除", isEcliptic: true,
recommends: ["祭祀"], avoids: ["动土"])
var snapshot = oldSnapshot
snapshot.calendarDays = [day]
let instant = try #require(ISO8601DateFormatter().date(from: "2026-09-24T16:00:00Z"))
var shanghai = Calendar(identifier: .gregorian)
shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
var utc = Calendar(identifier: .gregorian)
utc.timeZone = try #require(TimeZone(secondsFromGMT: 0))
#expect(snapshot.calendarSummary(for: instant, calendar: shanghai) == day)
#expect(snapshot.calendarSummary(for: instant, calendar: utc) == nil)
#expect(try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) == snapshot)
}
@Test func tomorrowWorkAnswerDoesNotGuessWithoutHolidayCoverage() {
#expect(WidgetSnapshot.CalendarSummary.Schedule.work.needsWork == true)
#expect(WidgetSnapshot.CalendarSummary.Schedule.makeupWork.needsWork == true)
#expect(WidgetSnapshot.CalendarSummary.Schedule.dayOff.needsWork == false)
#expect(WidgetSnapshot.CalendarSummary.Schedule.unknown.needsWork == nil)
}
@@ -38,6 +38,20 @@ struct CalendarTests {
#expect(CalendarEngine.hasHolidayData(year: 2099) == false)
}
@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 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: 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 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)
+1 -1
View File
@@ -30,7 +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.
- **Desktop widgets:** system overview, AI quotas, festival/almanac calendar, tomorrow's work status, IP cleanliness and public IP. AI, IP and calendar data come only from the main app's local cache.
## Install
+1 -1
View File
@@ -30,7 +30,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
- **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
- **デスクトップウィジェット**:システム概要、AI 利用枠、カレンダー、IP の信頼度、公開 IP。AI と IP のデータはメインアプリのローカルキャッシュから読み取ります。
- **デスクトップウィジェット**:システム概要、AI 利用枠、祝祭日・暦注カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。
## インストール
+1 -1
View File
@@ -30,7 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 명절·황력 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
## 설치
+1 -1
View File
@@ -30,7 +30,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
- **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
- **桌面小组件**:系统概览、AI 额度、日历、IP 纯净度和公网 IP;AI 与 IP 数据只读取主应用的本地缓存。
- **桌面小组件**:系统概览、AI 额度、节日黄历日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。
## 安装
+119 -41
View File
@@ -8,13 +8,14 @@ import WidgetKit
struct CalendarWidgetEntry: TimelineEntry {
let date: Date
let firstWeekday: Int
let showsLunar: Bool
let today: WidgetSnapshot.CalendarSummary?
let tomorrow: WidgetSnapshot.CalendarSummary?
}
struct CalendarWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> CalendarWidgetEntry {
.init(date: .now, firstWeekday: 2, showsLunar: true)
.init(date: .now, showsLunar: true, today: nil, tomorrow: nil)
}
func getSnapshot(in context: Context, completion: @escaping (CalendarWidgetEntry) -> Void) {
@@ -33,7 +34,11 @@ struct CalendarWidgetProvider: TimelineProvider {
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)
let now = Date.now
let nextDay = Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1, to: now) ?? now
return .init(date: now, showsLunar: snapshot.showsLunar,
today: snapshot.calendarSummary(for: now),
tomorrow: snapshot.calendarSummary(for: nextDay))
}
}
@@ -51,13 +56,13 @@ private struct CalendarWidgetView: View {
}
private var dayView: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 3) {
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))
.font(.system(size: 50, weight: .medium, design: .rounded))
.minimumScaleFactor(0.7)
Text(entry.date, format: .dateTime.weekday(.wide).month(.wide))
.font(.subheadline)
@@ -68,53 +73,75 @@ private struct CalendarWidgetView: View {
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let occasion = occasion {
Text(occasion)
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding()
.padding(12)
}
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")
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Label(tr("日历"), systemImage: "calendar")
.font(.caption.weight(.semibold))
.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)
Text(entry.date, format: .dateTime.day())
.font(.system(size: 50, weight: .medium, design: .rounded))
Text(entry.date, format: .dateTime.weekday(.wide).month(.wide))
.font(.caption)
.lineLimit(1)
if entry.showsLunar {
Text(lunarDate).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
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))
if let occasion {
Text(occasion).font(.caption.weight(.medium)).foregroundStyle(.orange).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Rectangle().fill(.quaternary).frame(width: 1)
VStack(alignment: .leading, spacing: 7) {
if let today = entry.today {
if let star = today.twelveStar {
HStack(spacing: 5) {
Text(tr(today.isEcliptic ? "黄道日" : "黑道日"))
.font(.caption.weight(.semibold))
.foregroundStyle(today.isEcliptic ? .green : .secondary)
Text(star).font(.caption2).foregroundStyle(.secondary)
}
Divider()
}
advice(tr("宜"), values: today.recommends)
advice(tr("忌"), values: today.avoids)
} else {
Text(tr("暂无安排")).font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.padding(12)
}
private func advice(_ title: String, values: [String]) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(title).fontWeight(.semibold).foregroundStyle(.secondary)
Text(values.isEmpty ? tr("无") : values.joined(separator: " · "))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.font(.caption2)
}
private var occasion: String? {
if let festival = entry.today?.festivals.first { return tr(festival) }
if let term = entry.today?.solarTerm { return tr(term) }
return nil
}
private var lunarDate: String {
@@ -132,7 +159,58 @@ struct CalendarWidget: Widget {
CalendarWidgetView(entry: entry)
}
.configurationDisplayName(tr("日历"))
.description(tr("查看今天的日期或本月月历。"))
.description(tr("查看今天的节日与黄历。"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
private struct TomorrowWorkWidgetView: View {
let entry: CalendarWidgetEntry
private var needsWork: Bool? { entry.tomorrow?.schedule.needsWork }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(tr("明天上班吗"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
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)
}
.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)
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale)
.accessibilityElement(children: .combine)
}
}
struct TomorrowWorkWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "work.12306.xstats.widget.tomorrowWork", provider: CalendarWidgetProvider()) { entry in
TomorrowWorkWidgetView(entry: entry)
}
.configurationDisplayName(tr("明天上班吗"))
.description(tr("查看明天是否上班或放假。"))
.supportedFamilies([.systemSmall])
}
}
+1
View File
@@ -210,6 +210,7 @@ struct XStatsWidgets: WidgetBundle {
var body: some Widget {
SystemWidget()
CalendarWidget()
TomorrowWorkWidget()
AIQuotaWidget()
IPPurityWidget()
PublicIPWidget()