Something is working Finally

This commit is contained in:
Hariharan Mudaliar 2025-11-23 22:25:39 +05:30
commit 29b5111a86
10 changed files with 404 additions and 315 deletions

View file

@ -419,6 +419,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@ -487,6 +488,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",

View file

@ -58,16 +58,6 @@ struct ContentView: View {
@State private var hoverTask: Task<Void, Never>?
@State private var isHovering: Bool = false
@State private var hoverWorkItem: DispatchWorkItem? // Used by handleSimpleHover for stats closing logic
@State private var debounceWorkItem: DispatchWorkItem? // Used by handleSimpleHover
@State private var isHoverStateChanging: Bool = false
@State private var isStatsTransitioning: Bool = false
@State private var isSwitchingToStats: Bool = false
@State private var isViewTransitioning: Bool = false
@State private var statsTransitionWorkItem: DispatchWorkItem?
@State private var viewTransitionWorkItem: DispatchWorkItem?
@State private var sizeChangeWorkItem: DispatchWorkItem?
@State private var lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero
@ -103,6 +93,15 @@ struct ContentView: View {
#endif
}
private func runAfter(_ delay: TimeInterval, _ action: @escaping @Sendable @MainActor () -> Void) {
guard delay >= 0 else { return }
Task { @MainActor in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
action()
}
}
private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) {
guard !isMusicControlWindowVisible else { return }
enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
@ -114,7 +113,6 @@ struct ContentView: View {
return .easeInOut(duration: 0.4)
}
private let extendedHoverPadding: CGFloat = 30
private let zeroHeightHoverPadding: CGFloat = 10
private let statsAdditionalRowHeight: CGFloat = 110
private let musicControlPauseGrace: TimeInterval = 5
@ -174,23 +172,18 @@ struct ContentView: View {
.animation(notchStateAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView)
}
.conditionalModifier(Defaults[.openNotchOnHover] && interactionsEnabled) { view in
view.onHover { hovering in
handleHover(hovering)
}
}
.conditionalModifier(!Defaults[.openNotchOnHover] && interactionsEnabled) { view in
.conditionalModifier(interactionsEnabled) { view in
view
.onHover { hovering in
handleSimpleHover(hovering)
handleHover(hovering)
}
.onTapGesture {
if (vm.notchState == .closed) && Defaults[.enableHaptics] {
if vm.notchState == .closed && Defaults[.enableHaptics] {
triggerHapticIfAllowed()
}
doOpen()
openNotch()
}
.conditionalModifier(Defaults[.enableGestures] && interactionsEnabled) { view in
.conditionalModifier(Defaults[.enableGestures]) { view in
view
.panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase)
@ -204,10 +197,10 @@ struct ContentView: View {
}
}
.onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
runAfter(1) {
withAnimation(vm.animation) {
if coordinator.firstLaunch {
doOpen()
openNotch()
}
}
}
@ -224,26 +217,20 @@ struct ContentView: View {
// Reset hover state when notch state changes
if newState == .closed && isHovering {
// Only reset visually, without triggering the hover logic again
isHoverStateChanging = true
withAnimation {
isHovering = false
}
// Reset the flag after the animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isHoverStateChanging = false
}
}
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
vm.close()
}
@ -251,7 +238,7 @@ struct ContentView: View {
}
.onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
runAfter(0.1) {
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive && !isHovering {
vm.close()
}
@ -260,15 +247,14 @@ struct ContentView: View {
.onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in
// When sneak peek finishes, check if user is still hovering and open notch if needed
if !sneakPeekShowing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
runAfter(0.2) {
if isHovering && vm.notchState == .closed {
doOpen()
openNotch()
}
}
}
}
.onChange(of: coordinator.currentView) { oldValue, newValue in
// Update smart monitoring based on current view change
.onChange(of: coordinator.currentView) { _, newValue in
if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
@ -276,84 +262,6 @@ struct ContentView: View {
currentView: currentViewString
)
}
// Cancel any pending close actions and ALL existing transition timers during view transitions
hoverWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
// Reset all transition flags immediately to prevent conflicts
isStatsTransitioning = false
isSwitchingToStats = false
isViewTransitioning = false
// Check if this transition will cause a size change
let baseOpenSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
let expandedHeight = baseOpenSize.height + statsAdditionalRowHeight
let oldSize = oldValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
let newSize = newValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
let sizeWillChange = oldSize != newSize
// Set flags for transition tracking
isSwitchingToStats = (oldValue != .stats && newValue == .stats)
// Set view transition flag if size will change with proper cleanup
if sizeWillChange {
isViewTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
// Provide protection when switching to stats tab with proper cleanup
if newValue == .stats {
isStatsTransitioning = true
// Much longer delay if stats tab has expanded height (4+ graphs)
let hasExpandedHeight = statsTabHasExpandedHeight()
let protectionDelay: Double = hasExpandedHeight ? 4.0 : 2.0 // Much longer delays
let workItem = DispatchWorkItem {
isStatsTransitioning = false
isSwitchingToStats = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + protectionDelay, execute: workItem)
}
// Also provide brief protection when switching FROM stats to prevent premature closure
if oldValue == .stats && newValue != .stats {
isStatsTransitioning = true
let workItem = DispatchWorkItem {
isStatsTransitioning = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
}
}
.onChange(of: [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph]) { _, _ in
// Protect during graph count changes that might affect size
if coordinator.currentView == .stats {
// Cancel existing transition timers to prevent conflicts
viewTransitionWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
isViewTransitioning = true
isStatsTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
isStatsTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
@ -377,19 +285,10 @@ struct ContentView: View {
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
.environmentObject(privacyManager)
.onChange(of: dynamicNotchSize) { oldSize, newSize in
// Protect against hover interference during frame size changes
if oldSize != newSize {
// Cancel existing size change timer to prevent conflicts
sizeChangeWorkItem?.cancel()
isViewTransitioning = true
let workItem = DispatchWorkItem {
isViewTransitioning = false
vm.shouldRecheckHover.toggle()
}
sizeChangeWorkItem = workItem
guard oldSize != newSize else { return }
let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
runAfter(delay) {
vm.shouldRecheckHover.toggle()
}
}
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
@ -486,12 +385,7 @@ struct ContentView: View {
}
}
.onDisappear {
// Clean up all timer work items to prevent memory leaks and conflicts
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
hoverTask?.cancel()
cancelMusicControlWindowSync()
hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
@ -804,7 +698,7 @@ struct ContentView: View {
.onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in
if isTargeted, vm.notchState == .closed {
coordinator.currentView = .shelf
doOpen()
openNotch()
} else if !isTargeted {
print("DROP EVENT", vm.dropEvent)
if vm.dropEvent {
@ -822,7 +716,7 @@ struct ContentView: View {
}
// MARK: - Private Methods
private func doOpen() {
private func openNotch() {
withAnimation(.bouncy.speed(1.2)) {
vm.open()
}
@ -856,19 +750,15 @@ struct ContentView: View {
self.isHovering,
!self.coordinator.sneakPeek.show else { return }
self.doOpen()
self.openNotch()
}
}
} else {
if vm.notchState == .open && isViewTransitioning {
return
}
hoverTask = Task {
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await MainActor.run {
guard !self.isViewTransitioning else { return }
withAnimation(.bouncy.speed(1.2)) {
self.isHovering = false
}
@ -881,65 +771,6 @@ struct ContentView: View {
}
}
// Simple hover handler for non-openNotchOnHover mode
private func handleSimpleHover(_ hovering: Bool) {
// Cancel any existing work items first
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
if hovering {
// Mouse entered - always update visual state immediately
withAnimation(vm.animation) {
isHovering = true
}
return
}
// Mouse exited - always update visual state immediately
withAnimation(vm.animation) {
isHovering = false
}
// Apply stronger protection when switching to stats, especially with 4+ graphs
// Also protect during any size change animations
if isStatsTransitioning || (isSwitchingToStats && statsTabHasExpandedHeight()) || isViewTransitioning {
return // Skip closing during transitions, but allow visual updates
}
// Check if we should close after a delay
let hasPopovers = hasAnyActivePopovers()
if vm.notchState == .open && !hasPopovers {
let isStatsTab = coordinator.currentView == .stats
let hasExpandedHeight = isStatsTab && statsTabHasExpandedHeight()
// Much longer delays for stats tab, especially when it has expanded height
let delay: Double
if hasExpandedHeight {
delay = 2.0 // Very long for stats with 4+ graphs
} else if isStatsTab {
delay = 1.0 // Long for stats with 1-3 graphs
} else {
delay = 0.2 // Short for other tabs
}
let closeTask = DispatchWorkItem {
// Triple-check conditions including transition state
let stillNotHovering = !isHovering
let stillOpen = vm.notchState == .open
let stillNoPopovers = !hasAnyActivePopovers()
let notTransitioning = !isStatsTransitioning && !isSwitchingToStats && !isViewTransitioning
if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning {
vm.close()
}
}
hoverWorkItem = closeTask
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask)
}
}
// Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool {
return vm.isBatteryPopoverActive ||
@ -961,10 +792,6 @@ struct ContentView: View {
}
// Helper to check if stats tab has 4+ graphs (needs expanded height)
private func statsTabHasExpandedHeight() -> Bool {
return statsRowCount() > 1
}
private func enabledStatsGraphCount() -> Int {
var enabledCount = 0
if showCpuGraph { enabledCount += 1 }
@ -1003,7 +830,7 @@ struct ContentView: View {
withAnimation(.smooth) {
gestureProgress = .zero
}
doOpen()
openNotch()
}
}

View file

@ -4192,6 +4192,7 @@
}
},
"Calendar access is denied. Please enable it in System Settings." : {
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -4291,6 +4292,10 @@
}
}
},
"Calendar or Reminder access is denied. Please enable it in System Settings." : {
"comment" : "A message displayed when a user has denied access to the calendar or reminders on their device. It instructs the user to open System Settings to grant access.",
"isCommentAutoGenerated" : true
},
"Calendars" : {
"localizations" : {
"fr" : {

View file

@ -12,6 +12,7 @@ import EventKit
protocol CalendarServiceProviding {
func requestAccess() async -> Bool
func requestAccess(to type: EKEntityType) async -> Bool
func calendars() async -> [CalendarModel]
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
}
@ -21,17 +22,22 @@ class CalendarService: CalendarServiceProviding {
@MainActor
func requestAccess() async -> Bool {
let eventsAccess = await requestAccess(to: .event)
let remindersAccess = await requestAccess(to: .reminder)
return eventsAccess || remindersAccess
}
@MainActor
func requestAccess(to type: EKEntityType) async -> Bool {
do {
let eventsAccess = try await requestAccess(to: .event)
let remindersAccess = try await requestAccess(to: .reminder)
return eventsAccess || remindersAccess // At least one should work
return try await performAccessRequest(for: type)
} catch {
print("Calendar access error: \(error)")
return false
}
}
private func requestAccess(to type: EKEntityType) async throws -> Bool {
private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
if #available(macOS 14.0, *) {
switch type {
case .event:
@ -93,47 +99,27 @@ class CalendarService: CalendarServiceProviding {
}
private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] {
await withTaskGroup(of: [EKReminder].self) { group in
var allReminders: [EKReminder] = []
guard !calendars.isEmpty else { return [] }
// Fetch incomplete reminders
group.addTask {
await withCheckedContinuation { continuation in
let predicate = self.store.predicateForIncompleteReminders(
withDueDateStarting: start,
ending: end,
calendars: calendars
)
self.store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
}
return await withCheckedContinuation { continuation in
let predicate = store.predicateForReminders(in: calendars)
store.fetchReminders(matching: predicate) { reminders in
guard let reminders else {
continuation.resume(returning: [])
return
}
// Fetch completed reminders
group.addTask {
await withCheckedContinuation { continuation in
let predicate = self.store.predicateForCompletedReminders(
withCompletionDateStarting: start,
ending: end,
calendars: calendars
)
self.store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
let filtered = reminders.compactMap { reminder -> EventModel? in
guard let dueDate = reminder.dueDateComponents?.date,
dueDate >= start,
dueDate <= end else {
return nil
}
return EventModel(from: reminder)
}
for await reminders in group {
allReminders.append(contentsOf: reminders)
continuation.resume(returning: filtered)
}
// Remove duplicates and convert to EventModel
let uniqueReminders = Dictionary(grouping: allReminders, by: \.calendarItemIdentifier)
.compactMapValues { $0.first }
.values
return Array(uniqueReminders.compactMap { EventModel(from: $0) })
}
}
}

View file

@ -1063,8 +1063,8 @@ struct CalendarSettings: View {
var body: some View {
Form {
if calendarManager.calendarAuthorizationStatus != .fullAccess {
Text("Calendar access is denied. Please enable it in System Settings.")
if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess {
Text("Calendar or Reminder access is denied. Please enable it in System Settings.")
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding()
@ -1072,7 +1072,8 @@ struct CalendarSettings: View {
HStack {
Button("Request Access") {
Task {
await calendarManager.checkCalendarAuthorization()
await calendarManager.checkCalendarAuthorization(forceReload: true)
await calendarManager.checkReminderAuthorization(forceReload: true)
}
}
.buttonStyle(.borderedProminent)
@ -1179,6 +1180,7 @@ struct CalendarSettings: View {
.onAppear {
Task {
await calendarManager.checkCalendarAuthorization()
await calendarManager.checkReminderAuthorization()
}
}
.navigationTitle("Calendar")
@ -1186,7 +1188,7 @@ struct CalendarSettings: View {
private func statusText(for status: EKAuthorizationStatus) -> String {
switch status {
case .fullAccess: return "Full Access"
case .fullAccess, .authorized: return "Full Access"
case .writeOnly: return "Write Only"
case .denied: return "Denied"
case .restricted: return "Restricted"
@ -1197,7 +1199,7 @@ struct CalendarSettings: View {
private func color(for status: EKAuthorizationStatus) -> Color {
switch status {
case .fullAccess: return .green
case .fullAccess, .authorized: return .green
case .writeOnly: return .yellow
case .denied, .restricted: return .red
case .notDetermined: return .secondary

View file

@ -27,11 +27,14 @@ class CalendarManager: ObservableObject {
private var eventStoreChangedObserver: NSObjectProtocol?
var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) }
var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) }
private init() {
self.currentWeekStartDate = CalendarManager.startOfDay(Date())
setupEventStoreChangedObserver()
Task {
await reloadCalendarAndReminderLists()
await self.bootstrapAuthorizations()
}
}
@ -62,41 +65,83 @@ class CalendarManager: ObservableObject {
updateSelectedCalendars()
}
func checkCalendarAuthorization() async {
let status = EKEventStore.authorizationStatus(for: .event)
let reminderStatus = EKEventStore.authorizationStatus(for: .reminder)
DispatchQueue.main.async {
print("📅 Current calendar authorization status: \(status)")
self.calendarAuthorizationStatus = status
self.reminderAuthorizationStatus = reminderStatus
private func bootstrapAuthorizations() async {
await checkCalendarAuthorization(forceReload: true)
await checkReminderAuthorization(forceReload: true)
}
private func refreshAuthorizationStatuses() {
calendarAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
reminderAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
}
private func isAuthorized(_ status: EKAuthorizationStatus) -> Bool {
switch status {
case .fullAccess, .authorized:
return true
default:
return false
}
}
func checkCalendarAuthorization(forceReload: Bool = false) async {
refreshAuthorizationStatuses()
let status = calendarAuthorizationStatus
print("📅 Current calendar authorization status: \(status.rawValue)")
switch status {
case .notDetermined:
let granted = await calendarService.requestAccess()
let newEventStatus = EKEventStore.authorizationStatus(for: .event)
let newReminderStatus = EKEventStore.authorizationStatus(for: .reminder)
self.calendarAuthorizationStatus = newEventStatus
self.reminderAuthorizationStatus = newReminderStatus
if granted {
await reloadCalendarAndReminderLists()
events = await calendarService.events(
from: currentWeekStartDate,
to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!,
calendars: selectedCalendars.map { $0.id }
)
}
case .restricted, .denied:
// Handle the case where the user has denied or restricted access
NSLog("Calendar access denied or restricted")
case .fullAccess:
NSLog("Full access")
let granted = await calendarService.requestAccess(to: .event)
refreshAuthorizationStatuses()
if granted || hasCalendarAccess {
await reloadCalendarAndReminderLists()
await updateEvents()
}
case .restricted, .denied:
NSLog("Calendar access denied or restricted")
case .writeOnly:
NSLog("Write only")
NSLog("Calendar write-only access")
case .fullAccess:
guard forceReload else { return }
await reloadCalendarAndReminderLists()
await updateEvents()
@unknown default:
print("Unknown authorization status")
if isAuthorized(status) {
guard forceReload else { return }
await reloadCalendarAndReminderLists()
await updateEvents()
} else {
print("Unknown authorization status: \(status.rawValue)")
}
}
}
func checkReminderAuthorization(forceReload: Bool = false) async {
refreshAuthorizationStatuses()
let status = reminderAuthorizationStatus
print("📅 Current reminder authorization status: \(status.rawValue)")
switch status {
case .notDetermined:
let granted = await calendarService.requestAccess(to: .reminder)
refreshAuthorizationStatuses()
if granted || hasReminderAccess {
await reloadCalendarAndReminderLists()
}
case .restricted, .denied:
NSLog("Reminder access denied or restricted")
case .writeOnly:
NSLog("Reminder write-only access")
case .fullAccess:
guard forceReload else { return }
await reloadCalendarAndReminderLists()
@unknown default:
if isAuthorized(status) {
guard forceReload else { return }
await reloadCalendarAndReminderLists()
} else {
print("Unknown reminder authorization status: \(status.rawValue)")
}
}
}

View file

@ -971,7 +971,7 @@ private enum WeatherSymbolMapper {
@MainActor
private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate {
private let manager: CLLocationManager
private var continuation: CheckedContinuation<CLLocation?, Never>?
private var pendingContinuations: [CheckedContinuation<CLLocation?, Never>] = []
private var lastLocation: CLLocation?
override init() {
@ -997,7 +997,7 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag
}
manager.requestLocation()
return await withCheckedContinuation { continuation in
self.continuation = continuation
self.pendingContinuations.append(continuation)
}
default:
return nil
@ -1006,12 +1006,17 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
lastLocation = locations.last
continuation?.resume(returning: lastLocation)
continuation = nil
flushContinuations(with: lastLocation)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(returning: nil)
continuation = nil
flushContinuations(with: nil)
}
private func flushContinuations(with location: CLLocation?) {
guard !pendingContinuations.isEmpty else { return }
let continuations = pendingContinuations
pendingContinuations.removeAll()
continuations.forEach { $0.resume(returning: location) }
}
}

View file

@ -101,7 +101,7 @@ final class MusicControlWindowManager {
window.orderOut(nil)
window.alphaValue = 0
if tearDown {
tearDownHostingView()
tearDownWindowResources(using: window)
}
return
}
@ -119,17 +119,19 @@ final class MusicControlWindowManager {
window.orderOut(nil)
window.alphaValue = 0
if tearDown {
self?.tearDownHostingView()
self?.tearDownWindowResources(using: window)
}
}
}
private func tearDownHostingView() {
if let window {
window.contentView = nil
}
private func tearDownWindowResources(using window: NSWindow? = nil) {
let targetWindow = window ?? self.window
targetWindow?.contentView = nil
targetWindow?.orderOut(nil)
hostingView = nil
lastMetrics = nil
self.window = nil
hasDelegated = false
}
private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView<MusicControlOverlay> {

View file

@ -10,6 +10,7 @@ import Defaults
import EventKit
import Foundation
import CoreGraphics
import os
@MainActor
final class ReminderLiveActivityManager: ObservableObject {
@ -33,6 +34,22 @@ final class ReminderLiveActivityManager: ObservableObject {
@Published private(set) var upcomingEntries: [ReminderEntry] = []
@Published private(set) var activeWindowReminders: [ReminderEntry] = []
private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity")
private enum RefreshReason: String {
case initialization
case defaults
case manual
case eventStoreChange
case calendarListChange
case calendarEventsUpdate
case fallback
case evaluation
case hover
case ticker
case other
}
private var nextReminder: ReminderEntry?
private var cancellables = Set<AnyCancellable>()
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
@ -42,11 +59,28 @@ final class ReminderLiveActivityManager: ObservableObject {
private var pendingRefreshTask: Task<Void, Never>?
private var pendingRefreshForce = false
private var pendingRefreshToken = UUID()
private var pendingRefreshReason: RefreshReason = .other
private var lastKnownCalendarIDs = Set<String>()
private var lastRefreshDate: Date?
private var lastRefreshCompletionDate: Date?
private let minimumRefreshInterval: TimeInterval = 60
private let refreshDebounceInterval: TimeInterval = 0.3
private var refreshTaskToken = UUID()
private var hasShownCriticalSneakPeek = false
private let eventStoreChangeNoiseInterval: TimeInterval = 4
private let eventStoreForceThrottleInterval: TimeInterval = 20
private let manualRefreshSuppressionInterval: TimeInterval = 20
private var lastEventStoreForceRefreshDate: Date?
private var lastManualRefreshDate: Date?
private var pendingEventStoreChange: (date: Date, reason: RefreshReason)?
private var reminderCompletionSuppressionDeadline: Date?
private let reminderCompletionSuppressionInterval: TimeInterval = 15
private var lastUpcomingSignature: [String] = []
private var lastNoopEventStoreRefreshDate: Date?
private let noopEventStoreSuppressionInterval: TimeInterval = 30
private var lastCalendarEventSnapshot: [EventModel] = []
private var lastCalendarSnapshotDate: Date?
private var lastCalendarEventSnapshotSignature: [String] = []
private let calendarService: CalendarServiceProviding
private let calendarManager = CalendarManager.shared
@ -56,7 +90,7 @@ final class ReminderLiveActivityManager: ObservableObject {
private init(calendarService: CalendarServiceProviding = CalendarService()) {
self.calendarService = calendarService
setupObservers()
scheduleRefresh(force: true)
scheduleRefresh(force: true, reason: .initialization)
}
private func setupObservers() {
@ -64,7 +98,7 @@ final class ReminderLiveActivityManager: ObservableObject {
.sink { [weak self] change in
guard let self else { return }
if change.newValue {
self.scheduleRefresh(force: true)
self.scheduleRefresh(force: true, reason: .defaults)
} else {
self.deactivateReminder()
}
@ -74,7 +108,7 @@ final class ReminderLiveActivityManager: ObservableObject {
Defaults.publisher(.reminderLeadTime, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true)
self.scheduleRefresh(force: true, reason: .defaults)
}
.store(in: &cancellables)
@ -89,23 +123,27 @@ final class ReminderLiveActivityManager: ObservableObject {
.store(in: &cancellables)
calendarManager.$allCalendars
.sink { [weak self] _ in
.sink { [weak self] calendars in
guard let self else { return }
self.scheduleRefresh(force: true)
let identifiers = Set(calendars.map { $0.id })
guard identifiers != self.lastKnownCalendarIDs else { return }
self.lastKnownCalendarIDs = identifiers
self.handleEventStoreDrivenChange(reason: .calendarListChange)
}
.store(in: &cancellables)
calendarManager.$events
.sink { [weak self] _ in
.receive(on: RunLoop.main)
.sink { [weak self] events in
guard let self else { return }
self.scheduleRefresh(force: false)
self.handleCalendarEventsUpdate(events)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .EKEventStoreChanged)
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true)
self.handleEventStoreDrivenChange(reason: .eventStoreChange)
}
.store(in: &cancellables)
}
@ -122,6 +160,7 @@ final class ReminderLiveActivityManager: ObservableObject {
refreshTask?.cancel()
refreshTask = nil
hasShownCriticalSneakPeek = false
pendingEventStoreChange = nil
}
private func deactivateReminder() {
@ -130,6 +169,8 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = []
activeWindowReminders = []
cancelAllTimers()
lastUpcomingSignature = []
lastNoopEventStoreRefreshDate = nil
}
private func selectedCalendarIDs() -> [String] {
@ -138,6 +179,24 @@ final class ReminderLiveActivityManager: ObservableObject {
.map { $0.id }
}
private func handleCalendarEventsUpdate(_ events: [EventModel]) {
let now = Date()
let signature = events.map { $0.id }
if signature == lastCalendarEventSnapshotSignature,
let snapshotDate = lastCalendarSnapshotDate,
now.timeIntervalSince(snapshotDate) < 5 {
return
}
lastCalendarEventSnapshot = events
lastCalendarSnapshotDate = now
lastCalendarEventSnapshotSignature = signature
guard Defaults[.enableReminderLiveActivity] else { return }
logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))")
refreshFromEvents(events, referenceDate: now, reason: .calendarEventsUpdate)
}
private func shouldHide(_ event: EventModel) -> Bool {
if event.isAllDay && Defaults[.hideAllDayEvents] {
return true
@ -174,18 +233,28 @@ final class ReminderLiveActivityManager: ObservableObject {
private func scheduleFallbackRefresh() {
fallbackRefreshTask?.cancel()
let delay: TimeInterval = 15 * 60
logger.debug("[Reminder] Scheduling fallback refresh in \(delay, format: .fixed(precision: 0))s")
fallbackRefreshTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
await self.scheduleRefresh(force: true)
do {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Cancelled pending fallback refresh before execution")
return
}
await self.scheduleRefresh(force: true, reason: .fallback)
}
}
private func scheduleRefresh(force: Bool) {
private func scheduleRefresh(force: Bool, reason: RefreshReason = .other) {
let wasPending = pendingRefreshTask != nil
pendingRefreshForce = pendingRefreshForce || force
pendingRefreshToken = UUID()
pendingRefreshReason = reason
let token = pendingRefreshToken
logger.debug("[Reminder] scheduleRefresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public) pending=\(wasPending, privacy: .public)")
refreshTask?.cancel()
pendingRefreshTask?.cancel()
pendingRefreshTask = Task { [weak self] in
@ -193,6 +262,7 @@ final class ReminderLiveActivityManager: ObservableObject {
do {
try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Debounced refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return
}
await self.executeScheduledRefresh(token: token)
@ -204,15 +274,18 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingRefreshTask = nil
if Task.isCancelled {
logger.debug("[Reminder] Cancelled refresh token=\(token.uuidString, privacy: .public)")
return
}
let reason = pendingRefreshReason
let now = Date()
let force = pendingRefreshForce
if !force, let last = lastRefreshDate {
let elapsed = now.timeIntervalSince(last)
if elapsed < minimumRefreshInterval {
let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval)
logger.debug("[Reminder] Deferring refresh reason=\(reason.rawValue, privacy: .public); next attempt in \(remaining, format: .fixed(precision: 2))s")
pendingRefreshToken = UUID()
let nextToken = pendingRefreshToken
pendingRefreshTask = Task { [weak self] in
@ -220,6 +293,7 @@ final class ReminderLiveActivityManager: ObservableObject {
do {
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Deferred refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return
}
await self.executeScheduledRefresh(token: nextToken)
@ -231,17 +305,24 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingRefreshForce = false
lastRefreshDate = now
let startTime = Date()
logger.debug("[Reminder] Starting refresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public)")
refreshTask?.cancel()
let taskToken = UUID()
refreshTaskToken = taskToken
let task = Task { [weak self] in
guard let self else { return }
await self.refreshUpcomingReminder(force: force)
await self.refreshUpcomingReminder(force: force, reason: reason)
}
refreshTask = task
defer {
if refreshTaskToken == taskToken {
refreshTask = nil
lastRefreshCompletionDate = Date()
let duration = Date().timeIntervalSince(startTime)
logger.debug("[Reminder] Finished refresh reason=\(reason.rawValue, privacy: .public) duration=\(duration, format: .fixed(precision: 2))s upcoming=\(self.upcomingEntries.count, privacy: .public)")
processPendingEventStoreChangeIfNeeded()
}
}
_ = try? await task.value
@ -262,6 +343,7 @@ final class ReminderLiveActivityManager: ObservableObject {
}
private func stopTicker() {
tickerTask?.cancel()
tickerTask = nil
}
@ -272,7 +354,7 @@ final class ReminderLiveActivityManager: ObservableObject {
Task { await self.evaluateCurrentState(at: referenceDate) }
}
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date) {
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date, reason: RefreshReason) {
let leadMinutes = Defaults[.reminderLeadTime]
let upcoming = events
.filter { !shouldHide($0) }
@ -282,42 +364,92 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = upcoming
updateActiveWindowReminders(for: referenceDate)
logger.debug("[Reminder] Reduced \(events.count, privacy: .public) events to \(upcoming.count, privacy: .public) upcoming reminders")
guard let first = upcoming.first else {
deactivateReminder()
logger.debug("[Reminder] No upcoming reminders found; scheduling fallback refresh")
scheduleFallbackRefresh()
return
}
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ at \(first.triggerDate.timeIntervalSinceReferenceDate, privacy: .public)")
handleEntrySelection(first, referenceDate: referenceDate)
let signature = upcoming.map { $0.event.id }
if lastUpcomingSignature == signature {
if reason == .eventStoreChange {
lastNoopEventStoreRefreshDate = Date()
}
} else {
lastNoopEventStoreRefreshDate = nil
}
lastUpcomingSignature = signature
}
func requestRefresh(force: Bool = false) {
scheduleRefresh(force: force)
let reason: RefreshReason = force ? .manual : .other
if force {
let now = Date()
if let lastManualRefreshDate,
now.timeIntervalSince(lastManualRefreshDate) < manualRefreshSuppressionInterval {
let delta = now.timeIntervalSince(lastManualRefreshDate)
logger.debug("[Reminder] Skipping manual refresh; last manual was \(delta, format: .fixed(precision: 2))s ago")
return
}
lastManualRefreshDate = now
}
scheduleRefresh(force: force, reason: reason)
}
func refreshUpcomingReminder(force: Bool = false) async {
private func refreshUpcomingReminder(force: Bool = false, reason: RefreshReason = .other) async {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted (feature disabled)")
return
}
guard calendarManager.hasReminderAccess else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted; reminder permissions missing")
return
}
let now = Date()
if !force, let entry = nextReminder, entry.event.start > now {
logger.debug("[Reminder] Skipping fetch; cached entry \(entry.event.title, privacy: .public) still valid for reason=\(reason.rawValue, privacy: .public)")
await evaluateCurrentState(at: now)
return
}
if let snapshotDate = lastCalendarSnapshotDate,
now.timeIntervalSince(snapshotDate) < 5,
!lastCalendarEventSnapshot.isEmpty {
logger.debug("[Reminder] Using calendar snapshot (age=\(now.timeIntervalSince(snapshotDate), format: .fixed(precision: 2))s) for reason=\(reason.rawValue, privacy: .public)")
refreshFromEvents(lastCalendarEventSnapshot, referenceDate: now, reason: reason)
return
}
let calendars = selectedCalendarIDs()
guard !calendars.isEmpty else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted; no calendars selected")
return
}
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60)
logger.debug("[Reminder] Fetching reminders (reason=\(reason.rawValue, privacy: .public), force=\(force, privacy: .public), calendars=\(calendars.count, privacy: .public))")
let fetchStart = Date()
let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars)
let fetchDuration = Date().timeIntervalSince(fetchStart)
logger.debug("[Reminder] EventKit fetch completed (events=\(events.count, privacy: .public)) in \(fetchDuration, format: .fixed(precision: 2))s")
lastCalendarEventSnapshot = events
lastCalendarSnapshotDate = now
lastCalendarEventSnapshotSignature = events.map { $0.id }
await MainActor.run {
self.refreshFromEvents(events, referenceDate: now)
self.refreshFromEvents(events, referenceDate: now, reason: reason)
}
}
@ -331,8 +463,14 @@ final class ReminderLiveActivityManager: ObservableObject {
updateActiveWindowReminders(for: date)
guard var entry = nextReminder else {
if activeReminder != nil {
activeReminder = nil
}
stopTicker()
hasShownCriticalSneakPeek = false
if fallbackRefreshTask == nil {
scheduleFallbackRefresh()
}
return
}
@ -341,7 +479,9 @@ final class ReminderLiveActivityManager: ObservableObject {
nextReminder = nil
stopTicker()
hasShownCriticalSneakPeek = false
scheduleRefresh(force: true)
logger.debug("[Reminder] Reminder reached start time; requesting evaluation refresh")
reminderCompletionSuppressionDeadline = Date().addingTimeInterval(reminderCompletionSuppressionInterval)
scheduleRefresh(force: true, reason: .evaluation)
return
}
@ -408,10 +548,88 @@ final class ReminderLiveActivityManager: ObservableObject {
entry.triggerDate <= date && entry.event.start >= date
}
if filtered != activeWindowReminders {
logger.debug("[Reminder] Active window reminder count -> \(filtered.count, privacy: .public)")
activeWindowReminders = filtered
}
}
private func handleEventStoreDrivenChange(reason: RefreshReason) {
processEventStoreChange(at: Date(), reason: reason)
}
private func processEventStoreChange(at date: Date, reason: RefreshReason) {
if let manual = lastManualRefreshDate {
let delta = date.timeIntervalSince(manual)
if delta < manualRefreshSuppressionInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public) change; manual refresh cooldown \(delta, format: .fixed(precision: 2))s")
return
}
}
if let lastRefreshStart = lastRefreshDate {
let sinceStart = date.timeIntervalSince(lastRefreshStart)
if sinceStart >= 0 && sinceStart < eventStoreChangeNoiseInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceStart, format: .fixed(precision: 2))s since start")
return
}
}
if let lastCompletion = lastRefreshCompletionDate {
let sinceCompletion = abs(date.timeIntervalSince(lastCompletion))
if sinceCompletion < eventStoreChangeNoiseInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceCompletion, format: .fixed(precision: 2))s since completion")
return
}
}
if reason == .eventStoreChange,
let suppressionDeadline = reminderCompletionSuppressionDeadline,
date <= suppressionDeadline {
let remaining = suppressionDeadline.timeIntervalSince(date)
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); reminder completion suppression \(remaining, format: .fixed(precision: 2))s")
return
}
if reason == .eventStoreChange,
let noopDate = lastNoopEventStoreRefreshDate,
date.timeIntervalSince(noopDate) < noopEventStoreSuppressionInterval {
let remaining = noopEventStoreSuppressionInterval - date.timeIntervalSince(noopDate)
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); identical upcoming entries \(remaining, format: .fixed(precision: 2))s window")
return
}
if let lastForce = lastEventStoreForceRefreshDate {
let sinceForce = date.timeIntervalSince(lastForce)
if sinceForce < self.eventStoreForceThrottleInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); throttle window remaining \(self.eventStoreForceThrottleInterval - sinceForce, format: .fixed(precision: 2))s")
return
}
}
if refreshTask != nil {
pendingEventStoreChange = (date, reason)
logger.debug("[Reminder] Queued \(reason.rawValue, privacy: .public) change while refresh is running")
return
}
triggerEventStoreForcedRefresh(at: date, reason: reason)
}
private func processPendingEventStoreChangeIfNeeded() {
guard let pendingChange = pendingEventStoreChange else { return }
pendingEventStoreChange = nil
processEventStoreChange(at: pendingChange.date, reason: pendingChange.reason)
}
private func triggerEventStoreForcedRefresh(at date: Date = Date(), reason: RefreshReason) {
lastEventStoreForceRefreshDate = date
if reason == .eventStoreChange, reminderCompletionSuppressionDeadline != nil {
reminderCompletionSuppressionDeadline = nil
}
logger.debug("[Reminder] Triggering event-store refresh for \(reason.rawValue, privacy: .public)")
scheduleRefresh(force: true, reason: reason)
}
static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
guard rowCount > 0 else { return 0 }
let rows = CGFloat(rowCount)

View file

@ -183,9 +183,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
// Force music information update when notch is opened
MusicManager.shared.forceUpdate()
// Refresh reminders immediately to surface the latest state when the notch opens
ReminderLiveActivityManager.shared.requestRefresh(force: true)
}
private func calculateDynamicNotchSize() -> CGSize {