fix(calendar): 降低日历资源占用并修复夏令时漏标
This commit is contained in:
1 parent
28d8c12830
commit
fd465466d8
7 files changed
+164
-21
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user