Update Shelf to work with limited permissions

This commit is contained in:
Hariharan Mudaliar 2026-03-15 19:55:08 +05:30
commit eccc16237b
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
3 changed files with 166 additions and 14 deletions

View file

@ -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.";

View file

@ -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()
}
}
}

View file

@ -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<Void, Never>?
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
}
}
}