? {
+ didSet { defaults.set(calendarReminderSourceIDs.map { Array($0).sorted() }, forKey: "calendarReminderSourceIDs") }
+ }
/// 休息提醒完全由本机计时;旧用户默认关闭。
public var restEnabled: Bool {
didSet {
@@ -699,6 +709,10 @@ public final class AppSettings {
restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off
restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
+ calendarPreferences = defaults.data(forKey: Keys.calendarPreferences)
+ .flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) } ?? CalendarPreferences()
+ calendarEventSourceIDs = defaults.stringArray(forKey: "calendarEventSourceIDs").map { Set($0) }
+ calendarReminderSourceIDs = defaults.stringArray(forKey: "calendarReminderSourceIDs").map { Set($0) }
calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
.map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults
calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2
@@ -819,6 +833,7 @@ public final class AppSettings {
static let restSound = "restSound"
static let restHUDStyle = "restHUDStyle"
static let calendarEnabled = "calendarEnabled"
+ static let calendarPreferences = "calendarPreferences"
static let calendarFeatures = "calendarFeatures"
static let calendarFirstWeekday = "calendarFirstWeekday"
static let menuBarItems = "menuBarItems"
diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift
index e5f28b7..45e6102 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift
@@ -17,6 +17,7 @@ import WebDAVSync
public struct SettingsDocument: Codable, Equatable, Sendable {
public var schema: Int? = 1
public var calendarEnabled: Bool?
+ public var calendarPreferences: CalendarPreferences?
public var restEnabled: Bool?
public var restWorkMinutes: Int?
public var restBreakMinutes: Int?
@@ -66,6 +67,7 @@ extension AppSettings {
public func exportDocument() -> SettingsDocument {
var doc = SettingsDocument()
doc.calendarEnabled = calendarEnabled
+ doc.calendarPreferences = calendarPreferences
doc.restEnabled = restEnabled
doc.restWorkMinutes = restWorkMinutes
doc.restBreakMinutes = restBreakMinutes
@@ -120,6 +122,7 @@ extension AppSettings {
}
assign(\.calendarEnabled, doc.calendarEnabled)
+ assign(\.calendarPreferences, doc.calendarPreferences)
assign(\.restEnabled, doc.restEnabled)
assign(\.restWorkMinutes, option(doc.restWorkMinutes, in: Self.restWorkOptions))
assign(\.restBreakMinutes, option(doc.restBreakMinutes, in: Self.restBreakOptions))
diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift
new file mode 100644
index 0000000..ab752a6
--- /dev/null
+++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift
@@ -0,0 +1,170 @@
+// Copyright (C) 2026 ysicing
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import Foundation
+import Testing
+@testable import XStatsUI
+
+@MainActor
+struct CalendarEnhancementTests {
+ let zone = TimeZone(identifier: "Asia/Shanghai")!
+
+ @Test func menuBarPresetsShowWeekdayAndFullLunarDate() throws {
+ var preferences = CalendarPreferences()
+ let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T16:30:00Z"))
+ #expect(preferences.title(at: date, locale: Locale(identifier: "en_US"), timeZone: zone).contains("Tue"))
+ let locale = Locale(identifier: "zh_Hans_CN")
+ preferences.display = .lunar
+ #expect(preferences.title(at: date, locale: locale, timeZone: zone) == "八月十九")
+ #expect(preferences.title(at: date, locale: locale, timeZone: TimeZone(secondsFromGMT: 0)!) == "八月十八")
+ preferences.display = .dateLunar
+ let combined = preferences.title(at: date, locale: locale, timeZone: zone)
+ #expect(combined.contains("29") && combined.contains("八月十九"))
+ }
+
+ @Test func lunarTitlePreservesMonthStartsAndLeapMonths() throws {
+ let leap = try #require(ISO8601DateFormatter().date(from: "2025-07-25T04:00:00Z"))
+ #expect(CalendarEngine.lunarDateTitle(at: leap, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "闰六月初一")
+ #expect(CalendarEngine.lunarDateTitle(at: leap, locale: Locale(identifier: "zh_Hant_TW"), timeZone: zone) == "閏六月初一")
+ let newYear = try #require(ISO8601DateFormatter().date(from: "2026-02-17T04:00:00Z"))
+ #expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一")
+ }
+
+ @Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"])
+ func customDateKeepsUserOrderAndSeparators(localeID: String) throws {
+ var preferences = CalendarPreferences()
+ preferences.display = .custom
+ let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z"))
+ let locale = Locale(identifier: localeID)
+ for (format, expected) in [("yyyy-MM-dd", "2026-09-28"), ("M月d日", "9月28日"),
+ ("dd/MM", "28/09"), ("d.M.yy", "28.9.26")] {
+ preferences.dateFormat = format
+ #expect(preferences.isDateFormatValid)
+ #expect(preferences.title(at: date, locale: locale, timeZone: zone) == expected)
+ }
+ }
+
+ @Test(arguments: ["", "yyyy", "MM", "YYYY-MM-dd", "MM-DD", "HH:mm:ss", "MM/dd '", "MM\ndd"])
+ func invalidCustomDatesUseReadableFallback(format: String) throws {
+ var preferences = CalendarPreferences()
+ preferences.display = .custom
+ preferences.dateFormat = format
+ #expect(!preferences.isDateFormatValid)
+ let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z"))
+ #expect(preferences.title(at: date, locale: Locale(identifier: "en_US"), timeZone: zone) == "09/28")
+ }
+
+ @Test func retiredDisplayModesDecodeWithoutDiscardingOtherPreferences() throws {
+ let icon = try JSONDecoder().decode(CalendarPreferences.self,
+ from: Data(#"{"display":"dayNumber","showEvents":true}"#.utf8))
+ #expect(icon.display == .standard)
+ #expect(icon.showEvents)
+ let compact = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"compact"}"#.utf8))
+ #expect(compact.display == .custom)
+ #expect(compact.dateFormat == "MM/dd")
+ let custom = try JSONDecoder().decode(CalendarPreferences.self,
+ from: Data(#"{"display":"custom","dateFormat":"d.M.yyyy","largeLunarText":true}"#.utf8))
+ #expect(custom.display == .custom)
+ #expect(custom.dateFormat == "d.M.yyyy")
+ #expect(custom.largeLunarText)
+ }
+
+ @Test func calendarPreferencesKeepSafeDefaultsForOlderDocuments() throws {
+ let old = try JSONDecoder().decode(CalendarPreferences.self, from: Data("{}".utf8))
+ #expect(old == CalendarPreferences())
+ #expect(!old.showEvents && !old.showReminders && !old.openOnHover)
+ let future = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"future"}"#.utf8))
+ #expect(future.display == .standard)
+ }
+
+ @Test func displayPreferencesRoundTripWithoutSyncingLocalCalendarIDs() throws {
+ let name = "CalendarEnhancementTests.\(UUID())"
+ let defaults = try #require(UserDefaults(suiteName: name))
+ defer { defaults.removePersistentDomain(forName: name) }
+ let settings = AppSettings(defaults: defaults)
+ settings.calendarPreferences.display = .custom
+ settings.calendarPreferences.dateFormat = "dd.MM.yyyy"
+ settings.calendarPreferences.largeLunarText = true
+ settings.calendarEventSourceIDs = ["local-private-id"]
+ settings.calendarReminderSourceIDs = []
+ let restored = AppSettings(defaults: defaults)
+ #expect(restored.calendarPreferences == settings.calendarPreferences)
+ #expect(restored.calendarReminderSourceIDs == [])
+ let data = try JSONEncoder().encode(settings.exportDocument())
+ #expect(!String(decoding: data, as: UTF8.self).contains("local-private-id"))
+ settings.apply(try JSONDecoder().decode(SettingsDocument.self, from: Data("{}".utf8)))
+ #expect(settings.calendarPreferences.display == .custom)
+ #expect(settings.calendarEventSourceIDs == ["local-private-id"])
+ }
+
+ @Test func allDayAndOvernightEventsExcludeTheirEndDay() throws {
+ let calendar = CalendarEngine.gregorian(timeZone: zone)
+ let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28)))
+ let next = try #require(calendar.date(byAdding: .day, value: 1, to: start))
+ let event = CalendarAgendaItem(id: "event", title: "", source: "", start: start, end: next,
+ isAllDay: true, isReminder: false)
+ #expect(event.occurs(on: start, calendar: calendar))
+ #expect(!event.occurs(on: next, calendar: calendar))
+ let overnight = CalendarAgendaItem(id: "night", title: "", source: "", start: next.addingTimeInterval(-1800),
+ end: next.addingTimeInterval(1800), isAllDay: false, isReminder: false)
+ #expect(overnight.occurs(on: start, calendar: calendar))
+ #expect(overnight.occurs(on: next, calendar: calendar))
+ let reminder = CalendarAgendaItem(id: "todo", title: "", source: "", start: next, end: next,
+ isAllDay: false, isReminder: true)
+ #expect(!reminder.occurs(on: start, calendar: calendar))
+ #expect(reminder.occurs(on: next, calendar: calendar))
+ }
+
+ @Test func eventDayMatchingRespectsDSTInsteadOfFixed24Hours() throws {
+ let zone = try #require(TimeZone(identifier: "America/Los_Angeles"))
+ let calendar = CalendarEngine.gregorian(timeZone: zone)
+ let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 8)))
+ let next = try #require(calendar.date(byAdding: .day, value: 1, to: start))
+ #expect(next.timeIntervalSince(start) == 23 * 3600)
+ let event = CalendarAgendaItem(id: "dst", title: "", source: "", start: next, end: next,
+ isAllDay: false, isReminder: true)
+ #expect(!event.occurs(on: start, calendar: calendar))
+ }
+
+ @Test func holidaySuggestionsBridgePublishedHolidaysUsingOnlyFutureWorkdays() throws {
+ let today = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone))
+ let plan = try #require(CalendarEngine.holidayPlan(from: today.date, timeZone: zone))
+ #expect(plan.daysUntil == 2)
+ #expect(plan.totalDays == 3)
+ let bridge = try #require(plan.leaveOptions.first { $0.leaveDates.count == 3 })
+ #expect(bridge.totalDays == 13)
+ let calendar = CalendarEngine.gregorian(timeZone: zone)
+ #expect(bridge.leaveDates.map { calendar.component(.day, from: $0) } == [28, 29, 30])
+ let during = try #require(CalendarEngine.day(year: 2026, month: 9, day: 26, timeZone: zone))
+ let ongoing = try #require(CalendarEngine.holidayPlan(from: during.date, timeZone: zone))
+ #expect(ongoing.daysUntil == 0)
+ #expect(ongoing.leaveOptions.allSatisfy { $0.leaveDates.allSatisfy { $0 >= calendar.startOfDay(for: during.date) } })
+ let unknown = try #require(CalendarEngine.day(year: 2027, month: 1, day: 1, timeZone: zone))
+ #expect(CalendarEngine.holidayPlan(from: unknown.date, timeZone: zone) == nil)
+ }
+
+ @Test func makeupSaturdayIsCountedAsLeaveRatherThanAFreeWeekend() throws {
+ let calendar = CalendarEngine.gregorian(timeZone: zone)
+ let today = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 1)))
+ let days = (0..<10).map { index in
+ CalendarWorkday(date: calendar.date(byAdding: .day, value: index, to: today)!,
+ isWork: ![2, 3, 5].contains(index), holidayName: index == 2 ? "holiday" : nil)
+ }
+ let plan = try #require(CalendarHolidayPlan.make(days: days, today: today, calendar: calendar))
+ #expect(plan.totalDays == 2)
+ let option = try #require(plan.leaveOptions.first { $0.leaveDates.count == 1 })
+ #expect(option.totalDays == 4)
+ #expect(option.leaveDates == [days[4].date])
+ }
+
+ @Test(arguments: [CGFloat(10), CGFloat(500), CGFloat(1000)])
+ func panelRemainsInsideShortNarrowScreen(_ anchorX: CGFloat) {
+ let visible = CGRect(x: 0, y: 0, width: 400, height: 300)
+ let frame = StatusPanel.constrainedFrame(anchor: CGRect(x: anchorX, y: 320, width: 20, height: 20), visible: visible,
+ preferredSize: CGSize(width: 560, height: 700))
+ #expect(frame.minX >= visible.minX)
+ #expect(frame.maxX <= visible.maxX)
+ #expect(frame.minY >= visible.minY)
+ #expect(frame.maxY <= visible.maxY)
+ }
+}
diff --git a/project.yml b/project.yml
index d7c4a09..b2de8ad 100644
--- a/project.yml
+++ b/project.yml
@@ -52,9 +52,13 @@ targets:
LSUIElement: true
LSApplicationCategoryType: public.app-category.utilities
NSHumanReadableCopyright: © 2026 XStats
+ NSCalendarsFullAccessUsageDescription: 在本机日历面板显示您选择的系统日程,不修改或上传日程内容。
+ NSRemindersFullAccessUsageDescription: 在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。
entitlements:
path: App/XStats.entitlements
properties:
+ # EventKit 在 Hardened Runtime 下请求日历与提醒事项访问所需;仍由用户单独授权。
+ com.apple.security.personal-information.calendars: true
com.apple.security.application-groups:
- group.work.12306.xstats
settings:
diff --git a/scripts/arm64_release_test.sh b/scripts/arm64_release_test.sh
index a14e02f..584354a 100644
--- a/scripts/arm64_release_test.sh
+++ b/scripts/arm64_release_test.sh
@@ -19,6 +19,19 @@ fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
+# Hardened Runtime 在缺少日历 entitlement 时会直接拒绝 EventKit 授权,不显示系统弹窗。
+python3 - <<'PY'
+import plistlib
+
+with open("App/XStats.entitlements", "rb") as source:
+ entitlements = plistlib.load(source)
+assert entitlements.get("com.apple.security.personal-information.calendars") is True, "缺少 EventKit 日历访问 entitlement"
+with open("App/Info.plist", "rb") as source:
+ info = plistlib.load(source)
+for key in ("NSCalendarsFullAccessUsageDescription", "NSRemindersFullAccessUsageDescription"):
+ assert info.get(key), f"缺少权限说明:{key}"
+PY
+
# appcast.py 只读它自己上一级目录的 CHANGELOG.md,没有路径参数。所以把脚本复制到
# 临时目录、在那里配一份 fixture:测试从此与仓库真实的版本历史无关——归档旧版本、
# 发布新版本都不会让它失效。
diff --git a/web/privacy.html b/web/privacy.html
index f141cec..3139b7f 100644
--- a/web/privacy.html
+++ b/web/privacy.html
@@ -33,6 +33,7 @@
CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,也不会进入安装统计;应用没有广告或第三方分析 SDK。
+ - 日程与提醒事项默认关闭;主动授权后仅在日历面板打开时读取所选列表的月历范围,在本机只读展示,不上传、不修改记录,也不写入设置备份或小组件摘要。可在日历设置中关闭或在系统设置中撤销权限。
- 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
- 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器
- 连接探测打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭