mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #153 from Ebullioscopic/hotfix/high-energy-usage
Finally fixed: added explicit cancellation checks, need to verify by independent testers
This commit is contained in:
commit
1fd62b8e61
7 changed files with 527 additions and 185 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ class CalendarManager: ObservableObject {
|
|||
private var lastEventsFetchDate: Date?
|
||||
private let reloadRefreshInterval: TimeInterval = 15
|
||||
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 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<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
|
||||
|
||||
@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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
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 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<Void, Never>? { didSet { oldValue?.cancel() } }
|
||||
private var upcomingComputationTask: Task<Void, Never>? { 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<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 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 }
|
||||
}
|
||||
|
|
|
|||
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