iOS folder bookmarks

I have an iOS app that allows user to select a folder (from Files). I want to bookmark that folder and later on (perhaps on a different launch of the app) access the contents of it. Is that scenario supported or not? Can't make it work for some reason (e.g. I'm getting no error from the call to create a bookmark, from a call to resolve the bookmark, the folder URL is not stale, but... startAccessingSecurityScopedResource() is returning false.

Answered by DTS Engineer in 854824022

I have an iOS app that allows users to select a folder (from Files). I want to bookmark that folder and later on (perhaps on a different launch of the app) access the contents of it. Is that scenario supported or not?

Technically, yes, this works. However, practically speaking... it doesn't work very well. The problem here is that due to a bug (r.102995804), NSURL isn't able to resolve bookmarks across volume mounts. That means bookmarks within the device work fine and bookmarks to other volumes initially work... but then break completely once the volume has been unmounted.

Unfortunately, I think that makes them pretty unusable in practice, since one of the main reasons a user would WANT to persistently reference an external directory is that they're accessing external storage. More to the point, it's hard to build the kind of "automatic" interface that bookmarks allow when the probability is so high that the bookmark simply won't work at all.

Finally, while this is a known bug, I'd appreciate you filing a bug on this and posting the bug number back here. It turns out that is a somewhat tricky issue to resolve, so duplicate bugs are important.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Is that scenario supported or not?

Yes.

On macOS you need to use a security-scoped bookmark for this. Security-scoped bookmarks aren’t a thing on iOS, so instead you just use a normal bookmark.

However, I know there’s been some problems with this recently. My colleague Kevin has been tracking that issue, so I’m gonna ask him to chime in here.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I have an iOS app that allows users to select a folder (from Files). I want to bookmark that folder and later on (perhaps on a different launch of the app) access the contents of it. Is that scenario supported or not?

Technically, yes, this works. However, practically speaking... it doesn't work very well. The problem here is that due to a bug (r.102995804), NSURL isn't able to resolve bookmarks across volume mounts. That means bookmarks within the device work fine and bookmarks to other volumes initially work... but then break completely once the volume has been unmounted.

Unfortunately, I think that makes them pretty unusable in practice, since one of the main reasons a user would WANT to persistently reference an external directory is that they're accessing external storage. More to the point, it's hard to build the kind of "automatic" interface that bookmarks allow when the probability is so high that the bookmark simply won't work at all.

Finally, while this is a known bug, I'd appreciate you filing a bug on this and posting the bug number back here. It turns out that is a somewhat tricky issue to resolve, so duplicate bugs are important.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

But I don't see it working for a folder bookmark even if I quit the app and immediately launch it again, in which case (I assume) volume remounting is not a concern – it's the folder in Files on the iPhone itself, not cloud or anything like that. It works for the file bookmarks though, so the workaround I had in mind was to iterate through the whole hierarchy, create the corresponding mirroring tree of bookmarks, persist that tree and later on use file bookmarks from that tree, but that's quite awkward, besides it takes quite a while to build that tree for a large folder hierarchy.

But I don't see it working for a folder bookmark even if I quit the app and immediately launch it again, in which case (I assume) volume remounting is not a concern – it's the folder in Files on the iPhone itself, not cloud or anything like that.

This should work. More to the point, I don't think iOS is able to resolve a bookmark you can't access. Can you put an example project up somewhere so I can see what you're doing?

Also, have you tried accessing the directory even though startAccessing failed? The iOS file security model is different enough from macOS that the startAccessing calls aren't as essential as they are on macOS, but that also means we're less likely to "notice" if something breaks and they start failing incorrectly.

Lastly, be aware that I’ve seen an odd bug (r.150542999) in UIDocumentPickerViewController/FileProvider which can end up making the first content/enumerate call to return that the directory is empty, only to have the second call return the correct data. If a directory returns as empty, you may want to retry just in case.

It works for the file bookmarks though, so the workaround I had in mind was to iterate through the whole hierarchy, create the corresponding mirroring tree of bookmarks, persist that tree and later on use file bookmarks from that tree, but that's quite awkward, besides it takes quite a while to build that tree for a large folder hierarchy.

You can do this, but I'm not sure I'd recommend it. This approach only captures the files that happened to exist at that point in time so you'll slow go "out of sync" with the true directory state as files are added and/or modified. In some cases, this actually end up creating more problems than it solves as your app slowly starts to look "wrong", instead of acknowledging that it doesn't have access to the actual directory.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

I figured out some further details:

  • when I get a file or folder URL from the picker I need to call startAccessingSecurityScopedResource on it first prior to converting it to bookmark. If I don't do that and try to convert that URL to bookmark I am seeing this error in the console:
[ERROR] getattrlist(<AppContainers>/7{34}3/D{7}s/2{8}2/N{2}1/0{8}7/1{6}3/2{69}0.png) = 1

however the call to make bookmark:

url!.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil)

doesn't return an error.

For a folder URL it's even less visible: no error from the call (same as for file URL) but also nothing in the console as a hint.

Later on (perhaps on a different lunch) when I read a bookmark (file or folder) I don't need to call startAccessingSecurityScopedResource on it (not sure if that's intentional behaviour or not).

I figured out some further details:

When I get a file or folder URL from the picker, I need to call startAccessingSecurityScopedResource on it first prior to converting it to a bookmark. If I don't do that and try to convert that URL to a bookmark, I am seeing this error in the console:

[ERROR] getattrlist(<AppContainers>/7{34}3/D{7}s/2{8}2/N{2}1/0{8}7/1{6}3/2{69}0.png) = 1

However, the call to make a bookmark: url!.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil) doesn't return an error.

Basically, yes, that all makes sense in terms of what's happening in the lower-level system. The situation isn't something I can easily describe, but the practical description is that the way the iOS's different file access control systems overlap means that your code can end up working that really "shouldn't" work.

A few quick suggestions here:

  • If it's feasible, I'd recommend testing your code as a "native" macOS app (not the simulator or in compatibility mode). macOS is better at enforcing the "right" behavior, so code that works there will generally work on iOS.

  • Make sure you test how things work after you reboot the device. One of the complicating factors here is that the kernel is what's tracking the access here, which can have unexpected side effects.

For a folder URL, it's even less visible: no error from the call (same as for a file URL) but also nothing in the console as a hint.

Can you post an example of the full error message? I'd like to see if I can figure out what actually errored.

Later on (perhaps on a different lunch) when I read a bookmark (file or folder) I don't need to call startAccessingSecurityScopedResource on it (not sure if that's intentional behaviour or not).

The word "intention" is tricky here. iOS has very few APIs that will "give" you access to files outside of the containers you directly control, so what's actually going on is that those apps always return a URL that's security scoped to your process. Similarly, "NSURLBookmarkCreationWithSecurityScope" isn't defined on iOS because the system doesn't really allow you to create bookmarks that DON'T have security scope*, at least not in the way macOS does. The original idea here was that this would basically be a "closed" system, where every URL you "got" always worked. We later added startAccessingSecurityScopedResource (primarily to support Catalyst), but the original system still exists, which is why you're able to access files even when you don't call startAccessingSecurityScopedResource.

*You might notice that NSURLBookmarkCreationWithoutImplicitSecurityScope DOES exist- that's actually there because many of our frameworks work by passing file paths "over" to other daemon's and this lets them pass a "bare" file reference over to the daemon (the daemon then gets access through SPI). However, what's missing here is the "base" bookmark that macOS creates when you don't pass in any option.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Kevin, thank you for your help and for the specific advices to check on macOS and to check after restart.

I created a small test bench app that allows testing most aspects of url bookmarks in an interactive manner. You may find that useful (attached):

It's a single file SwiftUI app that works on iOS and macOS.

If it's feasible, I'd recommend testing your code as a "native" macOS app (not the simulator or in compatibility mode). macOS is better at enforcing the "right" behavior, so code that works there will generally work on iOS.

Yeah, I noticed a few differences with the above test bench app. iOS seems to ignore withoutImplicitStartAccessing flag, but that's only after app restart (or device restart?). If I resolve the bookmark in the same session where I picked the file/folder it's a slightly different behaviour - when the withoutImplicitStartAccessing is off (that's default) there's one extra "access count" happening, i.e. if I resolve the bookmark N times I have to stop access N+1 times for it to stop working.

FTM, I found withoutImplicitStartAccessing flag (specifically being off by default and existing at all) is probably causing more harm than good. Do devs realise they need to call "stop access" after resolving bookmark? Explicit start/stop makes it more obvious what to do, and the ideal API would be something like "withAccess { callback }" that would automatically stop access when callback returns, or this:

URL.resolvingBookmarkData(data) { url, error in
    // url access is only valid here
}

Make sure you test how things work after you reboot the device. One of the complicating factors here is that the kernel is what's tracking the access here, which can have unexpected side effects.

Good to know! However it would be extremely helpful if we did have some (dev only?) means to simulate the effect without actual restart, or even better if it was just working correctly without the need of app or device restart to begin with.

BTW, is there no way to get a more meaningful app name on iOS, like the Files app itself is showing other than this bundleID form?

/private/var/mobile/Containers/Data/Application/F103656D-99AA-4131-B6FC-CCC9C71606D6/Documents

As a user I know the name as I've just seen it (and selected) in Files.

Cheers,

Do devs realise they need to call "stop access" after resolving a bookmark?

It's long been documented, but the reality is that:

  1. Most apps only interact with files in a limited/controlled way.

  2. Particularly on iOS, app lifetimes tend to be short enough that it hides most mistakes.

Explicit start/stop makes it more obvious what to do, and the ideal API would be something like "withAccess { callback }" that would automatically stop access when the callback returns, or this:

This works okay if/when file access is highly constrained, but it doesn't really work as well if you're working with a document over a very long period of time. More to the point, the APIs involved here are part of a LONG chain of evolution that keeps being expanded/tweaked/repurposed. For example, "bookmarks" weren't created to preserve security access; they were created to replace the Alias manager (from macOS Classic/Carbon). Similarly, security scoping in URLs is partially tied to file reference URLs (vs. file paths), but THOSE were really created as a replacement for FSRef's (since resolving a bookmark to a file path creates weird behavior). Both of those changes ultimately came from the fact that the expectation the Carbon file manager* created was so embedded into macOS that we basically HAD to create a new API that enabled the same functionality. All of that work was "done" before we ever really thought about security scope.

*As a new developer in the early-2000s, I used the File Manager JUST long enough to detest it. It took 15+ years later for me to realize how much it got "right" compared to every other file API in the world.

BTW, is there no way to get a more meaningful app name on iOS, like the Files app itself is showing other than this bundleID form?

Hmmm. I'm not sure. Two things you could try (I'd test this myself, but Xcode and I aren't getting along):

  1. Try both displayName(atPath:) (on the parent directories) and componentsToDisplay(forPath:). I'm confident those will do what you do on macOS, less so on iOS.

  2. You could take a look at the data returned by getFileProviderServicesForItem(at:completionHandler:), particularly when you're dealing with 3rd-party file services. It won't work for all cases, but it might be better than a UUID.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

It's long been documented

Specifically is it documented that we need to call stopAccessingSecurityScopedResource after resolvingBookmarkData that has no withoutImplicitStartAccessing in the options?

More to the point, the APIs involved here are part of a LONG chain of evolution that keeps being expanded/tweaked/repurposed ...

Thanks for those details.

Try both displayName(atPath:) (on the parent directories) and componentsToDisplay(forPath:).

These do some basic substitutions:

/private/var/mobile -> "User"
/private/var/     -> "System"+"var"
/private/         -> "System"+"private"
/                 -> "System"

but UUID is not converted:

... /Application/29CC76B4-074B-43CC-87DC-0E8122773FAA

You could take a look at the data returned by getFileProviderServicesForItem(at:completionHandler:),

I tested it with "on iPhone" storage - it returns something for "Application" directory:

"com.apple.FileProvider.ValidationV1" -> NSFileProviderService

this I could probably not use. At other levels (e.g. ".../Application/UUID" it returns "No valid file provider found from URL"

The mentioned API's were previously unknown to me.. In fairness I didn't expect them to be at the FileManager level, more at LaunchServices / NSWorkspace or similar..

I also tried specifying "includingResourceValuesForKeys" keys with the hope that'd help me revealing the bundleID name but wasn't successful with that so far.

Thanks anyway!

Specifically is it documented that we need to call stopAccessingSecurityScopedResource after resolvingBookmarkData that has no withoutImplicitStartAccessing in the options?

Yes. See "Persist file access with security-scoped URL bookmarks" as one example.

Also, to be clear, using "withoutImplicitStartAccessing" is EXCEEDINGLY rare, even within the system. I can't think of any reason why an app would do this, as you're essentially asking the system to give you a URL you can't use.

but UUID is not converted:

Please file a bug on this and post the bug number once it's filed. I'm not sure what the right answer here is (returning the app name has privacy implications) but this does feel like something we should have a better answer for.

I tested it with "on iPhone" storage - it returns something for "Application" directory:

...

this I could probably not use. At other levels (e.g. ".../Application/UUID" it returns "No valid file provider found from URL"

I don't think any of these values are directly usable, but it if it's critical to your apps overall experience, I think they might let you provide a bit more information.

I also tried specifying "includingResourceValuesForKeys" keys with the hope that'd help me revealing the bundleID name but wasn't successful with that so far.

Yeah, the information isn't attached to the files like that.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

BTW, is there no way to get a more meaningful app name on iOS, like the Files app itself is showing other than this bundleID form?

Are you starting from a document? You should be able to get the UTI of a document. If you ask for the localizedDescription, that might be what you're looking for.

Also, to be clear, using "withoutImplicitStartAccessing" is EXCEEDINGLY rare, even within the system. I can't think of any reason why an app would do this, as you're essentially asking the system to give you a URL you can't use.

There must be some reason... otherwise we wouldn't have withoutImplicitStartAccessing in the first place ;)

FWIW iOS UIDocumentPickerViewController provides you an URL for with startAccessingSecurityScopedResource has not been called yet.. I guess this is another difference between the two platforms.

Please file a bug

Will do shortly.

returning the app name has privacy implications

But so should be the case on macOS, no?

import SwiftUI

struct ContentView: View {
    var body: some View {
        let view = Button("Pick Folder") {
            #if os(macOS)
            macPickFolder()
            #else
            phoneShowPicker = true
            #endif
        }
        .buttonStyle(.borderedProminent)
        
        #if os(macOS)
        return view
        #else
        return view.sheet(isPresented: $phoneShowPicker) {
            PhoneFilePicker(isFolder: true) { url in
                if let url {
                    printUrl(url)
                }
            }
        }
        #endif
    }
    
    #if os(macOS)
    func macPickFolder() {
        let panel = NSOpenPanel()
        panel.directoryURL = URL.libraryDirectory.appendingPathComponent("Containers")
        panel.allowsMultipleSelection = false
        panel.canChooseDirectories = true
        panel.canChooseFiles = true
        panel.allowedContentTypes = [.folder]
        if panel.runModal() == .OK {
            if let url = panel.url {
                printUrl(url)
            }
        }
    }
    #else
    @State private var phoneShowPicker = false
    #endif
    
    func printUrl(_ url: URL) {
        // NOTE: on mac it's possible to select the app container itself
        // and on iOS it is not, you could only select the "Documents" folder inside
        // making the test consistent, so that even on macOS if you select "Data"
        // inside the app container it will work the same way as on iOS.
        var url = url
        if (url.lastPathComponent == "Data") || (url.lastPathComponent == "Documents") {
            url = url.deletingLastPathComponent()
        }
        
        let result = url.startAccessingSecurityScopedResource()
        print("startAccessingSecurityScopedResource result:", result)
        defer { url.stopAccessingSecurityScopedResource() }
        
        let values = try! url.resourceValues(forKeys: [.nameKey, .localizedNameKey])

        print("url:", url)
        print("name:", values.name ?? "nil")
        print("localizedName:", values.localizedName ?? "nil")
    }
}

#Preview {
    ContentView()
}

#if !os(macOS)
struct PhoneFilePicker: UIViewControllerRepresentable {
    let isFolder: Bool
    var onPick: (URL?) -> Void

    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forOpeningContentTypes: [isFolder ? .folder : .item], asCopy: false)
        picker.delegate = context.coordinator
        picker.allowsMultipleSelection = false
        return picker
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(onPick: onPick)
    }

    class Coordinator: NSObject, UIDocumentPickerDelegate {
        var onPick: (URL?) -> Void
        init(onPick: @escaping (URL?) -> Void) {
            self.onPick = onPick
        }
        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            onPick(urls.first!)
        }
        func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            onPick(nil)
        }
    }
}
#endif

@main struct TestApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

Example output on macOS:

 url: file:///Users/user/Library/Containers/com.apple.AboutThisMacLauncher/
 name: com.apple.AboutThisMacLauncher
 localizedName: About This Mac

on iOS:

 url: file:///private/var/mobile/Containers/Data/Application/2A6A7975-6CE1-40F0-B7D0-18A6E64C0D89/
 name: 2A6A7975-6CE1-40F0-B7D0-18A6E64C0D89
 localizedName: 2A6A7975-6CE1-40F0-B7D0-18A6E64C0D89

Are you starting from a document? You should be able to get the UTI of a document. If you ask for the localizedDescription, that might be what you're looking for.

Etresoft, I am not sure I follow, please explain.


PS. I'm not sure if this is important or not: compared to mac where I could select the container folder itself on iPhone I could only select the Documents folder inside... - the "File" UI just works that way... Which means I have to deleteLastPathComponent to get to the app URL... but that URL was not exactly the one user gave permission for.. Maybe because of that localizedName doesn't work? (despite it works on Mac after stripping the trailing "Data" off the url, but at this point I'd be not surprised if that's yet another difference between the platforms).

Etresoft, I am not sure I follow, please explain.

I'm not sure I follow your question either. It seems unrelated to your overall question. I've just been following this thread due to some similar confusion I've had with these APIs on macOS.

If you have a URL to a document. You can use the UniformTypeIdentifiers API to get the rich(er) type information for the document. This gives you things like extension, MIME type, and the UTI (public.jpg, public.text, com.adobe.pdf, etc.)

You referenced the Files app. The Files app will also give you something like "Pages document", which is another field that developers can specify when they create an exported document type.

But then you're asking about apps specifically, so I'm not sure if this is what you're looking for. Apple considers installed apps to be privacy-sensitive. I've heard that there are some methods to get this information, but it puts your app, and you, personally, at risk. I wouldn't recommend going down that path. If you want to get a human-readable description of a document, which may include the app used to create it, the UniformTypeIdentifiers API should do that for you. If you're interested in inspecting other apps on iOS, you're on your own.

There must be some reason... otherwise we wouldn't have withoutImplicitStartAccessing in the first place ;)

First off, as a clarification, "withoutImplicitStartAccessing" does NOT mean "don't call startAccessingSecurityScopedResource" for me. To quote the documentation:

"Bookmarks that you create without security scope automatically carry implicit ephemeral security scope." ...

"When using this option, other processes can’t call startAccessingSecurityScopedResource on the resolved URL."

Behind the scenes, what actually happens when you resolve a bookmark is:

  • You pass the bookmark data to a daemon ("ScopedBookmarkAgent").

  • The daemon validates the security scope, resolves the bookmark, then passes the resolved URL back to you.

...and "implicit ephemeral security scope" is what then allows your app to call startAccessingSecurityScopedResource on the URL it just got. Your app then calls "startAccessingSecurityScopedResource" to take "ownership" of the resource. The open/save panel works exactly the same way, except they happen to call "startAccessingSecurityScopedResource" before returning the URL to you.

In terms of the reason it's public, they are:

  • There are specific places where the system wants to pass a URL over to an app, but be sure that the URL it gives the app WON'T also give the app access to that resource. For example, all NSWorkspace "urlForApplication(...)" methods. Most of the time, the app will already have access (because the app is in "/Applications/"), but Launch services doesn't want to have to worry/predict all of the "oddball" edge cases.

  • We want to use the same public API because having a duplicate SPI that's ALMOST the same is a pain.

  • We could keep the flag secret/private, but that opens the door to developers complaining because they passed the flag in by accident and things broke "mysteriously". If the flag is public, then there isn't any mystery.

  • It's vaguely possible SOMEONE might have the same need as the system does, so why not make it public?

FWIW, iOS UIDocumentPickerViewController provides you with a URL for startAccessingSecurityScopedResource has not been called yet. I guess this is another difference between the two platforms.

Yes.

But so should be the case on macOS, no?

It is, but that doesn't mean there's a good solution. The interaction model of iOS and macOS are basically the polar opposites of each other. On iOS, you start with "apps" and those apps then manage their own files. On macOS, you "start" with the file system and then supply files to apps. Think about the main way you open files on macOS— it isn't through the open/save panels, it's by double-clicking on the Finder or by dragging onto the app.

On macOS, the app sandbox does constrain how much of the file system an app can "see“; however, we can't really do anything about the fact that the overall filesystem is more "human readable". It's that way because that's how it has to work.

Which means I have to deleteLastPathComponent to get to the app URL... but that URL was not exactly the one user gave permission for. Maybe because of that, localizedName doesn't work?

No, this doesn't matter. The API just isn't "wired" up the way it would need to be.

But then you're asking about apps specifically, so I'm not sure if this is what you're looking for.

The issue he's looking at here is how an app communicates to the user which of the user’s files the user is interacting with. On macOS, this is handled by showing the user the path to the file, but that doesn't work on iOS because our UUID usage means the paths aren't usable.

Apple considers installed apps to be privacy-sensitive.

We do, but that doesn't mean we don't need to solve the underlying problem. For example, one solution would be to allow the app to push "back" to Files.app*, providing the same functionality as "Show in Finder". In any case, please file a bug on this and post the bug number back here.

*Strictly speaking, this is doable today, but the URL scheme is not documented, so it shouldn't be used.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

First off, as a clarification, "withoutImplicitStartAccessing" does NOT mean "don't call startAccessingSecurityScopedResource" for me.

It feels you are talking here about withoutImplicitSecurityStore creation option instead of withoutImplicitStartAccessing resolving option..

In my tests withoutImplicitStartAccessing does pretty much what is says on the tin. For example:

  1. create a bookmark (without specifying neither withoutImplicitSecurityStore nor withSecurityScope

  2. persist it somehow (the above test app writes it to user defaults)

  3. restart the app

  4. resolve bookmark specifying withoutImplicitStartAccessing

  5. access the url - that would fail.

  6. call startAccessingSecurityScopedResource on it - it returns true

  7. access the url again - that will now work

  8. if instead of 4 above you resolve the bookmark without specifying withoutImplicitStartAccessing, then

  9. access the url - that would work right away.

Behind the scenes, what actually happens when you resolve a bookmark is

Thanks a lot for those details, it's really helpful to see how it works behind the scenes.

For example, one solution would be to allow the app to push "back" to Files.app*, providing the same functionality as "Show in Finder". In any case, please file a bug on this and post the bug number back here.

*Strictly speaking, this is doable today, but the URL scheme is not documented, so it shouldn't be used.

You mean it's doable to get the app name from the Application/2A6A7975-6CE1-40F0-B7D0-18A6E64C0D89/ url via some undocumented means?


Filed the radars:

FB19996364 - "iOS and macOS: bookmarkData expected to fail but it doesn't"

FB19995901 - "iOS: The app name returned from file/folder picker API is cryptic"

FB19995978: "iOS: I could select "On My iPhone" folder in Files.app for the issue discussed in another thread

iOS folder bookmarks
 
 
Q