fix(calendar): 降低日历资源占用并修复夏令时漏标

This commit is contained in:
ysicing committed 2026-09-29 13:11:50 +08:00
1 parent 28d8c12830
commit fd465466d8
7 files changed
+164 -21

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
菜单栏日历提供“日期与星期”“日期与农历”“农历”“自定义”四种显示方式,自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。
月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
EventKit 授权还依赖主 App 的 `com.apple.security.personal-information.calendars` entitlement,配置来源为 `project.yml`。即使已有 Info.plist 权限说明,Hardened Runtime 缺少此 entitlement 仍会直接拒绝授权且不弹窗。本地预览和发布时应检查最终 App 签名中的 entitlement;系统授权必须由用户点击允许访问后决定。
@@ -43,6 +43,38 @@ struct CalendarAgendaQuery: Equatable, Sendable {
let eventIDs: Set<String>?
let reminderIDs: Set<String>?
let revision: Int
var timeZone: TimeZone = .current
}
/// 只保存有日程的日期,不复制跨天事件列表;在工作 actor 内随查询结果生成一次。
struct CalendarAgendaResult: Sendable {
let items: [CalendarAgendaItem]
let markedDays: Set<Date>
init(items: [CalendarAgendaItem], query: CalendarAgendaQuery) {
self.items = items
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = query.timeZone
var marks: Set<Date> = []
for item in items {
if item.isReminder || item.end <= item.start {
if item.start >= query.start && item.start < query.end {
marks.insert(calendar.startOfDay(for: item.start))
}
continue
}
let end = min(item.end, query.end)
var day = calendar.startOfDay(for: max(item.start, query.start))
// 结束时间是开区间;按实际日界线推进。午夜切换夏令时当天可能从 01:00 开始,
// 不能直接加一天,否则之后每一天都会保留 01:00,无法匹配界面的 startOfDay。
while day < end {
marks.insert(day)
guard let next = calendar.dateInterval(of: .day, for: day)?.end, next > day else { break }
day = next
}
}
markedDays = marks
}
}
/// EventKit 对象留在工作 actor 内;界面只接收值类型,不在主线程同步查询日程。
@@ -70,7 +102,7 @@ private actor CalendarAgendaReader {
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
func load(_ query: CalendarAgendaQuery) async -> [CalendarAgendaItem]? {
func load(_ query: CalendarAgendaQuery) async -> CalendarAgendaResult? {
guard !Task.isCancelled else { return nil }
let store = self.store
var result: [CalendarAgendaItem] = []
@@ -93,10 +125,12 @@ private actor CalendarAgendaReader {
result += reminders
}
}
return result.sorted {
guard !Task.isCancelled else { return nil }
let sorted = result.sorted {
if $0.start != $1.start { return $0.start < $1.start }
return $0.id < $1.id
}
return CalendarAgendaResult(items: sorted, query: query)
}
private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar], store: EKEventStore) async -> [CalendarAgendaItem]? {
@@ -143,6 +177,7 @@ private actor CalendarAgendaReader {
@Observable
final class CalendarAgendaController {
private(set) var items: [CalendarAgendaItem] = []
private(set) var markedDays: Set<Date> = []
private(set) var eventSources: [CalendarAgendaSource] = []
private(set) var reminderSources: [CalendarAgendaSource] = []
private(set) var eventsAccess = EKEventStore.authorizationStatus(for: .event)
@@ -153,6 +188,7 @@ final class CalendarAgendaController {
var revision = 0
@ObservationIgnored private let reader = CalendarAgendaReader()
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var storeChangeTask: Task<Void, Never>?
/// 打开面板、应用激活时调用;授权确实变化才重新查询,避免每次打开都取消刚开始的查询再重来
func refreshAuthorization() {
@@ -164,10 +200,18 @@ final class CalendarAgendaController {
revision += 1
}
/// 日历或提醒事项内容变化(EKEventStoreChanged)时重新查询
/// iCloud 同步可能连续发出通知;每个短窗口只刷新一次,不因持续通知无限延后。
func storeChanged() {
refreshAuthorization()
revision += 1
guard storeChangeTask == nil else { return }
storeChangeTask = Task { @MainActor [weak self] in
do { try await Task.sleep(for: .milliseconds(300)) }
catch { return }
guard let self else { return }
self.storeChangeTask = nil
let before = self.revision
self.refreshAuthorization()
if self.revision == before { self.revision += 1 }
}
}
/// 只能由明确的授权按钮调用;启用功能、导入偏好和打开日历均不自动弹出系统授权。
@@ -197,18 +241,22 @@ final class CalendarAgendaController {
generation = current
failed = false
// 新结果返回前保留旧列表,刷新时日程和日期圆点不会先消失再出现;按日期过滤,跨月也不会显示错位
guard query.events || query.reminders else { items = []; isLoading = false; return }
guard query.events || query.reminders else { items = []; markedDays = []; isLoading = false; return }
isLoading = true
let result = await reader.load(query)
guard generation == current, !Task.isCancelled else { return }
items = result ?? []
items = result?.items ?? []
markedDays = result?.markedDays ?? []
failed = result == nil
isLoading = false
}
func clear() {
storeChangeTask?.cancel()
storeChangeTask = nil
generation = UUID()
items = []
markedDays = []
isLoading = false
failed = false
}
@@ -15,6 +15,7 @@ struct CalendarAgendaView: View {
var body: some View {
let agenda = model.calendarAgenda
let items = self.items
let preferences = model.settings.calendarPreferences
VStack(alignment: .leading, spacing: 10) {
HStack {
@@ -92,6 +92,9 @@ struct CalendarDetail: Identifiable {
/// Tyme 使用可变的静态表;统一在主线程读取,不把其引用类型暴露给界面或跨线程共享。
@MainActor
enum CalendarEngine {
// 实际面板、测量视图和 onAppear 共用一次计算;只保留最近一个月,翻月不会累积内存。
private static var monthCache: (month: CalendarMonth, firstWeekday: Int, timeZone: TimeZone, days: [CalendarDay])?
static func gregorian(timeZone: TimeZone = .autoupdatingCurrent) -> Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
@@ -101,6 +104,10 @@ enum CalendarEngine {
static func today(at date: Date = Date(), timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? {
let parts = gregorian(timeZone: timeZone).dateComponents([.year, .month, .day], from: date)
guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil }
if let cached = monthCache, cached.timeZone.identifier == timeZone.identifier,
let existing = cached.days.first(where: { $0.year == year && $0.month == month && $0.day == day }) {
return existing
}
return self.day(year: year, month: month, day: day, timeZone: timeZone)
}
@@ -133,16 +140,23 @@ enum CalendarEngine {
static func month(year: Int, month: Int, firstWeekday: Int,
timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] {
guard CalendarMonth.years.contains(year), (1...12).contains(month),
let first = day(year: year, month: month, day: 1, timeZone: timeZone) else { return [] }
let calendar = gregorian(timeZone: timeZone)
let requested = CalendarMonth(year: year, month: month)
let weekStart = firstWeekday == 1 ? 1 : 2
// 固定自动更新时区的当前值,避免缓存里的时区也跟着系统变化,误判为可复用。
let zone = TimeZone(identifier: timeZone.identifier) ?? timeZone
if let cached = monthCache, cached.month == requested,
cached.firstWeekday == weekStart, cached.timeZone == zone { return cached.days }
guard CalendarMonth.years.contains(year), (1...12).contains(month),
let first = day(year: year, month: month, day: 1, timeZone: zone) else { return [] }
let calendar = gregorian(timeZone: zone)
let offset = (first.weekday - weekStart + 7) % 7
// 用日历加天而非 86400 秒;夏令时切换不能导致重复日期或漏掉日期。
return (0..<42).compactMap { index in
let days: [CalendarDay] = (0..<42).compactMap { index in
guard let date = calendar.date(byAdding: .day, value: index - offset, to: first.date) else { return nil }
return today(at: date, timeZone: timeZone)
return today(at: date, timeZone: zone)
}
monthCache = (requested, weekStart, zone, days)
return days
}
static func day(year: Int, month: Int, day: Int,
@@ -174,8 +174,8 @@ final class CalendarMenuBarController: NSObject {
if panel == nil || (panel?.isVisible == false && panelSizing != sizing) {
let model = self.model
let panel = StatusPanel(width: sizing.width, minHeight: 380, maxHeight: sizing.maxHeight,
content: { CalendarPopover(sizing: sizing).environment(model) },
measuring: { CalendarPopover(sizing: sizing).environment(model).environment(\.isSnapshot, true) })
content: { CalendarPopover(sizing: sizing, firstWeekday: model.settings.calendarFirstWeekday).environment(model) },
measuring: { CalendarPopover(sizing: sizing, firstWeekday: model.settings.calendarFirstWeekday).environment(model).environment(\.isSnapshot, true) })
panel.onVisibilityChange = { [weak self] visible in self?.item?.button?.highlight(visible) }
self.panel = panel
panelSizing = sizing
@@ -47,7 +47,7 @@ struct CalendarPopover: View {
private let sizing: CalendarPopoverSizing
init(referenceDate: Date? = nil, showsDayDetails: Bool = false,
sizing: CalendarPopoverSizing = .standard) {
sizing: CalendarPopoverSizing = .standard, firstWeekday: Int = 2) {
self.referenceDate = referenceDate
self.sizing = sizing
let today = CalendarEngine.today(at: referenceDate ?? Date())
@@ -58,7 +58,7 @@ struct CalendarPopover: View {
_showsDayDetails = State(initialValue: showsDayDetails)
_almanac = State(initialValue: showsDayDetails ? today.flatMap { CalendarEngine.almanac(for: $0) } : nil)
_lastTodayID = State(initialValue: today?.id)
_days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: 2))
_days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: firstWeekday))
_holidayPlan = State(initialValue: CalendarEngine.holidayPlan(from: referenceDate ?? Date()))
}
@@ -126,7 +126,9 @@ struct CalendarPopover: View {
}
.onDisappear { if !isSnapshot { model.calendarAgenda.clear() } }
.onReceive(NotificationCenter.default.publisher(for: .EKEventStoreChanged)) { _ in
if !isSnapshot { model.calendarAgenda.storeChanged() }
if !isSnapshot && (model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders) {
model.calendarAgenda.storeChanged()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
if !isSnapshot { model.calendarAgenda.refreshAuthorization() }
@@ -157,7 +159,7 @@ struct CalendarPopover: View {
return .init(start: start, end: end,
events: settings.calendarPreferences.showEvents, reminders: settings.calendarPreferences.showReminders,
eventIDs: settings.calendarEventSourceIDs, reminderIDs: settings.calendarReminderSourceIDs,
revision: model.calendarAgenda.revision)
revision: model.calendarAgenda.revision, timeZone: .current)
}
private func header(today: CalendarDay?) -> some View {
@@ -212,7 +214,7 @@ struct CalendarPopover: View {
CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month,
isToday: day.id == today?.id, isSelected: day.id == selected?.id,
preferences: model.settings.calendarPreferences, sizing: sizing,
hasAgenda: model.calendarAgenda.items.contains { $0.occurs(on: day.date, calendar: CalendarEngine.gregorian()) }) {
hasAgenda: model.calendarAgenda.markedDays.contains(CalendarEngine.gregorian().startOfDay(for: day.date))) {
selected = day
if day.month != month.month, CalendarMonth.years.contains(day.year) {
month = CalendarMonth(year: day.year, month: day.month)
@@ -130,15 +130,92 @@ struct CalendarEnhancementTests {
#expect(allDay.timeLabel(on: nextDay, calendar: calendar) == tr("全天"))
}
@Test func refreshingUnchangedAuthorizationDoesNotRestartTheQuery() {
@Test func refreshingUnchangedAuthorizationDoesNotRestartTheQuery() async throws {
let agenda = CalendarAgendaController()
let before = agenda.revision
// 打开面板、应用激活都会调用;授权没变时不能改动查询的 id,否则刚开始的查询会被取消重来
agenda.refreshAuthorization()
agenda.refreshAuthorization()
#expect(agenda.revision == before)
agenda.storeChanged()
for _ in 0..<100 { agenda.storeChanged() }
#expect(agenda.revision == before, "a burst of store notifications should be coalesced")
for _ in 0..<30 {
if agenda.revision != before { break }
try await Task.sleep(for: .milliseconds(100))
}
#expect(agenda.revision == before + 1, "calendar content changes must trigger a reload")
agenda.storeChanged()
agenda.clear()
try await Task.sleep(for: .milliseconds(500))
#expect(agenda.revision == before + 1, "closing the panel must cancel scheduled refreshes")
}
@Test func repeatedMonthCalculationReusesStorageAndInvalidatesForCalendarChanges() throws {
let first = CalendarEngine.month(year: 2026, month: 9, firstWeekday: 2, timeZone: zone)
let repeated = CalendarEngine.month(year: 2026, month: 9, firstWeekday: 2, timeZone: zone)
// 两份结果同时存活:共享 CoW 存储证明测量视图和实际视图没有再次生成 42 天数据。
first.withUnsafeBufferPointer { a in
repeated.withUnsafeBufferPointer { b in #expect(a.baseAddress == b.baseAddress) }
}
let sunday = CalendarEngine.month(year: 2026, month: 9, firstWeekday: 1, timeZone: zone)
#expect(sunday.first?.id == "2026-08-30")
let utc = CalendarEngine.month(year: 2026, month: 9, firstWeekday: 2, timeZone: TimeZone(secondsFromGMT: 0)!)
#expect(utc.map(\.id) == first.map(\.id))
#expect(utc.first?.date != first.first?.date)
#expect(CalendarEngine.month(year: 2026, month: 10, firstWeekday: 2, timeZone: zone).first?.id == "2026-09-28")
let evicted = CalendarEngine.month(year: 2026, month: 9, firstWeekday: 2, timeZone: zone)
#expect(evicted == first)
first.withUnsafeBufferPointer { a in
evicted.withUnsafeBufferPointer { b in #expect(a.baseAddress != b.baseAddress) }
}
}
@Test(arguments: [3, 11])
func agendaMarkersMatchEventSemanticsAcrossDST(month: Int) 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: month, day: 1)))
let end = try #require(calendar.date(byAdding: .day, value: 42, to: start))
let query = CalendarAgendaQuery(start: start, end: end, events: true, reminders: true,
eventIDs: nil, reminderIDs: nil, revision: 0, timeZone: zone)
// 覆盖跨月、跨午夜、零时长、提醒、结束边界及窗口外数据,与旧逐日判断逐一对照。
for offset in [-10, -1, 0, 7, 8, 30, 41, 42, 50] {
let date = try #require(calendar.date(byAdding: .day, value: offset, to: start))
for duration in [-3600.0, 0, 3600, 86_400, 4_000_000] {
for reminder in [false, true] {
let item = CalendarAgendaItem(id: "test", title: "", source: "", start: date,
end: date.addingTimeInterval(duration), isAllDay: false, isReminder: reminder)
let result = CalendarAgendaResult(items: [item], query: query)
for dayIndex in 0..<42 {
let day = try #require(calendar.date(byAdding: .day, value: dayIndex, to: start))
#expect(result.markedDays.contains(day) == item.occurs(on: day, calendar: calendar))
}
#expect(result.markedDays.count <= 42)
#expect(result.markedDays.allSatisfy { $0 >= start && $0 < end })
}
}
}
}
@Test(arguments: [("America/Havana", 2026, 3, 7), ("America/Sao_Paulo", 2018, 11, 3)])
func agendaMarkersRemainAtDayStartAfterMidnightDST(_ sample: (String, Int, Int, Int)) throws {
let zone = try #require(TimeZone(identifier: sample.0))
let calendar = CalendarEngine.gregorian(timeZone: zone)
// 每个日期独立求日界线,不能沿用待测算法按前一天的小时递增。
let days = try (0..<5).map { offset in
let noon = try #require(calendar.date(from: DateComponents(year: sample.1, month: sample.2,
day: sample.3 + offset, hour: 12)))
return calendar.startOfDay(for: noon)
}
#expect(calendar.component(.hour, from: days[1]) == 1)
#expect(calendar.component(.hour, from: days[2]) == 0)
let query = CalendarAgendaQuery(start: days[0], end: days[4], events: true, reminders: false,
eventIDs: nil, reminderIDs: nil, revision: 0, timeZone: zone)
let item = CalendarAgendaItem(id: "midnight-dst", title: "", source: "", start: days[0],
end: days[4], isAllDay: true, isReminder: false)
let result = CalendarAgendaResult(items: [item], query: query)
#expect(result.markedDays == Set(days.prefix(4)))
#expect(!result.markedDays.contains(days[4]), "event end remains exclusive")
}
@Test func holidayPlanCacheStillFollowsDayAndTimeZone() throws {