HotFix: Focus Mode Detection

This commit is contained in:
Hariharan Mudaliar 2026-03-07 21:19:41 +05:30
commit a5734c2246
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
2 changed files with 166 additions and 34 deletions

View file

@ -56,6 +56,7 @@ struct DoNotDisturbLiveActivity: View {
.onAppear(perform: handleInitialState) .onAppear(perform: handleInitialState)
.onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange) .onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange)
.onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast) .onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast)
.onChange(of: focusToastMode, handleToastModeSettingChange)
.onDisappear(perform: cancelPendingTasks) .onDisappear(perform: cancelPendingTasks)
} }
@ -388,6 +389,31 @@ struct DoNotDisturbLiveActivity: View {
} }
} }
/// When the user toggles between toast / persistent mode while focus is active,
/// re-expand the view so the wings become visible again.
private func handleToastModeSettingChange(_ oldValue: Bool, _ newToastMode: Bool) {
guard manager.isDoNotDisturbActive else { return }
cancelPendingTasks()
if newToastMode {
// Switched TO toast mode — show a brief expansion then collapse.
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = true
showInactiveIcon = false
iconScale = 1.0
}
scheduleTransientCollapse()
} else {
// Switched TO persistent mode — ensure wings are expanded.
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = true
showInactiveIcon = false
iconScale = 1.0
}
}
}
private func triggerInactiveAnimation() { private func triggerInactiveAnimation() {
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
isExpanded = true isExpanded = true

View file

@ -42,9 +42,20 @@ final class DoNotDisturbManager: ObservableObject {
private var pollingSource: DispatchSourceTimer? private var pollingSource: DispatchSourceTimer?
private var lastAssertionsModificationDate: Date? private var lastAssertionsModificationDate: Date?
private var modeCancellable: AnyCancellable? private var modeCancellable: AnyCancellable?
/// Periodic task that verifies focus is still active when `isDoNotDisturbActive` is true.
/// Catches cases where the disabled notification fails to fire.
private var stateVerificationTask: Task<Void, Never>?
@Published private(set) var monitoringMode: FocusMonitoringMode = Defaults[.focusMonitoringMode] @Published private(set) var monitoringMode: FocusMonitoringMode = Defaults[.focusMonitoringMode]
/// Timestamp of the last notification-driven state change.
/// Used to suppress assertions polling from overriding a fresh notification for a short window.
private var lastNotificationTimestamp: Date = .distantPast
private static let notificationCooldown: TimeInterval = 4.0
/// Delayed task that clears retained metadata after the OFF animation completes.
private var metadataClearTask: Task<Void, Never>?
private init() { private init() {
focusLogStream.onMetadataUpdate = { [weak self] identifier, name in focusLogStream.onMetadataUpdate = { [weak self] identifier, name in
self?.handleLogMetadataUpdate(identifier: identifier, name: name) self?.handleLogMetadataUpdate(identifier: identifier, name: name)
@ -97,6 +108,10 @@ final class DoNotDisturbManager: ObservableObject {
focusLogStream.stop() focusLogStream.stop()
stopAssertionsPolling() stopAssertionsPolling()
stateVerificationTask?.cancel()
stateVerificationTask = nil
metadataClearTask?.cancel()
metadataClearTask = nil
isMonitoring = false isMonitoring = false
DispatchQueue.main.async { DispatchQueue.main.async {
@ -107,10 +122,12 @@ final class DoNotDisturbManager: ObservableObject {
} }
@objc private func handleFocusEnabled(_ notification: Notification) { @objc private func handleFocusEnabled(_ notification: Notification) {
lastNotificationTimestamp = Date()
apply(notification: notification, isActive: true) apply(notification: notification, isActive: true)
} }
@objc private func handleFocusDisabled(_ notification: Notification) { @objc private func handleFocusDisabled(_ notification: Notification) {
lastNotificationTimestamp = Date()
apply(notification: notification, isActive: false) apply(notification: notification, isActive: false)
} }
@ -220,11 +237,87 @@ final class DoNotDisturbManager: ObservableObject {
self.isDoNotDisturbActive = isActive self.isDoNotDisturbActive = isActive
} }
// If Focus turned OFF, clear cached mode metadata after the OFF toast can use it. // If Focus turned OFF, retain metadata briefly for the OFF toast,
// then clear it so stale state doesn't linger.
if isActive == false { if isActive == false {
self.currentFocusModeIdentifier = previousIdentifier self.currentFocusModeIdentifier = previousIdentifier
self.currentFocusModeName = previousName self.currentFocusModeName = previousName
self.scheduleMetadataClear()
} else {
// Focus turned ON — cancel any pending metadata clear.
self.metadataClearTask?.cancel()
self.metadataClearTask = nil
} }
// Start or stop periodic verification based on new state.
self.updateStateVerification(focusActive: isActive)
}
}
/// When focus is believed to be active, periodically verify the assertions
/// file. If the file shows no active assertions, reset `isDoNotDisturbActive`
/// to `false`. This catches the case where `_NSDoNotDisturbDisabledNotification`
/// fails to fire.
private func updateStateVerification(focusActive: Bool) {
stateVerificationTask?.cancel()
stateVerificationTask = nil
guard focusActive else { return }
stateVerificationTask = Task { @MainActor [weak self] in
// Wait a bit before starting verification to let notifications settle.
try? await Task.sleep(for: .seconds(5))
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(3))
guard let self, self.isDoNotDisturbActive else { break }
// Check the assertions file directly (if FDA is available).
let focusStillActive = await self.verifyFocusActiveFromAssertions()
if !focusStillActive {
withAnimation(.smooth(duration: 0.25)) {
self.isDoNotDisturbActive = false
}
self.scheduleMetadataClear()
debugPrint("[DoNotDisturbManager] State verification: focus no longer active, resetting.")
break
}
}
}
}
/// Read the assertions file directly to verify whether focus is truly active.
/// Returns `true` if the file confirms active assertions, or if the check
/// cannot be performed (e.g. no FDA) — erring on the side of not falsely resetting.
private nonisolated func verifyFocusActiveFromAssertions() async -> Bool {
guard FullDiskAccessAuthorization.hasPermission() else {
// Can't verify without FDA — conservatively assume still active.
return true
}
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json")
guard let data = try? Data(contentsOf: url),
!data.isEmpty,
let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]],
let firstItem = dataArray.first else {
// File unreadable/empty — assume focus is off.
return false
}
let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? []
return !assertions.isEmpty
}
private func scheduleMetadataClear() {
metadataClearTask?.cancel()
metadataClearTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self, !self.isDoNotDisturbActive else { return }
self.currentFocusModeIdentifier = ""
self.currentFocusModeName = ""
} }
} }
@ -413,6 +506,12 @@ private extension DoNotDisturbManager {
func pollAssertionsState() { func pollAssertionsState() {
guard FullDiskAccessAuthorization.hasPermission() else { return } guard FullDiskAccessAuthorization.hasPermission() else { return }
// Skip if a notification just arrived — let the notification take precedence
// to avoid a race where the file hasn't been flushed yet.
if Date().timeIntervalSince(lastNotificationTimestamp) < DoNotDisturbManager.notificationCooldown {
return
}
if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path),
let modifiedAt = attributes[.modificationDate] as? Date, let modifiedAt = attributes[.modificationDate] as? Date,
let lastObservedModificationDate = lastAssertionsModificationDate, let lastObservedModificationDate = lastAssertionsModificationDate,
@ -420,22 +519,27 @@ private extension DoNotDisturbManager {
return return
} }
guard let data = try? Data(contentsOf: assertionsURL), // Update last-observed modification date before reading content.
!data.isEmpty,
let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]],
let firstItem = dataArray.first else {
return
}
let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? []
let isActive = !assertions.isEmpty
if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path),
let modifiedAt = attributes[.modificationDate] as? Date { let modifiedAt = attributes[.modificationDate] as? Date {
lastAssertionsModificationDate = modifiedAt lastAssertionsModificationDate = modifiedAt
} }
// Default to OFF — if the file can't be read or parsed, treat as no active focus.
var isActive = false
var identifier: String?
var name: String?
if let data = try? Data(contentsOf: assertionsURL),
!data.isEmpty,
let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]],
let firstItem = dataArray.first {
let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? []
isActive = !assertions.isEmpty
if isActive {
let identifierKeys = [ let identifierKeys = [
"modeIdentifier", "modeIdentifier",
"FocusModeIdentifier", "FocusModeIdentifier",
@ -457,8 +561,10 @@ private extension DoNotDisturbManager {
"Name" "Name"
] ]
let identifier = isActive ? firstMatch(for: identifierKeys, in: assertions) : nil identifier = firstMatch(for: identifierKeys, in: assertions)
let name = isActive ? firstMatch(for: nameKeys, in: assertions) : nil name = firstMatch(for: nameKeys, in: assertions)
}
}
publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll") publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll")
} }