diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index e6db8a4..340acd0 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop."; - INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files."; INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; @@ -536,7 +536,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop."; - INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files."; INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2da6573..736752a 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3183,6 +3183,15 @@ struct Shelf: View { @Default(.autoRemoveShelfItems) var autoRemoveShelfItems @StateObject private var quickShareService = QuickShareService.shared @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared + @ObservedObject private var shelfFolderAccessPermission = ShelfFolderAccessPermissionStore.shared + + private var hasDocumentsAndDownloadsAccess: Bool { + shelfFolderAccessPermission.hasDocumentsAndDownloadsAccess + } + + private var canEnableShelf: Bool { + fullDiskAccessPermission.isAuthorized || hasDocumentsAndDownloadsAccess + } private var selectedProvider: QuickShareProvider? { quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) @@ -3198,18 +3207,33 @@ struct Shelf: View { var body: some View { Form { - if !fullDiskAccessPermission.isAuthorized { + if !canEnableShelf || !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() } - ) + if !canEnableShelf { + SettingsPermissionCallout( + title: "Additional folder access required", + message: "Enable Full Disk Access, or grant access to both Documents and Downloads folders to use Shelf.", + icon: "folder.badge.questionmark", + iconColor: .orange, + requestButtonTitle: "Request Folder Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { shelfFolderAccessPermission.requestAccessPrompt() }, + openSettingsAction: { shelfFolderAccessPermission.openSystemSettings() } + ) + } + + if !fullDiskAccessPermission.isAuthorized { + SettingsPermissionCallout( + title: "Full Disk Access for global mode", + message: "Without Full Disk Access, Shelf can only read files from Documents and Downloads. Grant Full Disk Access to make Shelf work globally.", + icon: "externaldrive.fill", + iconColor: .purple, + requestButtonTitle: "Request Full Disk Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, + openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } + ) + } } header: { Text("Permissions") } @@ -3217,7 +3241,7 @@ struct Shelf: View { Section { Defaults.Toggle("Enable shelf", key: .dynamicShelf) - .disabled(!fullDiskAccessPermission.isAuthorized) + .disabled(!canEnableShelf) .settingsHighlight(id: highlightID("Enable shelf")) Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) @@ -3304,6 +3328,7 @@ struct Shelf: View { .navigationTitle("Shelf") .onAppear { fullDiskAccessPermission.refreshStatus() + shelfFolderAccessPermission.refreshStatus() } } } diff --git a/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift index a891f1e..adab59d 100644 --- a/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift +++ b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift @@ -53,6 +53,53 @@ enum FullDiskAccessAuthorization { } } +enum ShelfFolderAccessAuthorization { + private static var documentsDirectoryURL: URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + } + + private static var downloadsDirectoryURL: URL? { + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + } + + static func hasDocumentsAccess() -> Bool { + canReadDirectory(at: documentsDirectoryURL) + } + + static func hasDownloadsAccess() -> Bool { + canReadDirectory(at: downloadsDirectoryURL) + } + + static func hasDocumentsAndDownloadsAccess() -> Bool { + hasDocumentsAccess() && hasDownloadsAccess() + } + + static func requestAccessProbe() { + if let documentsDirectoryURL { + _ = try? FileManager.default.contentsOfDirectory(at: documentsDirectoryURL, includingPropertiesForKeys: nil) + } + + if let downloadsDirectoryURL { + _ = try? FileManager.default.contentsOfDirectory(at: downloadsDirectoryURL, includingPropertiesForKeys: nil) + } + } + + private static func canReadDirectory(at url: URL?) -> Bool { + guard let url else { return false } + + do { + _ = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + return true + } catch { + return false + } + } +} + @MainActor final class FullDiskAccessPermissionStore: ObservableObject { static let shared = FullDiskAccessPermissionStore() @@ -128,3 +175,83 @@ final class FullDiskAccessPermissionStore: ObservableObject { isAuthorized = newValue } } + +@MainActor +final class ShelfFolderAccessPermissionStore: ObservableObject { + static let shared = ShelfFolderAccessPermissionStore() + + @Published private(set) var hasDocumentsAccess: Bool = ShelfFolderAccessAuthorization.hasDocumentsAccess() + @Published private(set) var hasDownloadsAccess: Bool = ShelfFolderAccessAuthorization.hasDownloadsAccess() + + var hasDocumentsAndDownloadsAccess: Bool { + hasDocumentsAccess && hasDownloadsAccess + } + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateStatus( + documents: ShelfFolderAccessAuthorization.hasDocumentsAccess(), + downloads: ShelfFolderAccessAuthorization.hasDownloadsAccess() + ) + } + + func requestAccessPrompt() { + ShelfFolderAccessAuthorization.requestAccessProbe() + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + let urls = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders", + "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 + } + } +#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 docs = ShelfFolderAccessAuthorization.hasDocumentsAccess() + let downloads = ShelfFolderAccessAuthorization.hasDownloadsAccess() + + await MainActor.run { + self.updateStatus(documents: docs, downloads: downloads) + } + + if docs && downloads { + break + } + } + } + } + + private func updateStatus(documents: Bool, downloads: Bool) { + if hasDocumentsAccess != documents { + hasDocumentsAccess = documents + } + + if hasDownloadsAccess != downloads { + hasDownloadsAccess = downloads + } + } +}