diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5f1f7bd..0ac6d8f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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;系统授权必须由用户点击允许访问后决定。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift index f00908a..2b7871b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift @@ -43,6 +43,38 @@ struct CalendarAgendaQuery: Equatable, Sendable { let eventIDs: Set? let reminderIDs: Set? let revision: Int + var timeZone: TimeZone = .current +} + +/// 只保存有日程的日期,不复制跨天事件列表;在工作 actor 内随查询结果生成一次。 +struct CalendarAgendaResult: Sendable { + let items: [CalendarAgendaItem] + let markedDays: Set + + init(items: [CalendarAgendaItem], query: CalendarAgendaQuery) { + self.items = items + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = query.timeZone + var marks: Set = [] + 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 = [] 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? /// 打开面板、应用激活时调用;授权确实变化才重新查询,避免每次打开都取消刚开始的查询再重来 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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift index 5b54036..4cef17e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift @@ -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 { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 95ed6e0..8bee8b0 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -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, diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift index 982f521..911e3c4 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index e8f23d6..9292573 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -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) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index 737429f..0b04de6 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -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 {