diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 1413148..7380cd7 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -398,6 +398,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.debouncedUpdateWindowSize() }.store(in: &cancellables) + MemoryUsageMonitor.shared.startMonitoring() + ReminderLiveActivityManager.shared.$activeWindowReminders .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 0c35a69..0dd0e0b 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -29,6 +29,12 @@ class CalendarManager: ObservableObject { private var lastEventsFetchDate: Date? private let reloadRefreshInterval: TimeInterval = 15 private var eventStoreChangedObserver: NSObjectProtocol? + private var pendingEventStoreRefreshTask: Task? + private var nextAllowedEventStoreRefresh: Date = .distantPast + private var ignoreEventStoreChangesUntil: Date = .distantPast + private let eventStoreChangeThrottle: TimeInterval = 20 + private let selfInducedChangeSuppression: TimeInterval = 6 + private let eventFetchLimiter = EventFetchLimiter() var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } @@ -45,6 +51,7 @@ class CalendarManager: ObservableObject { if let observer = eventStoreChangedObserver { NotificationCenter.default.removeObserver(observer) } + pendingEventStoreRefreshTask?.cancel() } private func setupEventStoreChangedObserver() { @@ -53,14 +60,46 @@ class CalendarManager: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { - guard let self else { return } - await self.reloadCalendarAndReminderLists() - await self.maybeRefreshEventsAfterReload() - } + self?.handleEventStoreChanged() } } + private func handleEventStoreChanged() { + Logger.log("CalendarManager: Event store changed notification received", category: .lifecycle) + let now = Date() + guard now >= ignoreEventStoreChangesUntil else { return } + + if now < nextAllowedEventStoreRefresh { + let delay = max(nextAllowedEventStoreRefresh.timeIntervalSince(now), 0.05) + scheduleEventStoreRefresh(after: delay) + return + } + + nextAllowedEventStoreRefresh = now.addingTimeInterval(eventStoreChangeThrottle) + scheduleEventStoreRefresh(after: 0) + } + + private func scheduleEventStoreRefresh(after delay: TimeInterval) { + pendingEventStoreRefreshTask?.cancel() + pendingEventStoreRefreshTask = Task { [weak self] in + guard let self else { return } + if delay > 0 { + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + await self.performEventStoreRefresh() + } + } + + @MainActor + private func performEventStoreRefresh() async { + pendingEventStoreRefreshTask = nil + await reloadCalendarAndReminderLists() + await maybeRefreshEventsAfterReload() + nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle) + ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression) + } + @MainActor func reloadCalendarAndReminderLists() async { let allCalendars = await calendarService.calendars() @@ -99,13 +138,13 @@ class CalendarManager: ObservableObject { calendarAuthorizationStatus = granted ? .fullAccess : .denied if granted { await reloadCalendarAndReminderLists() - await updateEvents() + await updateEvents(force: true) } case .restricted, .denied: NSLog("Calendar access denied or restricted") case .authorized, .fullAccess: await reloadCalendarAndReminderLists() - await updateEvents() + await updateEvents(force: true) case .writeOnly: NSLog("Calendar write only") @unknown default: @@ -175,7 +214,7 @@ class CalendarManager: ObservableObject { Defaults[.calendarSelectionState] = selectionState updateSelectedCalendars() - await updateEvents() + await updateEvents(force: true) } static func startOfDay(_ date: Date) -> Date { @@ -184,22 +223,70 @@ class CalendarManager: ObservableObject { func updateCurrentDate(_ date: Date) async { currentWeekStartDate = Calendar.current.startOfDay(for: date) - await updateEvents() + await updateEvents(force: true) } - private func updateEvents() async { + private func updateEvents(force: Bool = false) async { + let now = Date() + if !force, let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { + return + } + + Logger.log("CalendarManager: Updating events (force: \(force))", category: .lifecycle) + let calendarIDs = selectedCalendars.map { $0.id } - let events = await calendarService.events( - from: currentWeekStartDate, - to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, - calendars: calendarIDs - ) + let startDate = currentWeekStartDate + guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate) else { return } + let service = calendarService + + let events = await eventFetchLimiter.run { + await service.events( + from: startDate, + to: endDate, + calendars: calendarIDs + ) + } + self.events = events lastEventsFetchDate = Date() } func setReminderCompleted(reminderID: String, completed: Bool) async { await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed) - await updateEvents() + await updateEvents(force: true) + } +} + +// MARK: - Event Fetch Limiter + +private actor EventFetchLimiter { + private var waiters: [CheckedContinuation] = [] + private var isRunning = false + + func run(_ operation: @escaping @Sendable () async -> T) async -> T { + await waitTurn() + defer { resumeNext() } + return await operation() + } + + private func waitTurn() async { + if !isRunning { + isRunning = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func resumeNext() { + if waiters.isEmpty { + isRunning = false + return + } + + let continuation = waiters.removeFirst() + continuation.resume() } } diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index d5f4111..c8dad66 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -80,7 +80,12 @@ class LockScreenManager: ObservableObject { // MARK: - Event Handlers @objc private func screenLocked() { + guard !isLocked else { + print("[\(timestamp())] LockScreenManager: 🔁 Duplicate LOCK event ignored") + return + } print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received") + Logger.log("LockScreenManager: Screen locked", category: .lifecycle) LockSoundPlayer.shared.playLockChime() // Update state SYNCHRONOUSLY without Task/await to avoid any delay @@ -107,7 +112,6 @@ class LockScreenManager: ObservableObject { LockScreenPanelManager.shared.showPanel() LockScreenLiveActivityWindowManager.shared.showLocked() LockScreenWeatherManager.shared.showWeatherWidget() - LockScreenReminderWidgetManager.shared.showReminderWidget() LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true) TimerControlWindowManager.shared.hide(animated: false) @@ -123,15 +127,22 @@ class LockScreenManager: ObservableObject { } @objc private func screenUnlocked() { + guard isLocked else { + print("[\(timestamp())] LockScreenManager: 🔁 Unlock event ignored (already unlocked)") + return + } print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received") + Logger.log("LockScreenManager: Screen unlocked", category: .lifecycle) LockSoundPlayer.shared.playUnlockChime() + lastUpdated = Date() + updateIdleState(locked: false) + isLocked = false // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenWeatherManager.shared.hideWeatherWidget() - LockScreenReminderWidgetManager.shared.hideReminderWidget() LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) // Update state immediately @@ -146,10 +157,6 @@ class LockScreenManager: ObservableObject { } } - self.lastUpdated = Date() - self.updateIdleState(locked: false) - self.isLocked = false - print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated") } diff --git a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift index ddbd2f1..4bc7a52 100644 --- a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift @@ -1,8 +1,6 @@ import Foundation import Combine import Defaults -import SwiftUI -import AppKit @MainActor final class LockScreenReminderWidgetManager: ObservableObject { @@ -13,65 +11,58 @@ final class LockScreenReminderWidgetManager: ObservableObject { private let reminderManager = ReminderLiveActivityManager.shared private var cancellables = Set() - private let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - return formatter - }() - private init() { - observeReminderUpdates() observeDefaults() + observeLockState() + observeReminderSnapshots() } - func showReminderWidget() { + private func observeLockState() { + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + self?.handleLockStateChange(isLocked: locked) + } + .store(in: &cancellables) + } + + private func observeReminderSnapshots() { + reminderManager.$lockScreenSnapshot + .receive(on: RunLoop.main) + .sink { [weak self] snapshot in + self?.handleSnapshotUpdate(snapshot) + } + .store(in: &cancellables) + } + + private func handleLockStateChange(isLocked: Bool) { + Logger.log("LockScreenReminderWidgetManager: handleLockStateChange(isLocked: \(isLocked))", category: .lifecycle) guard Defaults[.enableLockScreenReminderWidget] else { LockScreenReminderWidgetPanelManager.shared.hide() return } - if let snapshot { - LockScreenReminderWidgetPanelManager.shared.show(with: snapshot) - } else { - recomputeSnapshot(now: reminderManager.currentDate) - if let snapshot { - LockScreenReminderWidgetPanelManager.shared.show(with: snapshot) + if isLocked { + if let latest = reminderManager.lockScreenSnapshot ?? snapshot { + snapshot = latest + LockScreenReminderWidgetPanelManager.shared.show(with: latest) } else { LockScreenReminderWidgetPanelManager.shared.hide() } + } else { + LockScreenReminderWidgetPanelManager.shared.hide() } } - func hideReminderWidget() { - LockScreenReminderWidgetPanelManager.shared.hide() - } - - private func observeReminderUpdates() { - reminderManager.$activeReminder - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self else { return } - self.recomputeSnapshot(now: self.reminderManager.currentDate) - } - .store(in: &cancellables) - - reminderManager.$currentDate - .receive(on: RunLoop.main) - .sink { [weak self] now in - guard let self else { return } - self.recomputeSnapshot(now: now) - } - .store(in: &cancellables) - } - private func observeDefaults() { Defaults.publisher(.enableLockScreenReminderWidget, options: []) .sink { [weak self] change in guard let self else { return } if change.newValue { - self.recomputeSnapshot(now: self.reminderManager.currentDate) - self.showReminderWidgetIfPossible() + if LockScreenManager.shared.currentLockStatus { + self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) + } } else { self.snapshot = nil LockScreenReminderWidgetPanelManager.shared.hide() @@ -82,128 +73,29 @@ final class LockScreenReminderWidgetManager: ObservableObject { Defaults.publisher(.lockScreenReminderChipStyle, options: []) .sink { [weak self] _ in guard let self else { return } - self.recomputeSnapshot(now: self.reminderManager.currentDate) + self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) } .store(in: &cancellables) } - private func showReminderWidgetIfPossible() { - guard LockScreenManager.shared.currentLockStatus else { return } - guard Defaults[.enableLockScreenReminderWidget] else { return } - if let snapshot { - LockScreenReminderWidgetPanelManager.shared.show(with: snapshot) - } else { - LockScreenReminderWidgetPanelManager.shared.hide() - } - } - - private func recomputeSnapshot(now: Date) { + private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) { guard Defaults[.enableLockScreenReminderWidget] else { snapshot = nil LockScreenReminderWidgetPanelManager.shared.hide() return } - guard let entry = reminderManager.activeReminder else { - snapshot = nil - LockScreenReminderWidgetPanelManager.shared.hide() - return - } + snapshot = newSnapshot - let snapshot = makeSnapshot(from: entry, now: now) - if self.snapshot != snapshot { - self.snapshot = snapshot - } - deliver(snapshot) - } - - private func deliver(_ snapshot: LockScreenReminderWidgetSnapshot) { guard LockScreenManager.shared.currentLockStatus else { return } - if LockScreenReminderWidgetPanelManager.shared.isVisible { - LockScreenReminderWidgetPanelManager.shared.update(with: snapshot) + + if let newSnapshot { + Logger.log("LockScreenReminderWidgetManager: Updating snapshot on lock screen", category: .ui) + LockScreenReminderWidgetPanelManager.shared.show(with: newSnapshot) } else { - LockScreenReminderWidgetPanelManager.shared.show(with: snapshot) + LockScreenReminderWidgetPanelManager.shared.hide() } } - private func makeSnapshot(from entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> LockScreenReminderWidgetSnapshot { - let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title - let timeText = timeFormatter.string(from: entry.event.start) - let isCritical = criticalWindowContains(entry: entry, now: now) - let relative = relativeDescription(for: entry, now: now) - let accent = accentColor(for: entry, isCritical: isCritical) - let iconName = isCritical ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName - - return LockScreenReminderWidgetSnapshot( - title: title, - eventTimeText: timeText, - relativeDescription: relative, - accent: accent, - chipStyle: Defaults[.lockScreenReminderChipStyle], - isCritical: isCritical, - iconName: iconName - ) - } - - private func accentColor(for entry: ReminderLiveActivityManager.ReminderEntry, isCritical: Bool) -> LockScreenReminderWidgetSnapshot.RGBAColor { - if isCritical { - return LockScreenReminderWidgetSnapshot.RGBAColor(nsColor: .systemRed) - } - - let boosted = Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) - return LockScreenReminderWidgetSnapshot.RGBAColor(nsColor: NSColor(boosted)) - } - - private func relativeDescription(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String? { - let remaining = entry.event.start.timeIntervalSince(now) - if remaining <= 0 { - return "now" - } - - let minutes = Int(ceil(remaining / 60)) - if minutes <= 0 { - return "now" - } - if minutes == 1 { - return "in 1 min" - } - return "in \(minutes) min" - } - - private func criticalWindowContains(entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool { - let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) - guard window > 0 else { return false } - let remaining = entry.event.start.timeIntervalSince(now) - return remaining > 0 && remaining <= window - } -} - -struct LockScreenReminderWidgetSnapshot: Equatable { - struct RGBAColor: Equatable { - let red: Double - let green: Double - let blue: Double - let alpha: Double - - init(nsColor: NSColor) { - let color = nsColor.usingColorSpace(.sRGB) ?? nsColor - self.red = Double(color.redComponent) - self.green = Double(color.greenComponent) - self.blue = Double(color.blueComponent) - self.alpha = Double(color.alphaComponent) - } - - var color: Color { - Color(red: red, green: green, blue: blue, opacity: alpha) - } - } - - let title: String - let eventTimeText: String - let relativeDescription: String? - let accent: RGBAColor - let chipStyle: LockScreenReminderChipStyle - let isCritical: Bool - let iconName: String } diff --git a/DynamicIsland/managers/MemoryUsageMonitor.swift b/DynamicIsland/managers/MemoryUsageMonitor.swift new file mode 100644 index 0000000..f66f9d9 --- /dev/null +++ b/DynamicIsland/managers/MemoryUsageMonitor.swift @@ -0,0 +1,110 @@ +import AppKit +import Foundation +import Darwin + +@MainActor +final class MemoryUsageMonitor { + static let shared = MemoryUsageMonitor() + + private let thresholdBytes: UInt64 = 300 * 1_024 * 1_024 + private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact + private let restartCooldown: TimeInterval = 300 + private let logSampleInterval: TimeInterval = 300 + private var monitorTask: Task? + private var lastRestartAttempt: Date = .distantPast + private var lastLogSample: Date = .distantPast + + func startMonitoring() { + guard monitorTask == nil else { return } + monitorTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await self.evaluateMemoryFootprint() + do { + try await Task.sleep(for: .seconds(self.pollInterval)) + } catch { + break + } + } + } + } + + func stopMonitoring() { + monitorTask?.cancel() + monitorTask = nil + } + + private func evaluateMemoryFootprint() async { + guard let usage = currentResidentSize() else { return } + if usage >= thresholdBytes { + restartIfNeeded(currentUsage: usage) + } else if Date().timeIntervalSince(lastLogSample) >= logSampleInterval { + lastLogSample = Date() + Logger.log("[MemoryMonitor] Resident usage: \(formatMegabytes(usage)) MB", category: .memory) + } + } + + private func restartIfNeeded(currentUsage: UInt64) { + let now = Date() + guard now.timeIntervalSince(lastRestartAttempt) >= restartCooldown else { + Logger.log("[MemoryMonitor] Usage \(formatMegabytes(currentUsage)) MB exceeds threshold but cooldown active", category: .warning) + return + } + lastRestartAttempt = now + Logger.log("[MemoryMonitor] Usage \(formatMegabytes(currentUsage)) MB >= \(formatMegabytes(thresholdBytes)) MB. Prompting for restart.", category: .warning) + presentRestartAlert(currentUsage: currentUsage) + } + + private func presentRestartAlert(currentUsage: UInt64) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "DynamicIsland memory usage is high" + alert.informativeText = "The app is currently using \(formatMegabytes(currentUsage)) MB, which exceeds the safe limit of \(formatMegabytes(thresholdBytes)) MB. Restart now to free memory?" + alert.addButton(withTitle: "Restart Now") + alert.addButton(withTitle: "Later") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + relaunchApplication() + } else { + Logger.log("[MemoryMonitor] Restart postponed by user", category: .warning) + } + } + + private func relaunchApplication() { + let workspace = NSWorkspace.shared + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + + let appURL = workspace.urlForApplication(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") ?? Bundle.main.bundleURL + + workspace.openApplication(at: appURL, configuration: configuration) { _, error in + if let error { + Logger.log("[MemoryMonitor] Failed to launch replacement app: \(error.localizedDescription)", category: .error) + } + Task { @MainActor in + NSApplication.shared.terminate(nil) + } + } + } + + private func currentResidentSize() -> UInt64? { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + guard result == KERN_SUCCESS else { + Logger.log("[MemoryMonitor] task_info failed with code \(result)", category: .error) + return nil + } + return UInt64(info.resident_size) + } + + private func formatMegabytes(_ bytes: UInt64) -> String { + let mb = Double(bytes) / 1_048_576.0 + return String(format: "%.1f", mb) + } +} diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift index b37044f..47bf209 100644 --- a/DynamicIsland/managers/ReminderLiveActivityManager.swift +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -9,6 +9,8 @@ import Combine import Defaults import Foundation import CoreGraphics +import SwiftUI +import AppKit import os @MainActor @@ -32,6 +34,7 @@ final class ReminderLiveActivityManager: ObservableObject { @Published private(set) var currentDate: Date = Date() @Published private(set) var upcomingEntries: [ReminderEntry] = [] @Published private(set) var activeWindowReminders: [ReminderEntry] = [] + @Published private(set) var lockScreenSnapshot: LockScreenReminderWidgetSnapshot? private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity") @@ -42,6 +45,8 @@ final class ReminderLiveActivityManager: ObservableObject { private var hasShownCriticalSneakPeek = false private var latestEvents: [EventModel] = [] private var pendingEventsSnapshot: [EventModel]? = nil + private var pendingEventsSignature: Int? + private var lastEventsSignature: Int? private var eventsUpdateDebounceTask: Task? { didSet { oldValue?.cancel() } } private var upcomingComputationTask: Task? { didSet { oldValue?.cancel() } } private let eventsDebounceInterval: TimeInterval = 0.35 @@ -50,6 +55,15 @@ final class ReminderLiveActivityManager: ObservableObject { private var pendingSettingsReason: String? private let settingsUpdateDebounceInterval: TimeInterval = 0.2 private var suppressUpdatesForLock = false + private var deferredLockResumeTask: Task? { didSet { oldValue?.cancel() } } + private var nextAllowedLockResumeRefresh: Date = .distantPast + private let lockResumeCooldown: TimeInterval = 5 + private let lockScreenTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() private var lastAppliedLeadTime = Defaults[.reminderLeadTime] private var lastAppliedHideAllDay = Defaults[.hideAllDayEvents] @@ -61,6 +75,7 @@ final class ReminderLiveActivityManager: ObservableObject { private init() { latestEvents = calendarManager.events + lastEventsSignature = makeEventsSignature(for: latestEvents) setupObservers() if !latestEvents.isEmpty { recalculateUpcomingEntries(reason: "initialization") @@ -146,6 +161,10 @@ final class ReminderLiveActivityManager: ObservableObject { guard let self else { return } let delay = UInt64(settingsUpdateDebounceInterval * 1_000_000_000) try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { + Logger.log("settingsUpdateTask cancelled after sleep", category: .debug) + return + } await self.applyPendingSettingsRecalculation() } } @@ -172,10 +191,20 @@ final class ReminderLiveActivityManager: ObservableObject { upcomingEntries = [] activeWindowReminders = [] cancelAllTimers() + lockScreenSnapshot = nil } private func handleCalendarEventsUpdate(_ events: [EventModel]) { + let signature = makeEventsSignature(for: events) + if let pendingEventsSignature, pendingEventsSignature == signature { + pendingEventsSnapshot = events + return + } + if let lastEventsSignature, lastEventsSignature == signature { + return + } pendingEventsSnapshot = events + pendingEventsSignature = signature guard !suppressUpdatesForLock else { return } schedulePendingEventsSnapshotApplication() } @@ -195,15 +224,26 @@ final class ReminderLiveActivityManager: ObservableObject { guard !suppressUpdatesForLock else { return } guard let snapshot = pendingEventsSnapshot else { return } pendingEventsSnapshot = nil - guard snapshot != latestEvents else { return } - + let previousEvents = latestEvents latestEvents = snapshot - guard Defaults[.enableReminderLiveActivity] else { return } + if let pendingEventsSignature { + lastEventsSignature = pendingEventsSignature + self.pendingEventsSignature = nil + } else { + lastEventsSignature = makeEventsSignature(for: snapshot) + } + + guard snapshot != previousEvents else { return } + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } logger.debug("[Reminder] Applying calendar snapshot update (events=\(snapshot.count, privacy: .public))") recalculateUpcomingEntries(reason: "calendar-events") } private func handleLockStateChange(isLocked: Bool) { + Logger.log("Lock state changed: \(isLocked)", category: .lifecycle) suppressUpdatesForLock = isLocked if isLocked { eventsUpdateDebounceTask = nil @@ -212,22 +252,63 @@ final class ReminderLiveActivityManager: ObservableObject { pendingSettingsAction = nil pendingSettingsReason = nil pauseReminderActivityForLock() + deferredLockResumeTask = nil } else { - if pendingEventsSnapshot != nil { - schedulePendingEventsSnapshotApplication() - } else { - recalculateUpcomingEntries(reason: "lock-resume") - } + resumeAfterLockIfNeeded() } } private func pauseReminderActivityForLock() { + Logger.log("Pausing reminder activity for lock", category: .lifecycle) tickerTask = nil evaluationTask?.cancel() evaluationTask = nil } + private func resumeAfterLockIfNeeded() { + Logger.log("Resuming reminder activity after lock", category: .lifecycle) + let now = Date() + if now < nextAllowedLockResumeRefresh { + let delay = nextAllowedLockResumeRefresh.timeIntervalSince(now) + scheduleDeferredLockResume(after: delay) + return + } + performLockResumeRefresh() + } + + private func scheduleDeferredLockResume(after delay: TimeInterval) { + guard delay > 0 else { + performLockResumeRefresh() + return + } + deferredLockResumeTask = Task { [weak self] in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + guard !Task.isCancelled else { + Logger.log("deferredLockResumeTask cancelled after sleep", category: .debug) + return + } + await MainActor.run { + self?.performLockResumeRefresh() + } + } + } + + @MainActor + private func performLockResumeRefresh() { + deferredLockResumeTask = nil + nextAllowedLockResumeRefresh = Date().addingTimeInterval(lockResumeCooldown) + if pendingEventsSnapshot != nil { + schedulePendingEventsSnapshotApplication() + } else { + Task { [weak self] in + await self?.evaluateCurrentState(at: Date(), source: "performLockResumeRefresh") + } + } + } + private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) { + Logger.log("Recalculating upcoming entries. Reason: \(reason)", category: .debug) guard Defaults[.enableReminderLiveActivity] else { deactivateReminder() return @@ -313,19 +394,24 @@ final class ReminderLiveActivityManager: ObservableObject { evaluationTask?.cancel() let delay = date.timeIntervalSinceNow guard delay > 0 else { - Task { await self.evaluateCurrentState(at: Date()) } + Task { await self.evaluateCurrentState(at: Date(), source: "scheduleEvaluation-immediate") } return } evaluationTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - await self.evaluateCurrentState(at: Date()) + guard !Task.isCancelled else { + Logger.log("scheduleEvaluation task cancelled after sleep", category: .debug) + return + } + await self.evaluateCurrentState(at: Date(), source: "scheduleEvaluation-delayed") } } private func startTickerIfNeeded() { guard tickerTask == nil else { return } + Logger.log("Starting ticker", category: .debug) tickerTask = Task { [weak self] in while let self, !Task.isCancelled { await self.handleTick() @@ -344,22 +430,27 @@ final class ReminderLiveActivityManager: ObservableObject { } private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) { + Logger.log("handleEntrySelection called for \(entry?.event.title ?? "nil")", category: .debug) guard nextReminder != entry else { - Task { await self.evaluateCurrentState(at: referenceDate) } + Logger.log("handleEntrySelection: entry matches nextReminder, forcing evaluation", category: .debug) + Task { await self.evaluateCurrentState(at: referenceDate, source: "handleEntrySelection-force") } return } nextReminder = entry hasShownCriticalSneakPeek = false - Task { await self.evaluateCurrentState(at: referenceDate) } + Task { await self.evaluateCurrentState(at: referenceDate, source: "handleEntrySelection-change") } } - func evaluateCurrentState(at date: Date) async { + func evaluateCurrentState(at date: Date, source: String = "unknown") async { + Logger.log("evaluateCurrentState at \(date) from \(source)", category: .debug) guard Defaults[.enableReminderLiveActivity] else { deactivateReminder() return } + defer { publishLockScreenSnapshot(referenceDate: date) } + currentDate = date updateActiveWindowReminders(for: date) @@ -374,8 +465,19 @@ final class ReminderLiveActivityManager: ObservableObject { if entry.event.start <= date { clearActiveReminderState() - logger.debug("[Reminder] Reminder reached start time; reevaluating reminders from cache") - recalculateUpcomingEntries(referenceDate: date, reason: "evaluation-complete") + Logger.log("[Reminder] Reminder reached start time; advancing to next reminder", category: .debug) + + // Robustly remove all expired entries. + // Note: We use event.start <= date instead of matching entry.id because + // entry might be a modified copy (with different triggerDate) that doesn't match + // the one in upcomingEntries, which would cause index lookup to fail and lead to an infinite loop. + upcomingEntries.removeAll { $0.event.start <= date } + + if let next = upcomingEntries.first { + handleEntrySelection(next, referenceDate: date) + } else { + Logger.log("[Reminder] No more upcoming reminders after expiration", category: .debug) + } return } @@ -430,11 +532,12 @@ final class ReminderLiveActivityManager: ObservableObject { @MainActor private func handleTick() async { + // Logger.log("Tick", category: .debug) // Too verbose let now = Date() if abs(currentDate.timeIntervalSince(now)) >= 0.5 { currentDate = now } - await evaluateCurrentState(at: now) + await evaluateCurrentState(at: now, source: "handleTick") } private func updateActiveWindowReminders(for date: Date) { @@ -447,6 +550,74 @@ final class ReminderLiveActivityManager: ObservableObject { } } + private func publishLockScreenSnapshot(referenceDate: Date) { + guard Defaults[.enableLockScreenReminderWidget] else { + if lockScreenSnapshot != nil { + lockScreenSnapshot = nil + } + return + } + guard let entry = activeReminder else { + if lockScreenSnapshot != nil { + lockScreenSnapshot = nil + } + return + } + + let snapshot = buildLockScreenSnapshot(for: entry, now: referenceDate) + if lockScreenSnapshot != snapshot { + Logger.log("Publishing new lock screen snapshot: \(snapshot.title)", category: .debug) + lockScreenSnapshot = snapshot + } + } + + private func buildLockScreenSnapshot(for entry: ReminderEntry, now: Date) -> LockScreenReminderWidgetSnapshot { + let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title + let isCritical = lockScreenCriticalWindowContains(entry: entry, now: now) + return LockScreenReminderWidgetSnapshot( + title: title, + eventTimeText: lockScreenTimeFormatter.string(from: entry.event.start), + relativeDescription: lockScreenRelativeDescription(for: entry, now: now), + accent: lockScreenAccentColor(for: entry, isCritical: isCritical), + chipStyle: Defaults[.lockScreenReminderChipStyle], + isCritical: isCritical, + iconName: isCritical ? Self.criticalIconName : Self.standardIconName + ) + } + + private func lockScreenAccentColor(for entry: ReminderEntry, isCritical: Bool) -> LockScreenReminderWidgetSnapshot.RGBAColor { + if isCritical { + return .init(nsColor: .systemRed) + } + + let boosted = Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + return .init(nsColor: NSColor(boosted)) + } + + private func lockScreenRelativeDescription(for entry: ReminderEntry, now: Date) -> String? { + let remaining = entry.event.start.timeIntervalSince(now) + if remaining <= 0 { + return "now" + } + + let minutes = Int(ceil(remaining / 60)) + switch minutes { + case ..<1: + return "now" + case 1: + return "in 1 min" + default: + return "in \(minutes) min" + } + } + + private func lockScreenCriticalWindowContains(entry: ReminderEntry, now: Date) -> Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = entry.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window + } + static func additionalHeight(forRowCount rowCount: Int) -> CGFloat { guard rowCount > 0 else { return 0 } let rows = CGFloat(rowCount) @@ -457,6 +628,48 @@ final class ReminderLiveActivityManager: ObservableObject { } +extension ReminderLiveActivityManager { + private func makeEventsSignature(for events: [EventModel]) -> Int { + var hasher = Hasher() + hasher.combine(events.count) + for event in events { + hasher.combine(event.id) + hasher.combine(event.title) + hasher.combine(event.start.timeIntervalSinceReferenceDate.bitPattern) + hasher.combine(event.end.timeIntervalSinceReferenceDate.bitPattern) + hasher.combine(event.isAllDay) + hasher.combine(event.calendar.id) + hasher.combine(event.location ?? "") + switch event.type { + case .reminder(let completed): + hasher.combine(0) + hasher.combine(completed) + case .event(let attendance): + hasher.combine(1) + hasher.combine(hashValue(for: attendance)) + case .birthday: + hasher.combine(2) + } + } + return hasher.finalize() + } + + private func hashValue(for status: AttendanceStatus) -> Int { + switch status { + case .accepted: + return 1 + case .maybe: + return 2 + case .pending: + return 3 + case .declined: + return 4 + case .unknown: + return 5 + } + } +} + extension ReminderLiveActivityManager.ReminderEntry: Identifiable { var id: String { event.id } } diff --git a/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift b/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift new file mode 100644 index 0000000..ca7f3e4 --- /dev/null +++ b/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift @@ -0,0 +1,31 @@ +import SwiftUI +import AppKit + +struct LockScreenReminderWidgetSnapshot: Equatable { + struct RGBAColor: Equatable { + let red: Double + let green: Double + let blue: Double + let alpha: Double + + init(nsColor: NSColor) { + let color = nsColor.usingColorSpace(.sRGB) ?? nsColor + self.red = Double(color.redComponent) + self.green = Double(color.greenComponent) + self.blue = Double(color.blueComponent) + self.alpha = Double(color.alphaComponent) + } + + var color: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } + } + + let title: String + let eventTimeText: String + let relativeDescription: String? + let accent: RGBAColor + let chipStyle: LockScreenReminderChipStyle + let isCritical: Bool + let iconName: String +}