From 014e7106d03bf73f6937f8710f24200508efb771 Mon Sep 17 00:00:00 2001 From: ysicing Date: Thu, 24 Sep 2026 16:08:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=E5=A2=9E=E5=BC=BA=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E5=B0=8F=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=98=8E=E5=A4=A9=E4=B8=8A=E7=8F=AD=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 3 + DEVELOPMENT.md | 2 +- .../Localization/CalendarTranslations.swift | 9 + .../Sources/Localization/Resources/ar.tsv | 9 + .../Sources/Localization/Resources/de.tsv | 9 + .../Sources/Localization/Resources/es.tsv | 9 + .../Sources/Localization/Resources/fr.tsv | 9 + .../Sources/Localization/Resources/ja.tsv | 9 + .../Sources/Localization/Resources/ko.tsv | 9 + .../Localization/Resources/zh-Hant.tsv | 9 + .../Sources/WidgetData/WidgetSnapshot.swift | 52 +++++- .../XStatsUI/AppKit/AppController.swift | 28 ++- .../XStatsUI/Calendar/CalendarAlmanac.swift | 18 ++ .../WidgetDataTests/WidgetSnapshotTests.swift | 28 +++ .../Tests/XStatsUITests/CalendarTests.swift | 14 ++ README.en.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- Widget/CalendarWidget.swift | 160 +++++++++++++----- Widget/XStatsWidget.swift | 1 + 21 files changed, 336 insertions(+), 50 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 670364c..f64df42 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 47712b8..a76cef8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 名称后再签名构建。 diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index 3844f71..61e0469 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 3f3e435..df8371b 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 أعاد Sub2API بيانات حص 钥匙串读写失败,请检查权限 تعذرت قراءة سلسلة المفاتيح أو الكتابة إليها؛ تحقق من الأذونات Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تطابق المصدر المحدد 本机账号可用,当前使用自动额度。 الحساب المحلي متاح؛ تُستخدم الآن بيانات الحصة التي جُلبت تلقائيًا. +上班 يوم عمل +不上班 يوم عطلة +暂无安排 لا توجد بيانات +暂无法判断 لا يمكن التحديد حاليًا +黄道日 يوم ميمون +黑道日 يوم غير ميمون +查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي. +明天上班吗 هل تعمل غدًا؟ +查看明天是否上班或放假。 تحقق مما إذا كان الغد يوم عمل أم عطلة. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 57833f6..243b8d7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index ffc147c..13d5ded 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 80b9e03..7ab0991 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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é. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 09d2ad6..6d9ae42 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API から認識できないク 钥匙串读写失败,请检查权限 キーチェーンの読み書きに失敗しました。権限を確認してください Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラットフォームが選択したソースと一致しません 本机账号可用,当前使用自动额度。 ローカルアカウントが利用可能です。現在は自動取得した利用枠を使用しています。 +上班 勤務日 +不上班 休み +暂无安排 予定データなし +暂无法判断 判定できません +黄道日 吉日 +黑道日 凶日 +查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。 +明天上班吗 明日は出勤? +查看明天是否上班或放假。 明日が勤務日か休日かを確認します。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index c1317e3..6c495be 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API가 인식할 수 없는 할 钥匙串读写失败,请检查权限 키체인을 읽거나 쓸 수 없습니다. 권한을 확인하세요 Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택한 소스와 일치하지 않습니다 本机账号可用,当前使用自动额度。 로컬 계정을 사용할 수 있어 현재 자동 조회한 할당량을 사용합니다. +上班 근무 +不上班 쉬는 날 +暂无安排 일정 데이터 없음 +暂无法判断 아직 판단할 수 없음 +黄道日 길일 +黑道日 흉일 +查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다. +明天上班吗 내일 출근하나요? +查看明天是否上班或放假。 내일이 근무일인지 휴일인지 확인합니다. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 822676a..f35cebe 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1399,3 +1399,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API 回傳了無法辨識的額 钥匙串读写失败,请检查权限 無法讀寫鑰匙圈,請檢查權限 Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源不符 本机账号可用,当前使用自动额度。 本機帳號可用,目前使用自動額度。 +上班 上班 +不上班 不上班 +暂无安排 暫無安排 +暂无法判断 暫時無法判斷 +黄道日 黃道日 +黑道日 黑道日 +查看今天的节日与黄历。 查看今天的節日與黃曆。 +明天上班吗 明天要上班嗎 +查看明天是否上班或放假。 查看明天是否上班或放假。 diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift index bf821cc..e47fa89 100644 --- a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift +++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift @@ -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 { diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 659ccf3..34363d6 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -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") } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift index 66658a5..28ff68d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift @@ -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() diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift index 627900f..8915424 100644 --- a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift +++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift @@ -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) +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift index 4967c92..b4fdaba 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift @@ -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) diff --git a/README.en.md b/README.en.md index 5735b8d..90fccf4 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.ja.md b/README.ja.md index 25fbdb3..79a6d22 100644 --- a/README.ja.md +++ b/README.ja.md @@ -30,7 +30,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ - **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。 - **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。 - **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。 -- **デスクトップウィジェット**:システム概要、AI 利用枠、カレンダー、IP の信頼度、公開 IP。AI と IP のデータはメインアプリのローカルキャッシュから読み取ります。 +- **デスクトップウィジェット**:システム概要、AI 利用枠、祝祭日・暦注カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。 ## インストール diff --git a/README.ko.md b/README.ko.md index 6782eb4..d12a64d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -30,7 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬 - **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다. - **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다. - **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회. -- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다. +- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 명절·황력 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다. ## 설치 diff --git a/README.md b/README.md index 3133cb1..9f28ebd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度 - **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。 - **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。 - **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。 -- **桌面小组件**:系统概览、AI 额度、日历、IP 纯净度和公网 IP;AI 与 IP 数据只读取主应用的本地缓存。 +- **桌面小组件**:系统概览、AI 额度、节日黄历日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。 ## 安装 diff --git a/Widget/CalendarWidget.swift b/Widget/CalendarWidget.swift index 03e0248..76a0df8 100644 --- a/Widget/CalendarWidget.swift +++ b/Widget/CalendarWidget.swift @@ -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]) + } +} diff --git a/Widget/XStatsWidget.swift b/Widget/XStatsWidget.swift index 064d879..4539243 100644 --- a/Widget/XStatsWidget.swift +++ b/Widget/XStatsWidget.swift @@ -210,6 +210,7 @@ struct XStatsWidgets: WidgetBundle { var body: some Widget { SystemWidget() CalendarWidget() + TomorrowWorkWidget() AIQuotaWidget() IPPurityWidget() PublicIPWidget()