mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Finally fixed: added explicit cancellation checks, need to verify by independent testers
This commit is contained in:
parent
093f95c49d
commit
b8f403d537
7 changed files with 527 additions and 185 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,12 +60,44 @@ class CalendarManager: ObservableObject {
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task {
|
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 }
|
guard let self else { return }
|
||||||
await self.reloadCalendarAndReminderLists()
|
if delay > 0 {
|
||||||
await self.maybeRefreshEventsAfterReload()
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
let events = await eventFetchLimiter.run {
|
||||||
|
await service.events(
|
||||||
|
from: startDate,
|
||||||
|
to: endDate,
|
||||||
calendars: calendarIDs
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,56 +11,48 @@ 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 {
|
||||||
}
|
|
||||||
|
|
||||||
func hideReminderWidget() {
|
|
||||||
LockScreenReminderWidgetPanelManager.shared.hide()
|
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() {
|
||||||
|
|
@ -70,8 +60,9 @@ final class LockScreenReminderWidgetManager: ObservableObject {
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
110
DynamicIsland/managers/MemoryUsageMonitor.swift
Normal file
110
DynamicIsland/managers/MemoryUsageMonitor.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
pendingEventsSnapshot = events
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let lastEventsSignature, lastEventsSignature == signature {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift
Normal file
31
DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue