diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b5dc5ee..5a85727 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -412,6 +413,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { + DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index c5147c6..653b143 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -379,6 +379,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { ScreenRecordingManager.shared.startMonitoring() } + // Setup Do Not Disturb Manager + if Defaults[.enableDoNotDisturbDetection] { + dndManager.startMonitoring() + } + // Setup Privacy Indicator Manager (camera and microphone monitoring) PrivacyIndicatorManager.shared.startMonitoring() @@ -435,6 +440,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } }.store(in: &cancellables) + Defaults.publisher(.enableDoNotDisturbDetection, options: []).sink { [weak self] _ in + guard let self else { return } + + if Defaults[.enableDoNotDisturbDetection] { + self.dndManager.startMonitoring() + } else { + self.dndManager.stopMonitoring() + } + }.store(in: &cancellables) + // Note: Polling setting removed - now uses event-driven private API detection only NotificationCenter.default.addObserver( diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index ebb402b..2eba167 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -20,6 +20,7 @@ enum SneakContentType { case download case timer case recording + case doNotDisturb case bluetoothAudio case privacy case lockScreen diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4df518e..222c4e1 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1805,6 +1805,9 @@ } } } + }, + "Active - No Focus" : { + }, "Active - No Recording" : { "comment" : "A description of the recording detection feature, indicating that recording is not currently active.", @@ -6769,6 +6772,9 @@ } } } + }, + "Do Not Disturb" : { + }, "Download" : { "localizations" : { @@ -8491,6 +8497,13 @@ }, "Fit to Notch" : { + }, + "Focus mode active" : { + "comment" : "An accessibility label for the focus indicator.", + "isCommentAutoGenerated" : true + }, + "Focus Status" : { + }, "Free ยท %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", @@ -10478,6 +10491,9 @@ } } } + }, + "Listens for Focus session changes via distributed notifications" : { + }, "LIVE" : { "comment" : "A label indicating that the music is live.", @@ -15692,7 +15708,7 @@ } } }, - "Screen Recording Detection" : { + "Screen Recording" : { }, "Screenshot options" : { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift new file mode 100644 index 0000000..ccfa3ba --- /dev/null +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -0,0 +1,119 @@ +// +// DoNotDisturbLiveActivity.swift +// DynamicIsland +// +// Renders the closed-notch Focus indicator with a purple moon badge. +// + +import SwiftUI + +struct DoNotDisturbLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var manager = DoNotDisturbManager.shared + @State private var isExpanded = false + + private let focusColor = Color.purple + + var body: some View { + HStack(spacing: 0) { + leadingWing + .frame(width: leadingWingWidth, height: wingHeight) + + Rectangle() + .fill(Color.black) + .frame(width: vm.closedNotchSize.width) + + trailingWing + .frame(width: trailingWingWidth, height: wingHeight) + } + .frame(height: vm.effectiveClosedNotchHeight) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: manager.isDoNotDisturbActive) { _, isActive in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = isActive + } + } + } + + private var wingHeight: CGFloat { + max(vm.effectiveClosedNotchHeight - 10, 20) + } + + private var leadingWingWidth: CGFloat { + isExpanded ? max(vm.effectiveClosedNotchHeight - 10, 20) : 0 + } + + private var trailingWingWidth: CGFloat { + guard isExpanded else { return 0 } + return max(vm.closedNotchSize.width * 0.65, 120) + } + + private var focusSymbol: String { + if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { + return symbol + } + return FocusModeType.doNotDisturb.sfSymbol + } + + private var focusLabel: String { + if !manager.currentFocusModeName.isEmpty { + return manager.currentFocusModeName + } + return FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.displayName ?? "Do Not Disturb" + } + + private var accessibilityDescription: String { + "Focus mode active: \(focusLabel)" + } + + private var leadingWing: some View { + Color.clear + .background { + if isExpanded { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(focusColor.opacity(0.18)) + + Image(systemName: focusSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(focusColor) + } + .frame(width: leadingWingWidth, height: wingHeight) + } + } + } + + private var trailingWing: some View { + Color.clear + .background { + if isExpanded { + HStack { + Text(focusLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(focusColor) + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .background( + Capsule(style: .continuous) + .fill(focusColor.opacity(0.18)) + ) + } + } + } +} + +#Preview { + DoNotDisturbLiveActivity() + .environmentObject(DynamicIslandViewModel()) + .frame(width: 320, height: 54) + .background(Color.black) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 83dc5ec..6204e9c 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -16,6 +16,7 @@ struct DynamicIslandHeader: View { @ObservedObject var clipboardManager = ClipboardManager.shared @ObservedObject var tvm = TrayDrop.shared @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @State private var showClipboardPopover = false @State private var showColorPickerPopover = false @State private var showTimerPopover = false @@ -196,6 +197,12 @@ struct DynamicIslandHeader: View { RecordingIndicator() .frame(width: 30, height: 30) // Same size as other header elements } + + if Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && doNotDisturbManager.isDoNotDisturbActive { + FocusIndicator() + .frame(width: 30, height: 30) + .transition(.opacity) + } if Defaults[.showBatteryIndicator] { DynamicIslandBatteryView( diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9e9135f..bfb8f4b 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -157,6 +157,7 @@ struct GeneralSettings: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @Default(.mirrorShape) var mirrorShape @Default(.showEmojis) var showEmojis @@ -172,6 +173,8 @@ struct GeneralSettings: View { @Default(.openNotchOnHover) var openNotchOnHover @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.showRecordingIndicator) var showRecordingIndicator + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @@ -235,12 +238,12 @@ struct GeneralSettings: View { Section { Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection) - + Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator) .disabled(!enableScreenRecordingDetection) - + // Note: Polling removed - now uses event-driven private API detection - + if recordingManager.isMonitoring { HStack { Text("Detection Status") @@ -260,11 +263,48 @@ struct GeneralSettings: View { } } } header: { - Text("Screen Recording Detection") + Text("Screen Recording") } footer: { Text("Uses event-driven private API for real-time screen recording detection") } - + + Section { + Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection) + + Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator) + .disabled(!enableDoNotDisturbDetection) + + if doNotDisturbManager.isMonitoring { + HStack { + Text("Focus Status") + Spacer() + if doNotDisturbManager.isDoNotDisturbActive { + HStack(spacing: 4) { + Circle() + .fill(Color.purple) + .frame(width: 8, height: 8) + Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName) + .foregroundColor(.purple) + } + } else { + Text("Active - No Focus") + .foregroundColor(.green) + } + } + } else { + HStack { + Text("Focus Status") + Spacer() + Text("Disabled") + .foregroundColor(.secondary) + } + } + } header: { + Text("Do Not Disturb") + } footer: { + Text("Listens for Focus session changes via distributed notifications") + } + Section { Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection) Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection) diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift new file mode 100644 index 0000000..27db505 --- /dev/null +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -0,0 +1,45 @@ +// +// FocusIndicator.swift +// DynamicIsland +// +// Small purple moon badge shown while Focus mode is active. +// + +import SwiftUI + +struct FocusIndicator: View { + @ObservedObject var manager = DoNotDisturbManager.shared + + private let focusColor = Color.purple + + var body: some View { + Capsule() + .fill(Color.black) + .overlay { + ZStack { + Circle() + .fill(focusColor.opacity(0.2)) + .frame(width: 20, height: 20) + + Image(systemName: focusSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(focusColor) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Focus mode active") + } + + private var focusSymbol: String { + if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { + return symbol + } + return FocusModeType.doNotDisturb.sfSymbol + } +} + +#Preview { + FocusIndicator() + .frame(width: 30, height: 30) + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 355b12b..83c63e6 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -2,398 +2,143 @@ // DoNotDisturbManager.swift // DynamicIsland // -// Created for Do Not Disturb / Focus Mode detection -// Monitors macOS Focus Mode state changes via distributed notifications +// Replaces the legacy polling-based Focus detection with +// NSDistributedNotificationCenter-backed monitoring. // -import Foundation -import Combine import AppKit +import Combine import Defaults +import Foundation import SwiftUI -import UserNotifications // For notification permission checks -import ApplicationServices // For AXIsProcessTrusted -/// Manages detection and state tracking for macOS Focus Mode (Do Not Disturb) -class DoNotDisturbManager: ObservableObject { +final class DoNotDisturbManager: ObservableObject { static let shared = DoNotDisturbManager() - - // MARK: - Published Properties - @Published var isDoNotDisturbActive: Bool = false + + @Published private(set) var isMonitoring = false + @Published var isDoNotDisturbActive = false @Published var currentFocusModeName: String = "" @Published var currentFocusModeIdentifier: String = "" - - // MARK: - Private Properties - private var observers: [NSObjectProtocol] = [] - private var cancellables = Set() - - // MARK: - Initialization - private init() { - print("๐Ÿ”” [DoNotDisturbManager] Initializing...") - checkPermissions() - setupFocusModeObservers() - checkInitialState() - } - + + private let notificationCenter = DistributedNotificationCenter.default() + private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private var hasLoggedPayload = false + + private init() {} + deinit { - cleanup() + stopMonitoring() } - - // MARK: - Permission Checks - - /// Checks if the app has necessary permissions for notification observation - private func checkPermissions() { - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ” CHECKING PERMISSIONS & CAPABILITIES") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - - // Check if running in sandbox - let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil - print(" ๐Ÿ“ฆ Sandboxed: \(isSandboxed ? "YES โš ๏ธ" : "NO โœ…")") - - // Check notification center permissions - let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.getNotificationSettings { settings in - print(" ๐Ÿ”” Notification Authorization: \(settings.authorizationStatus.rawValue)") - print(" - .notDetermined = 0") - print(" - .denied = 1") - print(" - .authorized = 2") - print(" - .provisional = 3") - print(" - .ephemeral = 4") - } - - // Check accessibility permissions - let trusted = AXIsProcessTrusted() - print(" โ™ฟ๏ธ Accessibility Trusted: \(trusted ? "YES โœ…" : "NO โš ๏ธ")") - if !trusted { - print(" โš ๏ธ App needs Accessibility permission!") - print(" โš ๏ธ Go to: System Settings > Privacy & Security > Accessibility") - } - - // Check entitlements - print(" ๐Ÿ“ Checking Entitlements...") - if let entitlements = Bundle.main.object(forInfoDictionaryKey: "Entitlements") as? [String: Any] { - print(" Entitlements found: \(entitlements.keys)") - } else { - print(" โš ๏ธ No entitlements in Info.plist") - } - - // Test if we can observe notifications at all - print(" ๐Ÿงช Testing notification observation capability...") - testNotificationObservation() - - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - } - - /// Test if notification observation is working at all - private func testNotificationObservation() { - // Test 1: Local NotificationCenter - let testObserver = NotificationCenter.default.addObserver( - forName: NSNotification.Name("TEST_NOTIFICATION"), + + func startMonitoring() { + guard !isMonitoring else { return } + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusEnabled(_:)), + name: .focusModeEnabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Local NotificationCenter works!") - } + suspensionBehavior: .deliverImmediately ) - NotificationCenter.default.post(name: NSNotification.Name("TEST_NOTIFICATION"), object: nil) - NotificationCenter.default.removeObserver(testObserver) - - // Test 2: Distributed NotificationCenter (this is what we need) - let dnc = DistributedNotificationCenter.default() - print(" ๐ŸŒ DistributedNotificationCenter instance: \(dnc)") - print(" ๐ŸŒ Attempting to post test distributed notification...") - - let testDistributedObserver = dnc.addObserver( - forName: NSNotification.Name("com.test.distributed.notification"), + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusDisabled(_:)), + name: .focusModeDisabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Distributed NotificationCenter observation works!") - } + suspensionBehavior: .deliverImmediately ) - - // Post a test distributed notification - dnc.postNotificationName( - NSNotification.Name("com.test.distributed.notification"), - object: nil, - userInfo: ["test": "data"], - deliverImmediately: true - ) - - // Wait a bit then remove observer - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - dnc.removeObserver(testDistributedObserver) - print(" ๐Ÿงน Test observer cleaned up") + + isMonitoring = true + } + + func stopMonitoring() { + guard isMonitoring else { return } + + notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil) + notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) + + isMonitoring = false + + DispatchQueue.main.async { + self.isDoNotDisturbActive = false + self.currentFocusModeIdentifier = "" + self.currentFocusModeName = "" } } - - // MARK: - Setup Methods - - /// Sets up distributed notification observers for Focus Mode changes - private func setupFocusModeObservers() { - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿšจ SETTING UP NOTIFICATION OBSERVERS ๐Ÿšจ") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - - // Method 1: Darwin Notifications (low-level, no permissions needed) - setupDarwinNotificationObserver() - - // Method 2: Distributed Notifications (system-wide) - setupDistributedNotificationObserver() - - // Method 3: NSWorkspace Notifications (local) - setupNSWorkspaceNotificationObserver() - - // Method 4: Polling as fallback - startPollingForDND() - - print("๐Ÿ”” [DoNotDisturbManager] โœ… All observers registered") - print("๐Ÿ”” [DoNotDisturbManager] Please toggle Do Not Disturb now...") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") + + @objc private func handleFocusEnabled(_ notification: Notification) { + apply(notification: notification, isActive: true) } - - private func setupDarwinNotificationObserver() { - print("๐Ÿ”” [DoNotDisturbManager] Setting up DARWIN notification observer...") - - // Darwin notifications - these are low-level and work without special permissions - let darwinNotificationNames = [ - "com.apple.controlcenter.modeChanged", - "com.apple.donotdisturb.changed", - "com.apple.donotdisturb.state", - "com.apple.springboard.donotdisturb" - ] - - let center = CFNotificationCenterGetDarwinNotifyCenter() - - for notificationName in darwinNotificationNames { - CFNotificationCenterAddObserver( - center, - Unmanaged.passUnretained(self).toOpaque(), - { (center, observer, name, object, userInfo) in - guard let name = name else { return } - let notificationName = name.rawValue as String - print("๐ŸŽฏ [DARWIN NOTIFICATION] \(notificationName)") - - // Get the manager instance - if let observer = observer { - let manager = Unmanaged.fromOpaque(observer).takeUnretainedValue() - manager.handleDarwinNotification(notificationName) - } - }, - notificationName as CFString, - nil, - .deliverImmediately - ) - print(" โœ… Darwin observer registered for: \(notificationName)") - } + + @objc private func handleFocusDisabled(_ notification: Notification) { + apply(notification: notification, isActive: false) } - - private func handleDarwinNotification(_ name: String) { - DispatchQueue.main.async { [weak self] in - print("๐Ÿ””๐Ÿ””๐Ÿ”” [DARWIN] Focus Mode notification received: \(name)") - // Toggle DND state - self?.isDoNotDisturbActive.toggle() - self?.showFocusModeActivatedHUD() - } - } - - private func setupDistributedNotificationObserver() { - let dnc = DistributedNotificationCenter.default() - - print("๐Ÿ”” [DoNotDisturbManager] Setting up DISTRIBUTED notification observer...") - - // Observer for ANY AND ALL distributed notifications (for discovery) - let allNotificationsObserver = dnc.addObserver( - forName: nil, // nil = listen to EVERYTHING - object: nil, - queue: .main, - using: { [weak self] notification in - let notificationName = notification.name.rawValue - - // PRINT EVERY SINGLE NOTIFICATION (no filtering) - print("๐Ÿ“ข [DISTRIBUTED] \(notificationName)") - - // Also check if it looks Focus Mode related - if notificationName.lowercased().contains("focus") || - notificationName.lowercased().contains("dnd") || - notificationName.lowercased().contains("disturb") || - notificationName.lowercased().contains("mode") || - notificationName.contains("com.apple.controlcenter") || - notificationName.contains("com.apple.donotdisturb") { - - print("๐Ÿ””๐Ÿ””๐Ÿ”” [DoNotDisturbManager] โšก๏ธโšก๏ธโšก๏ธ POTENTIAL FOCUS MODE NOTIFICATION:") - print(" Name: \(notificationName)") - print(" Object: \(String(describing: notification.object))") - print(" UserInfo Keys: \(notification.userInfo?.keys.map { String(describing: $0) } ?? [])") - print(" UserInfo: \(notification.userInfo ?? [:])") - print(" ===============================================") - - self?.handleFocusModeNotification(notification) - } - } - ) - observers.append(allNotificationsObserver) - print(" โœ… Distributed notification observer registered") - } - - private func setupNSWorkspaceNotificationObserver() { - let nc = NSWorkspace.shared.notificationCenter - - print("๐Ÿ”” [DoNotDisturbManager] Setting up NSWORKSPACE notification observer...") - - // Try to observe workspace notifications - let workspaceObserver = nc.addObserver( - forName: nil, // Listen to all workspace notifications - object: nil, - queue: .main, - using: { notification in - let notificationName = notification.name.rawValue - - // Print all workspace notifications - print("๐Ÿข [NSWORKSPACE] \(notificationName)") - - // Check for Focus Mode related - if notificationName.lowercased().contains("focus") || - notificationName.lowercased().contains("dnd") || - notificationName.lowercased().contains("disturb") { - print("๐Ÿ””๐Ÿ””๐Ÿ”” [WORKSPACE FOCUS NOTIFICATION]") - print(" Name: \(notificationName)") - print(" UserInfo: \(notification.userInfo ?? [:])") - } - } - ) - observers.append(workspaceObserver) - print(" โœ… NSWorkspace notification observer registered") - } - - private func startPollingForDND() { - print("๐Ÿ”” [DoNotDisturbManager] Starting polling fallback (every 2 seconds)...") - - // Poll every 2 seconds to check for DND state changes - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.checkDNDStateViaDefaults() - } - print(" โœ… Polling timer started") - } - - private func checkDNDStateViaDefaults() { - // Try to read DND state from UserDefaults or system preferences - // This is a fallback detection method - - let cfPrefs = CFPreferencesCopyAppValue("dndStart" as CFString, "com.apple.controlcenter" as CFString) - if let prefs = cfPrefs { - print("๐Ÿ” [POLLING] Found controlcenter prefs: \(prefs)") - } - - // Try reading notification center settings - let ncPrefs = CFPreferencesCopyAppValue("doNotDisturb" as CFString, "com.apple.notificationcenterui" as CFString) - if let nc = ncPrefs { - print("๐Ÿ” [POLLING] Found notification center prefs: \(nc)") - } - } - - /// Checks initial Focus Mode state on app launch - private func checkInitialState() { - // TODO: Find a way to check current Focus Mode state - // Options to explore: - // 1. NSWorkspace properties (if any exist) - // 2. Private APIs - // 3. AppleScript (last resort) - - print("๐Ÿ”” [DoNotDisturbManager] Checking initial Focus Mode state...") - print(" Note: Initial state detection not yet implemented") - } - - // MARK: - Notification Handling - - /// Handles incoming Focus Mode notifications - private func handleFocusModeNotification(_ notification: Notification) { - DispatchQueue.main.async { [weak self] in + + private func apply(notification: Notification, isActive: Bool) { + metadataExtractionQueue.async { [weak self] in guard let self = self else { return } - - // Parse notification payload - let userInfo = notification.userInfo ?? [:] - - // Try to extract Focus Mode state and identifier - // Note: Actual keys depend on the notification structure (to be discovered) - if let stateString = userInfo["state"] as? String { - self.isDoNotDisturbActive = (stateString == "enabled" || stateString == "on" || stateString == "active") - } else if let enabled = userInfo["enabled"] as? Bool { - self.isDoNotDisturbActive = enabled - } else if let active = userInfo["active"] as? Bool { - self.isDoNotDisturbActive = active - } - - // Try to extract mode name - if let modeName = userInfo["name"] as? String { - self.currentFocusModeName = modeName - } else if let mode = userInfo["mode"] as? String { - self.currentFocusModeName = mode - } - - // Try to extract mode identifier - if let identifier = userInfo["identifier"] as? String { - self.currentFocusModeIdentifier = identifier - } - - print("๐Ÿ”” [DoNotDisturbManager] State updated:") - print(" Active: \(self.isDoNotDisturbActive)") - print(" Mode Name: \(self.currentFocusModeName.isEmpty ? "N/A" : self.currentFocusModeName)") - print(" Identifier: \(self.currentFocusModeIdentifier.isEmpty ? "N/A" : self.currentFocusModeIdentifier)") - - // Trigger HUD display - if self.isDoNotDisturbActive { - self.showFocusModeActivatedHUD() - } else { - self.showFocusModeDeactivatedHUD() + + let metadata = self.extractMetadata(from: notification) + + DispatchQueue.main.async { + if let identifier = metadata.identifier, identifier != self.currentFocusModeIdentifier { + self.currentFocusModeIdentifier = identifier + } + + if let name = metadata.name { + if name != self.currentFocusModeName { + self.currentFocusModeName = name + } + } else if !self.currentFocusModeIdentifier.isEmpty { + let derivedName = FocusModeType(rawValue: self.currentFocusModeIdentifier)?.displayName ?? "" + if !derivedName.isEmpty { + self.currentFocusModeName = derivedName + } + } + + guard self.isDoNotDisturbActive != isActive else { return } + + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } } } } - - // MARK: - HUD Display Methods - - /// Shows HUD when Focus Mode is activated - private func showFocusModeActivatedHUD() { - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ“ฑ Showing Focus Mode ACTIVATED HUD") - - // TODO: Integrate with DynamicIslandViewCoordinator to show HUD - // coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 1) - } - - /// Shows HUD when Focus Mode is deactivated - private func showFocusModeDeactivatedHUD() { - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ“ฑ Showing Focus Mode DEACTIVATED HUD") - - // TODO: Integrate with DynamicIslandViewCoordinator to show HUD - // coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 0) - } - - // MARK: - Public Methods - - /// Manually refresh Focus Mode state - func refreshState() { - print("๐Ÿ”” [DoNotDisturbManager] Manual state refresh requested") - checkInitialState() - } - - // MARK: - Cleanup - - private func cleanup() { - print("๐Ÿ”” [DoNotDisturbManager] Cleaning up observers...") - - // Remove Darwin notification observers - let center = CFNotificationCenterGetDarwinNotifyCenter() - CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque()) - - // Remove other observers - let dnc = DistributedNotificationCenter.default() - for observer in observers { - dnc.removeObserver(observer) + + private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { + guard let userInfo = notification.userInfo else { return (nil, nil) } + + if !hasLoggedPayload { + hasLoggedPayload = true + debugPrint("[DoNotDisturbManager] focus notification payload: \(userInfo)") } - observers.removeAll() - cancellables.removeAll() + + let identifierKeys = [ + "FocusModeIdentifier", + "focusModeIdentifier", + "identifier", + "Identifier" + ] + + let nameKeys = [ + "FocusModeName", + "focusModeName", + "name", + "Name" + ] + + let identifier = identifierKeys.compactMap { userInfo[$0] as? String }.first + let name = nameKeys.compactMap { userInfo[$0] as? String }.first + + return (name, identifier) } + +} + +private extension Notification.Name { + static let focusModeEnabled = Notification.Name("_NSDoNotDisturbEnabledNotification") + static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") } // MARK: - Focus Mode Types diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index a02af29..735e990 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -483,6 +483,10 @@ extension Defaults.Keys { static let showRecordingIndicator = Key("showRecordingIndicator", default: true) // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + + // MARK: Focus / Do Not Disturb Detection + static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) // MARK: Privacy Indicators (Camera & Microphone Detection) static let enableCameraDetection = Key("enableCameraDetection", default: true)