mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
HotFix: Focus Mode Detection
This commit is contained in:
parent
07c0a7f0ae
commit
a5734c2246
2 changed files with 166 additions and 34 deletions
|
|
@ -56,6 +56,7 @@ struct DoNotDisturbLiveActivity: View {
|
|||
.onAppear(perform: handleInitialState)
|
||||
.onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange)
|
||||
.onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast)
|
||||
.onChange(of: focusToastMode, handleToastModeSettingChange)
|
||||
.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() {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
|
||||
isExpanded = true
|
||||
|
|
|
|||
|
|
@ -42,9 +42,20 @@ final class DoNotDisturbManager: ObservableObject {
|
|||
private var pollingSource: DispatchSourceTimer?
|
||||
private var lastAssertionsModificationDate: Date?
|
||||
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]
|
||||
|
||||
/// 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() {
|
||||
focusLogStream.onMetadataUpdate = { [weak self] identifier, name in
|
||||
self?.handleLogMetadataUpdate(identifier: identifier, name: name)
|
||||
|
|
@ -97,6 +108,10 @@ final class DoNotDisturbManager: ObservableObject {
|
|||
|
||||
focusLogStream.stop()
|
||||
stopAssertionsPolling()
|
||||
stateVerificationTask?.cancel()
|
||||
stateVerificationTask = nil
|
||||
metadataClearTask?.cancel()
|
||||
metadataClearTask = nil
|
||||
isMonitoring = false
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -107,10 +122,12 @@ final class DoNotDisturbManager: ObservableObject {
|
|||
}
|
||||
|
||||
@objc private func handleFocusEnabled(_ notification: Notification) {
|
||||
lastNotificationTimestamp = Date()
|
||||
apply(notification: notification, isActive: true)
|
||||
}
|
||||
|
||||
@objc private func handleFocusDisabled(_ notification: Notification) {
|
||||
lastNotificationTimestamp = Date()
|
||||
apply(notification: notification, isActive: false)
|
||||
}
|
||||
|
||||
|
|
@ -220,11 +237,87 @@ final class DoNotDisturbManager: ObservableObject {
|
|||
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 {
|
||||
self.currentFocusModeIdentifier = previousIdentifier
|
||||
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() {
|
||||
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),
|
||||
let modifiedAt = attributes[.modificationDate] as? Date,
|
||||
let lastObservedModificationDate = lastAssertionsModificationDate,
|
||||
|
|
@ -420,45 +519,52 @@ private extension DoNotDisturbManager {
|
|||
return
|
||||
}
|
||||
|
||||
guard 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 else {
|
||||
return
|
||||
}
|
||||
|
||||
let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? []
|
||||
let isActive = !assertions.isEmpty
|
||||
|
||||
// Update last-observed modification date before reading content.
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path),
|
||||
let modifiedAt = attributes[.modificationDate] as? Date {
|
||||
lastAssertionsModificationDate = modifiedAt
|
||||
}
|
||||
|
||||
let identifierKeys = [
|
||||
"modeIdentifier",
|
||||
"FocusModeIdentifier",
|
||||
"focusModeIdentifier",
|
||||
"identifier",
|
||||
"Identifier",
|
||||
"focusModeUUID",
|
||||
"UUID",
|
||||
"uuid"
|
||||
]
|
||||
// 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?
|
||||
|
||||
let nameKeys = [
|
||||
"activityDisplayName",
|
||||
"displayName",
|
||||
"FocusModeName",
|
||||
"focusModeName",
|
||||
"focusMode",
|
||||
"name",
|
||||
"Name"
|
||||
]
|
||||
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 identifier = isActive ? firstMatch(for: identifierKeys, in: assertions) : nil
|
||||
let name = isActive ? firstMatch(for: nameKeys, in: assertions) : nil
|
||||
let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? []
|
||||
isActive = !assertions.isEmpty
|
||||
|
||||
if isActive {
|
||||
let identifierKeys = [
|
||||
"modeIdentifier",
|
||||
"FocusModeIdentifier",
|
||||
"focusModeIdentifier",
|
||||
"identifier",
|
||||
"Identifier",
|
||||
"focusModeUUID",
|
||||
"UUID",
|
||||
"uuid"
|
||||
]
|
||||
|
||||
let nameKeys = [
|
||||
"activityDisplayName",
|
||||
"displayName",
|
||||
"FocusModeName",
|
||||
"focusModeName",
|
||||
"focusMode",
|
||||
"name",
|
||||
"Name"
|
||||
]
|
||||
|
||||
identifier = firstMatch(for: identifierKeys, in: assertions)
|
||||
name = firstMatch(for: nameKeys, in: assertions)
|
||||
}
|
||||
}
|
||||
|
||||
publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue