I have discovered a gap in my understanding of user selected URLs in iOS, and I would be grateful if someone can put me right please.
My understanding is that a URL selected by a user can be accessed by calling url.startAccessingSecurityScopedResource() call. Subsequently a call to stopAccessingSecurityScopedResource() is made to avoid sandbox memory leaks.
Furthermore, the URL can be saved as a bookmark and reconstituted when the app is run again to avoid re-asking permission from the user.
So far so good.
However, I have discovered that a URL retrieved from a bookmark can be accessed without the call to url.startAccessingSecurityScopedResource(). This seems contrary to what the documentation says here
So my question is (assuming this is not a bug) why not save and retrieve the URL immediately in order to avoid having to make any additional calls to url.startAccessingSecurityScopedResource?
Bill Aylward
You can copy and paste the code below into a new iOS project to illustrate this. Having chosen a folder, the 'Summarise folder without permission' button fails as expected, but once the 'Retrieve URL from bookmark' has been pressed, it works fine.
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@AppStorage("bookmarkData") private var bookmarkData: Data?
@State private var showFolderPicker = false
@State private var folderUrl: URL?
@State private var folderReport: String?
var body: some View {
VStack(spacing: 20) {
Text("Selected folder: \(folderUrl?.lastPathComponent ?? "None")")
Text("Contents: \(folderReport ?? "Unknown")")
Button("Select folder") {
showFolderPicker.toggle()
}
Button("Deselect folder") {
folderUrl = nil
folderReport = nil
bookmarkData = nil
}
.disabled(folderUrl == nil)
Button("Retrieve URL from bookmark") {
retrieveFolderURL()
}
.disabled(bookmarkData == nil)
Button("Summarise folder with permission") {
summariseFolderWithPermission(true)
}
.disabled(folderUrl == nil)
Button("Summarise folder without permission") {
summariseFolderWithPermission(false)
}
.disabled(folderUrl == nil)
}
.padding()
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [UTType.init("public.folder")!],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let selectedUrl = urls.first {
print("Processing folder: \(selectedUrl)")
processFolderURL(selectedUrl)
}
case .failure(let error):
print("\(error.localizedDescription)")
}
}
.onAppear() {
guard folderUrl == nil else { return }
retrieveFolderURL()
}
}
func processFolderURL(_ selectedUrl: URL?) {
guard selectedUrl != nil else { return }
// Create and save a security scoped bookmark in AppStorage
do {
guard selectedUrl!.startAccessingSecurityScopedResource() else { print("Unable to access \(selectedUrl!)"); return }
// Save bookmark
bookmarkData = try selectedUrl!.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil)
selectedUrl!.stopAccessingSecurityScopedResource()
} catch {
print("Unable to save security scoped bookmark")
}
folderUrl = selectedUrl!
}
func retrieveFolderURL() {
guard let bookmarkData = bookmarkData else {
print("No bookmark data available")
return
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withoutUI,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
folderUrl = url
} catch {
print("Error accessing URL: \(error.localizedDescription)")
}
}
func summariseFolderWithPermission(_ permission: Bool) {
folderReport = nil
print(String(describing: folderUrl))
guard folderUrl != nil else { return }
if permission { print("Result of access requrest is \(folderUrl!.startAccessingSecurityScopedResource())") }
do {
let contents = try FileManager.default.contentsOfDirectory(atPath: folderUrl!.path)
folderReport = "\(contents.count) files, the first is: \(contents.first!)"
} catch {
print(error.localizedDescription)
}
if permission { folderUrl!.stopAccessingSecurityScopedResource() }
}
}
I have discovered a gap in my understanding of user-selected URLs in iOS, and I would be grateful if someone can put me right, please.
I will do my best, but I'll freely admit that the behaviors here are more than a little... opaque.
However, I have discovered that a URL retrieved from a bookmark can be accessed without the call to url.startAccessingSecurityScopedResource().
Yes, that is correct. URLs can have security scope "attached" to them, and on iOS, it's very difficult (impossible?) to get a URL out of ANY API that doesn't have a security scope attached (this is also called "implicit" scope vs the "explicit" scope that start/stop manage). In theory, we should someday be able to turn off implicit scope, at which point start/stop WOULD be required...
This seems contrary to what the documentation says here.
Well... that's not quite true, but it takes a VERY close reading of the documentation to catch it. What the documentation actually says is:
"When you obtain a security-scoped URL, such as by resolving a security-scoped bookmark, you can’t immediately use the resource it points to."
The catch here is that you did NOT resolve a security-scoped bookmark. You create a security-scoped bookmark by using NSURLBookmarkCreationWithSecurityScope,, but you couldn't do that because that symbol doesn't exist on iOS. What you created was a "regular" bookmark which also happened to have enough data embedded to restore implicit security scope. As the saying goes, "technically correct" is the best kind... Though this is definitely not the most successful part of our API or documentation.
There's actually a hint of this dynamic in the documentation, since "NSURLBookmarkCreationWithoutImplicitSecurityScope" IS defined on iOS. I can't think of any reason why you'd use that option, but if you did, then you wouldn't be able to use the URL after it was resolved*.
*Probably? The behavior here is very messy as this is actually managed at the process level, not the individual URL level.
In any case, the practical consequence of all this is:
-
It's good practice to call start/stop, since we might somebody drop or restrict implicit scope.
-
It's very difficult to "get" a URL from the system that you can't actually access, which means start/stop aren't as essential as they would be on iOS.
So my question is (assuming this is not a bug) why not save and retrieve the URL immediately in order to avoid having to make any additional calls to url.startAccessingSecurityScopedResource?
I haven't benchmarked it, but I suspect it's considerably slower, as you're basically doing everything startAccessingSecurityScopedResource, plus a bunch of extra I/O calls to first generate and then resolve the bookmark.
Furthermore, the URL can be saved as a bookmark and reconstituted when the app is run again to avoid re-asking permission from the user.
Note that there is an iOS bug (r.102995804) that means bookmarks to any external volume break as soon as the volume is remounted. Unfortunately, that means that the kinds of apps that would most benefit from persistent access (because they want to do bulk I/O to external targets) can't persist access. If this is an issue for your app, please file a bug on this that describes exactly why this is important to your app and post the bug number back here. I would love to get this fixed, but there have been surprisingly few reports about it.
Thanks for your reply, but I remain confused about this. When I run my code on a real device, the 'Summarise folder without permission' button does NOT work, so there doesn't seem to be any implicit "start". Strangely running the identical code on a simulator DOES work, which I also find confusing.
Ironically, I believe this is actually caused by a shortcut the simulator uses to behave more like iOS. Instead of trying to replicate the implicit system iOS relies so heavily on, I believe there is a broader sandbox exception granted to your app’s simulator process. This ensures that the URLs the simulator returns work while avoiding all the work of trying to replicate what iOS is doing. It also means your simulator process can access places it "shouldn't" be able to, but that's not really an issue given what the simulator is "for".
Regarding performance, my interest in this started with sandbox_extension_consume error=[12: Cannot allocate memory] errors occurring when accessing hundreds of URLs, each bracketed by the accessing calls.
Can you tell me more about this? Is this on iOS or the simulator? What are you doing that's triggering this?
__
Kevin Elliott
DTS Engineer, CoreOS/Hardware