mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Fix: DevTools Access
This commit is contained in:
parent
e553ccf667
commit
53a9fdb112
5 changed files with 332 additions and 3 deletions
|
|
@ -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<AnyCancellable>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Bool>("enableDoNotDisturbDetection", default: true)
|
||||
static let focusMonitoringMode = Key<FocusMonitoringMode>("focusMonitoringMode", default: .withoutDevTools)
|
||||
static let showDoNotDisturbIndicator = Key<Bool>("showDoNotDisturbIndicator", default: true)
|
||||
static let showDoNotDisturbLabel = Key<Bool>("showDoNotDisturbLabel", default: true)
|
||||
static let focusIndicatorNonPersistent = Key<Bool>("focusIndicatorNonPersistent", default: false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue