feat(widget): 增强日历小组件并新增明天上班提示
This commit is contained in:
1 parent
2cd65e50dd
commit
014e7106d0
21 files changed
+336
-50
No files matched your search
@@ -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
@@ -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 لا تطابق المصدر المحدد
|
||||
本机账号可用,当前使用自动额度。 الحساب المحلي متاح؛ تُستخدم الآن بيانات الحصة التي جُلبت تلقائيًا.
|
||||
上班 يوم عمل
|
||||
不上班 يوم عطلة
|
||||
暂无安排 لا توجد بيانات
|
||||
暂无法判断 لا يمكن التحديد حاليًا
|
||||
黄道日 يوم ميمون
|
||||
黑道日 يوم غير ميمون
|
||||
查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي.
|
||||
明天上班吗 هل تعمل غدًا؟
|
||||
查看明天是否上班或放假。 تحقق مما إذا كان الغد يوم عمل أم عطلة.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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é.
|
||||
|
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API から認識できないク
|
||||
钥匙串读写失败,请检查权限 キーチェーンの読み書きに失敗しました。権限を確認してください
|
||||
Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラットフォームが選択したソースと一致しません
|
||||
本机账号可用,当前使用自动额度。 ローカルアカウントが利用可能です。現在は自動取得した利用枠を使用しています。
|
||||
上班 勤務日
|
||||
不上班 休み
|
||||
暂无安排 予定データなし
|
||||
暂无法判断 判定できません
|
||||
黄道日 吉日
|
||||
黑道日 凶日
|
||||
查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。
|
||||
明天上班吗 明日は出勤?
|
||||
查看明天是否上班或放假。 明日が勤務日か休日かを確認します。
|
||||
|
@@ -1398,3 +1398,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API가 인식할 수 없는 할
|
||||
钥匙串读写失败,请检查权限 키체인을 읽거나 쓸 수 없습니다. 권한을 확인하세요
|
||||
Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택한 소스와 일치하지 않습니다
|
||||
本机账号可用,当前使用自动额度。 로컬 계정을 사용할 수 있어 현재 자동 조회한 할당량을 사용합니다.
|
||||
上班 근무
|
||||
不上班 쉬는 날
|
||||
暂无安排 일정 데이터 없음
|
||||
暂无法判断 아직 판단할 수 없음
|
||||
黄道日 길일
|
||||
黑道日 흉일
|
||||
查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다.
|
||||
明天上班吗 내일 출근하나요?
|
||||
查看明天是否上班或放假。 내일이 근무일인지 휴일인지 확인합니다.
|
||||
|
@@ -1399,3 +1399,12 @@ Sub2API 返回了无法识别的额度数据 Sub2API 回傳了無法辨識的額
|
||||
钥匙串读写失败,请检查权限 無法讀寫鑰匙圈,請檢查權限
|
||||
Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源不符
|
||||
本机账号可用,当前使用自动额度。 本機帳號可用,目前使用自動額度。
|
||||
上班 上班
|
||||
不上班 不上班
|
||||
暂无安排 暫無安排
|
||||
暂无法判断 暫時無法判斷
|
||||
黄道日 黃道日
|
||||
黑道日 黑道日
|
||||
查看今天的节日与黄历。 查看今天的節日與黃曆。
|
||||
明天上班吗 明天要上班嗎
|
||||
查看明天是否上班或放假。 查看明天是否上班或放假。
|
||||
|
@@ -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
@@ -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
@@ -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
@@ -30,7 +30,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
|
||||
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
|
||||
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
|
||||
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
|
||||
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 달력, IP 신뢰도, 공인 IP. AI와 IP 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
|
||||
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 명절·황력 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
|
||||
|
||||
## 설치
|
||||
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ struct XStatsWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SystemWidget()
|
||||
CalendarWidget()
|
||||
TomorrowWorkWidget()
|
||||
AIQuotaWidget()
|
||||
IPPurityWidget()
|
||||
PublicIPWidget()
|
||||
|
||||
Reference in new issue
Block a user