Finally fixed: added explicit cancellation checks, need to verify by independent testers

This commit is contained in:
Hariharan Mudaliar 2025-12-07 23:54:10 +05:30
commit b8f403d537
7 changed files with 527 additions and 185 deletions

View file

@ -398,6 +398,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self?.debouncedUpdateWindowSize() self?.debouncedUpdateWindowSize()
}.store(in: &cancellables) }.store(in: &cancellables)
MemoryUsageMonitor.shared.startMonitoring()
ReminderLiveActivityManager.shared.$activeWindowReminders ReminderLiveActivityManager.shared.$activeWindowReminders
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] _ in .sink { [weak self] _ in

View file

@ -29,6 +29,12 @@ class CalendarManager: ObservableObject {
private var lastEventsFetchDate: Date? private var lastEventsFetchDate: Date?
private let reloadRefreshInterval: TimeInterval = 15 private let reloadRefreshInterval: TimeInterval = 15
private var eventStoreChangedObserver: NSObjectProtocol? private var eventStoreChangedObserver: NSObjectProtocol?
private var pendingEventStoreRefreshTask: Task<Void, Never>?
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 hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) }
var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) }
@ -45,6 +51,7 @@ class CalendarManager: ObservableObject {
if let observer = eventStoreChangedObserver { if let observer = eventStoreChangedObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
} }
pendingEventStoreRefreshTask?.cancel()
} }
private func setupEventStoreChangedObserver() { private func setupEventStoreChangedObserver() {
@ -53,14 +60,46 @@ class CalendarManager: ObservableObject {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { self?.handleEventStoreChanged()
guard let self else { return }
await self.reloadCalendarAndReminderLists()
await self.maybeRefreshEventsAfterReload()
}
} }
} }
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 @MainActor
func reloadCalendarAndReminderLists() async { func reloadCalendarAndReminderLists() async {
let allCalendars = await calendarService.calendars() let allCalendars = await calendarService.calendars()
@ -99,13 +138,13 @@ class CalendarManager: ObservableObject {
calendarAuthorizationStatus = granted ? .fullAccess : .denied calendarAuthorizationStatus = granted ? .fullAccess : .denied
if granted { if granted {
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await updateEvents() await updateEvents(force: true)
} }
case .restricted, .denied: case .restricted, .denied:
NSLog("Calendar access denied or restricted") NSLog("Calendar access denied or restricted")
case .authorized, .fullAccess: case .authorized, .fullAccess:
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await updateEvents() await updateEvents(force: true)
case .writeOnly: case .writeOnly:
NSLog("Calendar write only") NSLog("Calendar write only")
@unknown default: @unknown default:
@ -175,7 +214,7 @@ class CalendarManager: ObservableObject {
Defaults[.calendarSelectionState] = selectionState Defaults[.calendarSelectionState] = selectionState
updateSelectedCalendars() updateSelectedCalendars()
await updateEvents() await updateEvents(force: true)
} }
static func startOfDay(_ date: Date) -> Date { static func startOfDay(_ date: Date) -> Date {
@ -184,22 +223,70 @@ class CalendarManager: ObservableObject {
func updateCurrentDate(_ date: Date) async { func updateCurrentDate(_ date: Date) async {
currentWeekStartDate = Calendar.current.startOfDay(for: date) 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 calendarIDs = selectedCalendars.map { $0.id }
let events = await calendarService.events( let startDate = currentWeekStartDate
from: currentWeekStartDate, guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate) else { return }
to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, let service = calendarService
calendars: calendarIDs
) let events = await eventFetchLimiter.run {
await service.events(
from: startDate,
to: endDate,
calendars: calendarIDs
)
}
self.events = events self.events = events
lastEventsFetchDate = Date() lastEventsFetchDate = Date()
} }
func setReminderCompleted(reminderID: String, completed: Bool) async { func setReminderCompleted(reminderID: String, completed: Bool) async {
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed) await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
await updateEvents() await updateEvents(force: true)
}
}
// MARK: - Event Fetch Limiter
private actor EventFetchLimiter {
private var waiters: [CheckedContinuation<Void, Never>] = []
private var isRunning = false
func run<T>(_ 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()
} }
} }

View file

@ -80,7 +80,12 @@ class LockScreenManager: ObservableObject {
// MARK: - Event Handlers // MARK: - Event Handlers
@objc private func screenLocked() { @objc private func screenLocked() {
guard !isLocked else {
print("[\(timestamp())] LockScreenManager: 🔁 Duplicate LOCK event ignored")
return
}
print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received") print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received")
Logger.log("LockScreenManager: Screen locked", category: .lifecycle)
LockSoundPlayer.shared.playLockChime() LockSoundPlayer.shared.playLockChime()
// Update state SYNCHRONOUSLY without Task/await to avoid any delay // Update state SYNCHRONOUSLY without Task/await to avoid any delay
@ -107,7 +112,6 @@ class LockScreenManager: ObservableObject {
LockScreenPanelManager.shared.showPanel() LockScreenPanelManager.shared.showPanel()
LockScreenLiveActivityWindowManager.shared.showLocked() LockScreenLiveActivityWindowManager.shared.showLocked()
LockScreenWeatherManager.shared.showWeatherWidget() LockScreenWeatherManager.shared.showWeatherWidget()
LockScreenReminderWidgetManager.shared.showReminderWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true) LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true)
TimerControlWindowManager.shared.hide(animated: false) TimerControlWindowManager.shared.hide(animated: false)
@ -123,15 +127,22 @@ class LockScreenManager: ObservableObject {
} }
@objc private func screenUnlocked() { @objc private func screenUnlocked() {
guard isLocked else {
print("[\(timestamp())] LockScreenManager: 🔁 Unlock event ignored (already unlocked)")
return
}
print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received") print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received")
Logger.log("LockScreenManager: Screen unlocked", category: .lifecycle)
LockSoundPlayer.shared.playUnlockChime() LockSoundPlayer.shared.playUnlockChime()
lastUpdated = Date()
updateIdleState(locked: false)
isLocked = false
// Hide panel window immediately and synchronously // Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
LockScreenPanelManager.shared.hidePanel() LockScreenPanelManager.shared.hidePanel()
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenReminderWidgetManager.shared.hideReminderWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
// Update state immediately // 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") print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated")
} }

View file

@ -1,8 +1,6 @@
import Foundation import Foundation
import Combine import Combine
import Defaults import Defaults
import SwiftUI
import AppKit
@MainActor @MainActor
final class LockScreenReminderWidgetManager: ObservableObject { final class LockScreenReminderWidgetManager: ObservableObject {
@ -13,65 +11,58 @@ final class LockScreenReminderWidgetManager: ObservableObject {
private let reminderManager = ReminderLiveActivityManager.shared private let reminderManager = ReminderLiveActivityManager.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private init() { private init() {
observeReminderUpdates()
observeDefaults() 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 { guard Defaults[.enableLockScreenReminderWidget] else {
LockScreenReminderWidgetPanelManager.shared.hide() LockScreenReminderWidgetPanelManager.shared.hide()
return return
} }
if let snapshot { if isLocked {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot) if let latest = reminderManager.lockScreenSnapshot ?? snapshot {
} else { snapshot = latest
recomputeSnapshot(now: reminderManager.currentDate) LockScreenReminderWidgetPanelManager.shared.show(with: latest)
if let snapshot {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot)
} else { } else {
LockScreenReminderWidgetPanelManager.shared.hide() 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() { private func observeDefaults() {
Defaults.publisher(.enableLockScreenReminderWidget, options: []) Defaults.publisher(.enableLockScreenReminderWidget, options: [])
.sink { [weak self] change in .sink { [weak self] change in
guard let self else { return } guard let self else { return }
if change.newValue { if change.newValue {
self.recomputeSnapshot(now: self.reminderManager.currentDate) if LockScreenManager.shared.currentLockStatus {
self.showReminderWidgetIfPossible() self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot)
}
} else { } else {
self.snapshot = nil self.snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide() LockScreenReminderWidgetPanelManager.shared.hide()
@ -82,128 +73,29 @@ final class LockScreenReminderWidgetManager: ObservableObject {
Defaults.publisher(.lockScreenReminderChipStyle, options: []) Defaults.publisher(.lockScreenReminderChipStyle, options: [])
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } guard let self else { return }
self.recomputeSnapshot(now: self.reminderManager.currentDate) self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
private func showReminderWidgetIfPossible() { private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) {
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) {
guard Defaults[.enableLockScreenReminderWidget] else { guard Defaults[.enableLockScreenReminderWidget] else {
snapshot = nil snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide() LockScreenReminderWidgetPanelManager.shared.hide()
return return
} }
guard let entry = reminderManager.activeReminder else { snapshot = newSnapshot
snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide()
return
}
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 } 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 { } 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
} }

View file

@ -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<Void, Never>?
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<mach_task_basic_info>.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)
}
}

View file

@ -9,6 +9,8 @@ import Combine
import Defaults import Defaults
import Foundation import Foundation
import CoreGraphics import CoreGraphics
import SwiftUI
import AppKit
import os import os
@MainActor @MainActor
@ -32,6 +34,7 @@ final class ReminderLiveActivityManager: ObservableObject {
@Published private(set) var currentDate: Date = Date() @Published private(set) var currentDate: Date = Date()
@Published private(set) var upcomingEntries: [ReminderEntry] = [] @Published private(set) var upcomingEntries: [ReminderEntry] = []
@Published private(set) var activeWindowReminders: [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") 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 hasShownCriticalSneakPeek = false
private var latestEvents: [EventModel] = [] private var latestEvents: [EventModel] = []
private var pendingEventsSnapshot: [EventModel]? = nil private var pendingEventsSnapshot: [EventModel]? = nil
private var pendingEventsSignature: Int?
private var lastEventsSignature: Int?
private var eventsUpdateDebounceTask: Task<Void, Never>? { didSet { oldValue?.cancel() } } private var eventsUpdateDebounceTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
private var upcomingComputationTask: Task<Void, Never>? { didSet { oldValue?.cancel() } } private var upcomingComputationTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
private let eventsDebounceInterval: TimeInterval = 0.35 private let eventsDebounceInterval: TimeInterval = 0.35
@ -50,6 +55,15 @@ final class ReminderLiveActivityManager: ObservableObject {
private var pendingSettingsReason: String? private var pendingSettingsReason: String?
private let settingsUpdateDebounceInterval: TimeInterval = 0.2 private let settingsUpdateDebounceInterval: TimeInterval = 0.2
private var suppressUpdatesForLock = false private var suppressUpdatesForLock = false
private var deferredLockResumeTask: Task<Void, Never>? { 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 lastAppliedLeadTime = Defaults[.reminderLeadTime]
private var lastAppliedHideAllDay = Defaults[.hideAllDayEvents] private var lastAppliedHideAllDay = Defaults[.hideAllDayEvents]
@ -61,6 +75,7 @@ final class ReminderLiveActivityManager: ObservableObject {
private init() { private init() {
latestEvents = calendarManager.events latestEvents = calendarManager.events
lastEventsSignature = makeEventsSignature(for: latestEvents)
setupObservers() setupObservers()
if !latestEvents.isEmpty { if !latestEvents.isEmpty {
recalculateUpcomingEntries(reason: "initialization") recalculateUpcomingEntries(reason: "initialization")
@ -146,6 +161,10 @@ final class ReminderLiveActivityManager: ObservableObject {
guard let self else { return } guard let self else { return }
let delay = UInt64(settingsUpdateDebounceInterval * 1_000_000_000) let delay = UInt64(settingsUpdateDebounceInterval * 1_000_000_000)
try? await Task.sleep(nanoseconds: delay) try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else {
Logger.log("settingsUpdateTask cancelled after sleep", category: .debug)
return
}
await self.applyPendingSettingsRecalculation() await self.applyPendingSettingsRecalculation()
} }
} }
@ -172,10 +191,20 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = [] upcomingEntries = []
activeWindowReminders = [] activeWindowReminders = []
cancelAllTimers() cancelAllTimers()
lockScreenSnapshot = nil
} }
private func handleCalendarEventsUpdate(_ events: [EventModel]) { 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 pendingEventsSnapshot = events
pendingEventsSignature = signature
guard !suppressUpdatesForLock else { return } guard !suppressUpdatesForLock else { return }
schedulePendingEventsSnapshotApplication() schedulePendingEventsSnapshotApplication()
} }
@ -195,15 +224,26 @@ final class ReminderLiveActivityManager: ObservableObject {
guard !suppressUpdatesForLock else { return } guard !suppressUpdatesForLock else { return }
guard let snapshot = pendingEventsSnapshot else { return } guard let snapshot = pendingEventsSnapshot else { return }
pendingEventsSnapshot = nil pendingEventsSnapshot = nil
guard snapshot != latestEvents else { return } let previousEvents = latestEvents
latestEvents = snapshot 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))") logger.debug("[Reminder] Applying calendar snapshot update (events=\(snapshot.count, privacy: .public))")
recalculateUpcomingEntries(reason: "calendar-events") recalculateUpcomingEntries(reason: "calendar-events")
} }
private func handleLockStateChange(isLocked: Bool) { private func handleLockStateChange(isLocked: Bool) {
Logger.log("Lock state changed: \(isLocked)", category: .lifecycle)
suppressUpdatesForLock = isLocked suppressUpdatesForLock = isLocked
if isLocked { if isLocked {
eventsUpdateDebounceTask = nil eventsUpdateDebounceTask = nil
@ -212,22 +252,63 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingSettingsAction = nil pendingSettingsAction = nil
pendingSettingsReason = nil pendingSettingsReason = nil
pauseReminderActivityForLock() pauseReminderActivityForLock()
deferredLockResumeTask = nil
} else { } else {
if pendingEventsSnapshot != nil { resumeAfterLockIfNeeded()
schedulePendingEventsSnapshotApplication()
} else {
recalculateUpcomingEntries(reason: "lock-resume")
}
} }
} }
private func pauseReminderActivityForLock() { private func pauseReminderActivityForLock() {
Logger.log("Pausing reminder activity for lock", category: .lifecycle)
tickerTask = nil tickerTask = nil
evaluationTask?.cancel() evaluationTask?.cancel()
evaluationTask = nil 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) { private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) {
Logger.log("Recalculating upcoming entries. Reason: \(reason)", category: .debug)
guard Defaults[.enableReminderLiveActivity] else { guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder() deactivateReminder()
return return
@ -313,19 +394,24 @@ final class ReminderLiveActivityManager: ObservableObject {
evaluationTask?.cancel() evaluationTask?.cancel()
let delay = date.timeIntervalSinceNow let delay = date.timeIntervalSinceNow
guard delay > 0 else { guard delay > 0 else {
Task { await self.evaluateCurrentState(at: Date()) } Task { await self.evaluateCurrentState(at: Date(), source: "scheduleEvaluation-immediate") }
return return
} }
evaluationTask = Task { [weak self] in evaluationTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) 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() { private func startTickerIfNeeded() {
guard tickerTask == nil else { return } guard tickerTask == nil else { return }
Logger.log("Starting ticker", category: .debug)
tickerTask = Task { [weak self] in tickerTask = Task { [weak self] in
while let self, !Task.isCancelled { while let self, !Task.isCancelled {
await self.handleTick() await self.handleTick()
@ -344,22 +430,27 @@ final class ReminderLiveActivityManager: ObservableObject {
} }
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) { private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
Logger.log("handleEntrySelection called for \(entry?.event.title ?? "nil")", category: .debug)
guard nextReminder != entry else { 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 return
} }
nextReminder = entry nextReminder = entry
hasShownCriticalSneakPeek = false 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 { guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder() deactivateReminder()
return return
} }
defer { publishLockScreenSnapshot(referenceDate: date) }
currentDate = date currentDate = date
updateActiveWindowReminders(for: date) updateActiveWindowReminders(for: date)
@ -374,8 +465,19 @@ final class ReminderLiveActivityManager: ObservableObject {
if entry.event.start <= date { if entry.event.start <= date {
clearActiveReminderState() clearActiveReminderState()
logger.debug("[Reminder] Reminder reached start time; reevaluating reminders from cache") Logger.log("[Reminder] Reminder reached start time; advancing to next reminder", category: .debug)
recalculateUpcomingEntries(referenceDate: date, reason: "evaluation-complete")
// 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 return
} }
@ -430,11 +532,12 @@ final class ReminderLiveActivityManager: ObservableObject {
@MainActor @MainActor
private func handleTick() async { private func handleTick() async {
// Logger.log("Tick", category: .debug) // Too verbose
let now = Date() let now = Date()
if abs(currentDate.timeIntervalSince(now)) >= 0.5 { if abs(currentDate.timeIntervalSince(now)) >= 0.5 {
currentDate = now currentDate = now
} }
await evaluateCurrentState(at: now) await evaluateCurrentState(at: now, source: "handleTick")
} }
private func updateActiveWindowReminders(for date: Date) { 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 { static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
guard rowCount > 0 else { return 0 } guard rowCount > 0 else { return 0 }
let rows = CGFloat(rowCount) 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 { extension ReminderLiveActivityManager.ReminderEntry: Identifiable {
var id: String { event.id } var id: String { event.id }
} }

View file

@ -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
}