diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 9a98af1..ff5c2c4 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -70,6 +70,7 @@ struct DynamicNotchApp: App { } } + @CommandsBuilder var commands: some Commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { @@ -116,6 +117,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var cancellables = Set() private var windowsHiddenForLock = false private var optionalShortcutHandlersRegistered = false + private weak var focusWithoutDevToolsMenuItem: NSMenuItem? + private weak var focusUseDevToolsMenuItem: NSMenuItem? // Debouncing mechanism for window size updates private var windowSizeUpdateWorkItem: DispatchWorkItem? @@ -146,6 +149,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } + + func applicationDidBecomeActive(_ notification: Notification) { + installTopMenuItemsIfNeeded() + } func applicationWillTerminate(_ notification: Notification) { // Cancel any pending window size updates @@ -370,6 +377,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { idleAnimationManager.initializeDefaultAnimations() applySelectedAppIcon() + installTopMenuItemsIfNeeded() + + Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] _ in + self?.updateFocusMenuState() + } + .store(in: &cancellables) // Setup SystemHUD Manager SystemHUDManager.shared.setup(coordinator: coordinator) @@ -531,8 +545,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil ) { [weak self] _ in guard let self = self, let window = self.window else { return } - window.alphaValue = - self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + DispatchQueue.main.async { + window.alphaValue = + self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + } } NotificationCenter.default.addObserver( @@ -644,6 +660,155 @@ class AppDelegate: NSObject, NSApplicationDelegate { } + private func installTopMenuItemsIfNeeded() { + guard let mainMenu = NSApp.mainMenu else { return } + if mainMenu.items.contains(where: { $0.identifier?.rawValue == "Atoll.Focus.Menu" }) { + updateFocusMenuState() + return + } + + let insertionIndex = preferredMenuInsertionIndex(in: mainMenu) + + let focusMenuItem = NSMenuItem(title: "Focus", action: nil, keyEquivalent: "") + focusMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Focus.Menu") + let focusSubmenu = NSMenu(title: "Focus") + + let withoutDevTools = NSMenuItem( + title: "Use without DevTools", + action: #selector(selectFocusWithoutDevTools), + keyEquivalent: "" + ) + withoutDevTools.target = self + + let useDevTools = NSMenuItem( + title: "Use DevTools", + action: #selector(selectFocusUseDevTools), + keyEquivalent: "" + ) + useDevTools.target = self + + focusSubmenu.addItem(withoutDevTools) + focusSubmenu.addItem(useDevTools) + focusMenuItem.submenu = focusSubmenu + mainMenu.insertItem(focusMenuItem, at: insertionIndex) + + focusWithoutDevToolsMenuItem = withoutDevTools + focusUseDevToolsMenuItem = useDevTools + + let accessibilityMenuItem = NSMenuItem(title: "Accessibility", action: nil, keyEquivalent: "") + accessibilityMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Accessibility.Menu") + let accessibilitySubmenu = NSMenu(title: "Accessibility") + + let requestAccessibility = NSMenuItem( + title: "Request Accessibility Access", + action: #selector(requestAccessibilityAccess), + keyEquivalent: "" + ) + requestAccessibility.target = self + + let openAccessibility = NSMenuItem( + title: "Open Accessibility Settings", + action: #selector(openAccessibilitySettings), + keyEquivalent: "" + ) + openAccessibility.target = self + + accessibilitySubmenu.addItem(requestAccessibility) + accessibilitySubmenu.addItem(openAccessibility) + accessibilityMenuItem.submenu = accessibilitySubmenu + mainMenu.insertItem(accessibilityMenuItem, at: insertionIndex + 1) + + let permissionsMenuItem = NSMenuItem(title: "Permissions", action: nil, keyEquivalent: "") + permissionsMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Permissions.Menu") + let permissionsSubmenu = NSMenu(title: "Permissions") + + let requestFullDisk = NSMenuItem( + title: "Request Full Disk Access", + action: #selector(requestFullDiskAccess), + keyEquivalent: "" + ) + requestFullDisk.target = self + + let openFullDisk = NSMenuItem( + title: "Open Full Disk Access Settings", + action: #selector(openFullDiskAccessSettings), + keyEquivalent: "" + ) + openFullDisk.target = self + + let openDevTools = NSMenuItem( + title: "Open Developer Tools Settings", + action: #selector(openDeveloperToolsSettingsFromMenu), + keyEquivalent: "" + ) + openDevTools.target = self + + permissionsSubmenu.addItem(requestFullDisk) + permissionsSubmenu.addItem(openFullDisk) + permissionsSubmenu.addItem(NSMenuItem.separator()) + permissionsSubmenu.addItem(openDevTools) + permissionsMenuItem.submenu = permissionsSubmenu + mainMenu.insertItem(permissionsMenuItem, at: insertionIndex + 2) + + updateFocusMenuState() + } + + private func preferredMenuInsertionIndex(in mainMenu: NSMenu) -> Int { + if let index = mainMenu.items.firstIndex(where: { $0.title == "Window" }) { + return index + } + if let index = mainMenu.items.firstIndex(where: { $0.title == "Help" }) { + return index + } + return max(mainMenu.numberOfItems, 0) + } + + private func updateFocusMenuState() { + let mode = Defaults[.focusMonitoringMode] + focusWithoutDevToolsMenuItem?.state = mode == .withoutDevTools ? .on : .off + focusUseDevToolsMenuItem?.state = mode == .useDevTools ? .on : .off + } + + @objc private func selectFocusWithoutDevTools() { + Defaults[.focusMonitoringMode] = .withoutDevTools + updateFocusMenuState() + } + + @objc private func selectFocusUseDevTools() { + Defaults[.focusMonitoringMode] = .useDevTools + updateFocusMenuState() + } + + @objc private func requestAccessibilityAccess() { + AccessibilityPermissionStore.shared.requestAuthorizationPrompt() + } + + @objc private func openAccessibilitySettings() { + AccessibilityPermissionStore.shared.openSystemSettings() + } + + @objc private func requestFullDiskAccess() { + FullDiskAccessPermissionStore.shared.requestAccessPrompt() + } + + @objc private func openFullDiskAccessSettings() { + FullDiskAccessPermissionStore.shared.openSystemSettings() + } + + @objc private func openDeveloperToolsSettingsFromMenu() { + let urls = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools", + "x-apple.systempreferences:com.apple.preference.security" + ] + + for candidate in urls { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return + } + } + } + private func registerOptionalShortcutHandlers() { guard !optionalShortcutHandlersRegistered else { return } optionalShortcutHandlersRegistered = true diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 6966391..1792bb1 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -11006,6 +11006,10 @@ } } }, + "Focus detection mode" : { + "comment" : "The title of a confirmation dialog that lets users choose between using the focus detection mode with or without the use of the SwiftUI Inspector (DevTools).", + "isCommentAutoGenerated" : true + }, "Focus Status" : { "localizations" : { "pt-BR" : { @@ -23164,6 +23168,9 @@ } } } + }, + "This is optional. You can change it any time from the menu bar." : { + }, "This permanently removes every saved note and its attachments." : { "localizations" : { @@ -24421,6 +24428,10 @@ } } }, + "Use DevTools" : { + "comment" : "One option in the confirmation dialog in the `OnboardingView` that lets the user choose to use the developer tools for focus monitoring.", + "isCommentAutoGenerated" : true + }, "Use music visualizer spectrogram" : { "localizations" : { "ar" : { @@ -24543,6 +24554,10 @@ } } }, + "Use without DevTools" : { + "comment" : "An option in the onboarding flow that lets users choose to use focus detection without the option provided by the developer tools.", + "isCommentAutoGenerated" : true + }, "Used" : { "comment" : "A label describing the percentage of memory that is currently in use.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/Onboarding/OnboardingView.swift b/DynamicIsland/components/Onboarding/OnboardingView.swift index bed1fd7..5697ab3 100644 --- a/DynamicIsland/components/Onboarding/OnboardingView.swift +++ b/DynamicIsland/components/Onboarding/OnboardingView.swift @@ -22,6 +22,7 @@ import SwiftUI import AVFoundation +import Defaults enum OnboardingStep { case welcome @@ -36,6 +37,8 @@ private let calendarService = CalendarService() struct OnboardingView: View { @State private var step: OnboardingStep = .welcome + @State private var showFocusMonitoringChoice = false + @State private var didPresentFocusMonitoringChoice = false let onFinish: () -> Void let onOpenSettings: () -> Void @@ -120,6 +123,28 @@ struct OnboardingView: View { } } .frame(width: 400, height: 600) + .onAppear { + guard !didPresentFocusMonitoringChoice else { return } + didPresentFocusMonitoringChoice = true + showFocusMonitoringChoice = true + } + .confirmationDialog( + "Focus detection mode", + isPresented: $showFocusMonitoringChoice, + titleVisibility: .visible + ) { + Button("Use DevTools") { + Defaults[.focusMonitoringMode] = .useDevTools + } + + Button("Use without DevTools") { + Defaults[.focusMonitoringMode] = .withoutDevTools + } + + Button("Later", role: .cancel) {} + } message: { + Text("This is optional. You can change it any time from the menu bar.") + } } // MARK: - Permission Request Logic diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index ee0b6d7..073693a 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -32,12 +32,31 @@ final class DoNotDisturbManager: ObservableObject { private let notificationCenter = DistributedNotificationCenter.default() private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility) private let focusLogStream = FocusLogStream() + private let assertionsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json") + private var pollingSource: DispatchSourceTimer? + private var lastAssertionsModificationDate: Date? + private var modeCancellable: AnyCancellable? + + @Published private(set) var monitoringMode: FocusMonitoringMode = Defaults[.focusMonitoringMode] private init() { focusLogStream.onMetadataUpdate = { [weak self] identifier, name in self?.handleLogMetadataUpdate(identifier: identifier, name: name) } + + modeCancellable = Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] change in + guard let self else { return } + + DispatchQueue.main.async { + self.monitoringMode = change.newValue + } + + self.applyMonitoringModeChange(change.newValue) + } } deinit { @@ -63,7 +82,8 @@ final class DoNotDisturbManager: ObservableObject { suspensionBehavior: .deliverImmediately ) - focusLogStream.start() + startAssertionsPolling() + applyMonitoringModeChange(Defaults[.focusMonitoringMode]) isMonitoring = true } @@ -74,6 +94,7 @@ final class DoNotDisturbManager: ObservableObject { notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) focusLogStream.stop() + stopAssertionsPolling() isMonitoring = false DispatchQueue.main.async { @@ -159,6 +180,8 @@ final class DoNotDisturbManager: ObservableObject { } private func handleLogMetadataUpdate(identifier: String?, name: String?) { + guard Defaults[.focusMonitoringMode] == .useDevTools else { return } + metadataExtractionQueue.async { [weak self] in guard let self = self else { return } let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -308,6 +331,90 @@ private extension Notification.Name { static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") } +private extension DoNotDisturbManager { + func applyMonitoringModeChange(_ mode: FocusMonitoringMode) { + guard isMonitoring else { return } + + if mode == .useDevTools { + focusLogStream.start() + } else { + focusLogStream.stop() + } + } + + func startAssertionsPolling() { + stopAssertionsPolling() + lastAssertionsModificationDate = nil + + let timer = DispatchSource.makeTimerSource(queue: pollingQueue) + timer.schedule(deadline: .now() + .seconds(1), repeating: .seconds(2), leeway: .milliseconds(250)) + timer.setEventHandler { [weak self] in + self?.pollAssertionsState() + } + timer.resume() + pollingSource = timer + } + + func stopAssertionsPolling() { + pollingSource?.cancel() + pollingSource = nil + lastAssertionsModificationDate = nil + } + + func pollAssertionsState() { + guard FullDiskAccessAuthorization.hasPermission() else { return } + + if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), + let modifiedAt = attributes[.modificationDate] as? Date, + let lastObservedModificationDate = lastAssertionsModificationDate, + modifiedAt <= lastObservedModificationDate { + 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 + + 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" + ] + + let nameKeys = [ + "activityDisplayName", + "displayName", + "FocusModeName", + "focusModeName", + "focusMode", + "name", + "Name" + ] + + let identifier = isActive ? firstMatch(for: identifierKeys, in: assertions) : nil + let name = isActive ? firstMatch(for: nameKeys, in: assertions) : nil + + publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll") + } +} + // MARK: - Focus Mode Types enum FocusModeType: String, CaseIterable { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 2ed3592..b470a79 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -498,6 +498,22 @@ enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializab } } +enum FocusMonitoringMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case withoutDevTools = "withoutDevTools" + case useDevTools = "useDevTools" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .withoutDevTools: + return "Use without DevTools" + case .useDevTools: + return "Use DevTools" + } + } +} + enum ReminderPresentationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { case ringCountdown = "Ring" case digital = "Digital" @@ -1012,6 +1028,7 @@ extension Defaults.Keys { // MARK: Focus / Do Not Disturb Detection static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let focusMonitoringMode = Key("focusMonitoringMode", default: .withoutDevTools) static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) static let focusIndicatorNonPersistent = Key("focusIndicatorNonPersistent", default: false)