Files App Share Context with Security scoped resource fails

I'm creating an App that can accepted PDFs from a shared context. I am using iOS, Swift, and UIKit with IOS 17.1+

The logic is:

  • get the context
  • see who is sending in (this is always unknown)
  • see if I can open in place (in case I want to save later)
  • send the URL off to open the (PDF) document and
  • load it into PDFKit's pdfView.document

I have no trouble loading PDF docs with the file picker.

And everything works as expected for shares from apps like Messages, email, etc... (in which case URLContexts.first.options.openInPlace == False)

The problem is with opening (sharing) a PDF that is sent from the Files App. (openInPlace == True)

If the PDF is in the App's Document Folder, I need the Security scoped resource, to access the URL from the File's App so that I can copy the PDF's data to the PDFViewer.document. I get Security scoped resource access granted each time I get the File App's context URL. But, when I call fileCoordinator.coordinate and try to access a file outside of the App's document folder using the newUrl, I get an error.

FYI - The newUrl (byAccessor) and context url (readingItemAt) paths are always same for the Files App URL share context.

I can, however, copy the file to a new location in my apps directory and then open it from there and load in the data. But I really do not want to do that.

. . . . .

Questions:

Am I missing something in my pList or are there other parameters specific to sharing a file from the Files App?

I'd appreciate if someone shed some light on this?

. . . . .

Here are the parts of my code related to this with some print statements...

. . . . .

SceneDelegate

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    
    // nothing to see here, move along
    guard let urlContext = URLContexts.first else {
        print("No URLContext found")
        return
    }
    
    // let's get the URL (it will be a PDF)
    let url = urlContext.url
    let openInPlace = urlContext.options.openInPlace
    let bundleID = urlContext.options.sourceApplication
    print("Triggered with URL: \(url)")
    print("Can Open In Place?: \(openInPlace)")
    print("For Bundle ID:      \(bundleID ?? "None")")
    
    // get my Root ViewController from window
    if let rootViewController = self.window?.rootViewController {
        
        // currently using just the view
        if let targetViewController = rootViewController as? ViewController {
            targetViewController.prepareToLoadSharedPDFDocument(at: url)
        }

        // I might use a UINavigationController in the future
        else if let navigationController = rootViewController as? UINavigationController,
                let targetViewController = navigationController.viewControllers.first as? ViewController {
            targetViewController.prepareToLoadSharedPDFDocument(at: url)
        }
    }
    
}

. . . .

ViewController function

I broke out the if statement for accessingScope just to make it easier for me the debug and play around with the code in accessingScope == True

    func loadPDF(fromUrl url: URL) {
        
        // If using the File Picker / don't use this        
        // If going through a Share.... we pass the URL and have three outcomes (1, 2a, 2b)
        
        // 1. Security scoped resource access NOT needed if from a Share Like Messages or EMail
        
        // 2. Security scoped resource access granted/needed from 'Files' App
        //    a. success if in the App's doc directory
        //    b. fail if NOT in the App's doc directory

        // Set the securty scope variable
        var accessingScope = false
        
        // Log the URLs for debugging
        print("URL String: \(url.absoluteString)")
        print("URL Path:   \(url.path())")
        
        // Check if the URL requires security scoped resource access
        if url.startAccessingSecurityScopedResource() {
            accessingScope = true
            print("Security scoped resource access granted.")
        } else {
            print("Security scoped resource access denied or not needed.")
        }
        
        // Stop accessing the scope once everything is compeleted
        defer {
            if accessingScope {
                url.stopAccessingSecurityScopedResource()
                print("Security scoped resource access stopped.")
            }
        }
        
        // Make sure the file is still there (it should be in this case)
        guard FileManager.default.fileExists(atPath: url.path) else {
            print("File does not exist at URL: \(url)")
            return
        }
        
        // Let's see if we can open it in place
        
        if accessingScope {
            
            let fileCoordinator = NSFileCoordinator()
            var error: NSError?
            fileCoordinator.coordinate(readingItemAt: url, options: [], error: &error) { (newUrl) in
                DispatchQueue.main.async {
                    print(url.path())
                    print(newUrl.path())
                    if let document = PDFDocument(url: newUrl) {
                        self.pdfView.document = document
                        self.documentFileName = newUrl.deletingPathExtension().lastPathComponent
                        self.fileLoadLocation = newUrl.path()
                        self.updateGUI(pdfLoaded: true)
                        self.setPDFScale(to: self.VM.pdfPageScale, asNewPDF: true)
                    } else {
                        print("Could not load PDF directly from url: \(newUrl)")
                    }
                }
            }
            if let error = error {
                PRINT("File coordination error: \(error)")
            }
            
        } else {
            
            DispatchQueue.main.async {
                if let document = PDFDocument(url: url) {
                    self.pdfView.document = document
                    self.documentFileName = url.deletingPathExtension().lastPathComponent
                    self.fileLoadLocation = url.path()
                    self.updateGUI(pdfLoaded: true)
                    self.setPDFScale(to: self.VM.pdfPageScale, asNewPDF: true)
                } else {
                    PRINT("Could not load PDF from url: \(url)")
                }
            }
        }
    }

. . . .

Other relevant pList settings I've added are:

  • Supports opening documents in place - YES
  • Document types - PDFs (com.adobe.pdf)
  • UIDocumentBrowserRecentDocumentContentTypes - com.adobe.pdf
  • Application supports iTunes file sharing - YES

And iCloud is one for Entitlements with

  • iCloud Container Identifiers
  • Ubiquity Container Identifiers

. . . .

Thank you in advance!.

  • B

A few questions/comments:

-What's the error you're getting from the coordinate call?

-As a general comment, I think trying to implement file coordination "yourself" is generally a mistake. On the surface it seems like an easier option than implementing UIDocument, but in practice the file coordination process is harder to understand than it looks and using UIDocument doesn't have to mean restructuring your entire implementation around it.

-In terms of your specific implementation, you can't shift the file access out of your coordinated read as your code is doing. Any access to the file needs to be inside that file coordinated read.

Finally, I really want to push back on this point:

I can, however, copy the file to a new location in my apps directory and then open it from there and load in the data. But I really do not want to do that

Why not? APFS file cloning means that the copy itself is basically "free". If you're modifying the file, then safe-save semantics mean that you'd be writing the file to a different location, then doing an atomic replace to complete the save. Copying the file first just means you've shifted the save copy a bit. If you'll only be reading the file, then that copy may not be necessary, however, working out of your own copy makes edge cases like the file being deleted/modified/removed from the local device much easier to sort out, even if you're just going to match your behavior to the original file.

-Kevin Elliott
DTS Engineer, CoreOS/Hardware

First off, a warning on ANY usage of URL.path() and string-based paths in general. Basically, the existence of string paths in our API is a fundamental design mistake we made 25+ years ago and have been paying for ever since. The issue isn't all that visible because actually relying on normalization sensitivity is a fairly terrible idea, not because the system actually handles this case properly. In addition, there are places in our API like file iteration where the "path" variant is MUCH worse than the URL equivalent.

In any case, I recommend avoiding string paths wherever possible.

Kevin, this is helpful information. Just to make sure I'm reading you correctly, is it a valid approach to "copy" the file into your sandboxed app directory (e.g. the app's documents folder) to Quick Look, and then remove it once you're done with Quick Look?

Yes, but with the qualifiers:

  • Things get a lot more complicated if/when you're crossing volumes, something which isn't necessarily "obvious" without a lot of additional checking.

  • You can only do what a given extension point allows. Virtually all of our extension points allow the extension to write SOMEWHERE (which would allow copying), but the specifics do vary by extension point.

More details on the "yes" follow at the end...

Moving to here:

Not to hijack this thread, but I am facing a similar security scoping issue on visionOS trying to set up a PreviewApplication on a file URL outside the sandbox.

...with a second look, I see a few problems with your code. You never said what error you're getting, but I suspect it's caused by passing an empty array in "options". Here's what the documentation says about that:

options

One of the reading options described in NSFileCoordinator.ReadingOptions. If you pass no options, the savePresentedItemChanges(completionHandler:) method of relevant file presenters is called before your block executes.

Secondly, your code here is wrong:

fileCoordinator.coordinate(readingItemAt: url, options: [], error: &error) { (newUrl) in
	DispatchQueue.main.async {
		//Do I/O...
		...
	}
}

The point of the file coordination block is that it's the point where your app is expected to do the I/O it's asking to do. What your code is actually doing is scheduling the code that actually does the work on the main thread, then immediately returning without doing any work. Note that, in the worst case (think very slow SMB volume), this means that you're now blocking the main thread on a long, slow I/O request, potentially crashing yourself.

In terms of what you do about that, I see two approaches:

  1. For files on the local file system, I would load the data using Data.init(contentsOfURL:options:) and the mappedIfSafe if safe option, then use PDFDocument.init(data:) to create the object you need.

  2. Copy the file onto your local storage and display the copy.

I could argue for either one of these as the "right" choice; however, the more I think about this, the more I lean toward just using #2. The issue here is that the main benefit over #1 is (in theory) "speed“, however...

  • In the best case (small PDF on local storage), file cloning is so fast that the difference between #1 and #2 is small enough that it would be very, very difficult to measure.

  • In the worst case (large PDF on very slow media), #1 just doesn't really work. You'll either crash/hang or end up with either interface glitches which are difficult to predict and fix.

  • There's a middle case (large PDF on spinning media) where #1 probably would work better than #2, since it allows immediate display and I/O performance is good enough that you don't notice it. However, the middle case is difficult enough to predict that it's not something you can really code "for".

  • In the REALLY worst case, you'll lose your connection to the backing storage (smb server drops, USB cable pulled, etc.) while you're in the middle of displaying the data. Even worse, this case is very likely to "track" with the worst case above. That is, the larger a PDF is, the longer they're likely to spend looking at it, creating more opportunity for the connection to drop, and the more frustrated the user is going to be that this failed (because the longer a PDF is, the more likely it is to be "important" to the user).

In other words, the benefit of #1 is someone marginal and the downside risk of it is pretty significant.

Returning to your question here:

Is it a valid approach to "copy" the file into your sandboxed app directory (e.g. the app's documents folder) to Quick Look, and then remove it once you're done with Quick Look?

Yes, I think that's probably the best option.

More specifically, I'd put the interface in place to cover the situation where it might take “a while", potentially even using copyfile() so you can provide data-level progress. I'd also suggest using the "Caches" directory of your Quick Look extensions container so that it's not "visible" to your main application and so that the system will delete it automatically if your Quick Look extension fails to.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Files App Share Context with Security scoped resource fails
 
 
Q