diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index e9dd4f3..459d989 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1824,10 +1824,6 @@ }, "Accessibility" : { - }, - "Accessibility permission required" : { - "comment" : "A label explaining that accessibility permissions are required.", - "isCommentAutoGenerated" : true }, "Actions" : { "localizations" : { @@ -14994,10 +14990,6 @@ }, "Open Model Settings" : { - }, - "Open Settings" : { - "comment" : "A button that opens system settings when pressed.", - "isCommentAutoGenerated" : true }, "Open System Settings" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9a46e38..59de231 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2402,6 +2402,7 @@ struct Shelf: View { @Default(.copyOnDrag) var copyOnDrag @Default(.autoRemoveShelfItems) var autoRemoveShelfItems @StateObject private var quickShareService = QuickShareService.shared + @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared private var selectedProvider: QuickShareProvider? { quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) @@ -2417,8 +2418,26 @@ struct Shelf: View { var body: some View { Form { + if !fullDiskAccessPermission.isAuthorized { + Section { + SettingsPermissionCallout( + title: "Full Disk Access required", + message: "Grant Full Disk Access so the Shelf can index and move files outside the app sandbox.", + icon: "externaldrive.fill", + iconColor: .purple, + requestButtonTitle: "Request Full Disk Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, + openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } + ) + } header: { + Text("Permissions") + } + } + Section { Defaults.Toggle("Enable shelf", key: .dynamicShelf) + .disabled(!fullDiskAccessPermission.isAuthorized) .settingsHighlight(id: highlightID("Enable shelf")) Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) @@ -2503,6 +2522,9 @@ struct Shelf: View { } .accentColor(.effectiveAccent) .navigationTitle("Shelf") + .onAppear { + fullDiskAccessPermission.refreshStatus() + } } } @@ -2511,6 +2533,7 @@ struct LiveActivitiesSettings: View { @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared + @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection @@ -2555,6 +2578,19 @@ struct LiveActivitiesSettings: View { } Section { + if !fullDiskAccessPermission.isAuthorized { + SettingsPermissionCallout( + title: "Custom Focus metadata", + message: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators.", + icon: "externaldrive.fill", + iconColor: .purple, + requestButtonTitle: "Request Full Disk Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, + openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } + ) + } + Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection) .settingsHighlight(id: highlightID("Enable Focus Detection")) @@ -2672,6 +2708,9 @@ struct LiveActivitiesSettings: View { } } .navigationTitle("Live Activities") + .onAppear { + fullDiskAccessPermission.refreshStatus() + } } } @@ -5217,27 +5256,52 @@ struct CustomOSDSettings: View { } struct SettingsPermissionCallout: View { + let title: String let message: String + let icon: String + let iconColor: Color + let requestButtonTitle: String + let openSettingsButtonTitle: String let requestAction: () -> Void let openSettingsAction: () -> Void + init( + title: String = "Accessibility permission required", + message: String, + icon: String = "exclamationmark.triangle.fill", + iconColor: Color = .orange, + requestButtonTitle: String = "Request Access", + openSettingsButtonTitle: String = "Open Settings", + requestAction: @escaping () -> Void, + openSettingsAction: @escaping () -> Void + ) { + self.title = title + self.message = message + self.icon = icon + self.iconColor = iconColor + self.requestButtonTitle = requestButtonTitle + self.openSettingsButtonTitle = openSettingsButtonTitle + self.requestAction = requestAction + self.openSettingsAction = openSettingsAction + } + var body: some View { VStack(alignment: .leading, spacing: 8) { - Label("Accessibility permission required", systemImage: "exclamationmark.triangle.fill") + Label(title, systemImage: icon) .font(.subheadline.weight(.semibold)) - .foregroundStyle(.orange) + .foregroundStyle(iconColor) Text(message) .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { - Button("Request Access") { + Button(requestButtonTitle) { requestAction() } .buttonStyle(.borderedProminent) - Button("Open Settings") { + Button(openSettingsButtonTitle) { openSettingsAction() } .buttonStyle(.bordered) diff --git a/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift new file mode 100644 index 0000000..65a55f2 --- /dev/null +++ b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift @@ -0,0 +1,112 @@ +import AppKit +import Combine +import Foundation + +enum FullDiskAccessAuthorization { + private static let probeURLs: [URL] = [ + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.TCC/TCC.db"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json") + ] + + static func hasPermission() -> Bool { + for url in probeURLs { + if canReadProtectedResource(at: url) { + return true + } + } + return false + } + + private static func canReadProtectedResource(at url: URL) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + return false + } + + do { + let handle = try FileHandle(forReadingFrom: url) + try handle.close() + return true + } catch { + return false + } + } +} + +@MainActor +final class FullDiskAccessPermissionStore: ObservableObject { + static let shared = FullDiskAccessPermissionStore() + + @Published private(set) var isAuthorized: Bool = FullDiskAccessAuthorization.hasPermission() + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateAuthorizationStatus(to: FullDiskAccessAuthorization.hasPermission()) + } + + func requestAccessPrompt() { +#if os(macOS) + let alert = NSAlert() + alert.messageText = "Full Disk Access Required" + alert.informativeText = "Dynamic Island needs Full Disk Access to detect custom Focus indicators and power the Shelf. Click Continue to open Full Disk Access settings, then press the + button and select Dynamic Island (we'll reveal it in Finder for you)." + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + openSystemSettings() + revealAppBundleInFinder() + } +#endif + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { + NSWorkspace.shared.open(url) + } +#endif + } + + private func revealAppBundleInFinder() { +#if os(macOS) + let bundleURL = Bundle.main.bundleURL + NSWorkspace.shared.activateFileViewerSelecting([bundleURL]) +#endif + } + + private func beginPollingForStatusChanges() { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + guard let self else { return } + + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 500_000_000) + let status = FullDiskAccessAuthorization.hasPermission() + + await MainActor.run { + self.updateAuthorizationStatus(to: status) + } + + if status { + break + } + } + } + } + + private func updateAuthorizationStatus(to newValue: Bool) { + guard newValue != isAuthorized else { return } + isAuthorized = newValue + } +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 0afcc92..047d7fb 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -131,6 +131,9 @@ final class DoNotDisturbManager: ObservableObject { if nameChanged { self.currentFocusModeName = finalName + .localizedCaseInsensitiveContains( + "Reduce Interruptions" + ) ? "Reduce Interr." : finalName } if identifierChanged || nameChanged || shouldToggleActive { @@ -307,6 +310,7 @@ enum FocusModeType: String, CaseIterable { case gaming = "com.apple.focus.gaming" case mindfulness = "com.apple.focus.mindfulness" case reading = "com.apple.focus.reading" + case reduceInterruptions = "com.apple.focus.reduce-interruptions" case custom = "com.apple.focus.custom" case unknown = "" @@ -321,6 +325,7 @@ enum FocusModeType: String, CaseIterable { case .gaming: return "Gaming" case .mindfulness: return "Mindfulness" case .reading: return "Reading" + case .reduceInterruptions: return "Reduce Interr." case .custom: return "Focus" case .unknown: return "Focus Mode" } @@ -337,6 +342,7 @@ enum FocusModeType: String, CaseIterable { case .gaming: return "gamecontroller.fill" case .mindfulness: return "circle.hexagongrid" case .reading: return "book.closed.fill" + case .reduceInterruptions: return "apple.intelligence" case .custom: return "app.badge" case .unknown: return "moon.fill" } @@ -362,7 +368,11 @@ enum FocusModeType: String, CaseIterable { return image } - return Image(systemName: sfSymbol) + return Image( + systemName: self == .custom + ? self.getCustomIconFromFile() + : sfSymbol + ) } var accentColor: Color { @@ -385,8 +395,10 @@ enum FocusModeType: String, CaseIterable { return Color(red: 0.361, green: 0.898, blue: 0.883, opacity: 1.0) case .reading: return Color(red: 1.000, green: 0.622, blue: 0.044, opacity: 1.0) + case .reduceInterruptions: + return Color(red: 0.686, green: 0.322, blue: 0.871, opacity: 1.0) case .custom: - return Color(red: 0.513, green: 0.478, blue: 0.965) + return self.getCustomAccentColorFromFile() case .unknown: return Color(red: 0.370, green: 0.360, blue: 0.902) } @@ -425,7 +437,7 @@ extension FocusModeType { return } - if normalizedLowercased.hasPrefix("com.apple.focus.") { + if normalizedLowercased.hasPrefix("com.apple.focus") { self = .custom return } @@ -434,23 +446,31 @@ extension FocusModeType { } static func resolve(identifier: String?, name: String?) -> FocusModeType { - let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedName.isEmpty { + if let name, !name.isEmpty { if let match = FocusModeType.allCases.first(where: { guard !$0.displayName.isEmpty else { return false } - return $0.displayName.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + return $0.displayName.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame }) { return match } } - let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedIdentifier.isEmpty { - return FocusModeType(identifier: trimmedIdentifier) + if let identifier, !identifier.isEmpty { + return FocusModeType(identifier: identifier) } return .doNotDisturb } + + func getCustomIconFromFile() -> String { + return FocusMetadataReader.shared + .getIcon(for: DoNotDisturbManager.shared.currentFocusModeName) + } + + func getCustomAccentColorFromFile() -> Color { + return FocusMetadataReader.shared + .getAccentColor(for: DoNotDisturbManager.shared.currentFocusModeName) + } } // MARK: - Metadata helpers @@ -943,3 +963,90 @@ private enum FocusMetadataDecoder { return nil } } + +private final class FocusMetadataReader { + private let pathToDatabase:URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json") + + struct DNDConfigRoot: Codable { + let data: [DNDDataEntry] + } + + struct DNDDataEntry: Codable { + let modeConfigurations: [String: DNDModeWrapper] + } + + struct DNDModeWrapper: Codable { + let mode: DNDMode + } + + struct DNDMode: Codable { + let name: String + let symbolImageName: String? + let tintColorName: String? + } + + private init(){} + + static let shared = FocusMetadataReader() + + private func getModeConfig(for focusName: String) -> DNDMode? { + guard FullDiskAccessAuthorization.hasPermission() else { return nil } + do { + let data = try Data(contentsOf: pathToDatabase) + let root = try JSONDecoder().decode(DNDConfigRoot.self, from: data) + + for entry in root.data { + for wrapper in entry.modeConfigurations.values { + if wrapper.mode.name + .localizedCaseInsensitiveCompare(focusName) == .orderedSame { + return wrapper.mode + } + } + } + } catch { + print("JSON Error: \(error)") + } + return nil + } + + /// Fetch the icon for the current focus from disk. If the focus is not found return the placeholder `app.badge` + /// - Returns A string representing the sfSymbol of the current focus + func getIcon(for focus: String) -> String { + guard let mode = getModeConfig(for: focus) else { return "app.badge" } + return mode.symbolImageName ?? "app.badge" + } + + /// Fetch the accent color for the current focus from disk. If the focus is not found return the placeholder `Color.indigo` + /// - Returns A Color representing the accent color for the current focus + func getAccentColor(for focus: String) -> Color { + guard let mode = getModeConfig(for: focus), + let colorName = mode.tintColorName else { return .indigo } + + return Color.stringToColor(for: colorName) + } + +} + +extension Color { + static func stringToColor(for string:String) -> Color { + let cleanName = string.lowercased() + .replacingOccurrences(of: "system", with: "") + .replacingOccurrences(of: "color", with: "") + + switch cleanName { + case "red": return .red + case "orange": return .orange + case "yellow": return .yellow + case "green": return .green + case "mint": return .mint + case "teal": return .teal + case "cyan": return .cyan + case "blue": return .blue + case "indigo": return .indigo + case "purple": return .purple + case "pink": return .pink + case "gray", "grey": return .gray + default: return .indigo + } + } +}