Persist Access to User Folders Across Launches (Part 2)

Discover how to persist user's Mac folders and files permissions across app launches in an app with Sandbox enabled in Xcode.

Francesco Leoni

6 min read

In Part 1 we saw how to use the sandbox and how to ask the user permission to read from and write to folders outside the app container.

In this article we will see how to persist the user's permissions across app launches.

Since, once the user kills the app, these permissions are erased.

So, without further ado, let's begin!

Steps

Let's summarise the main step of the process.

  1. Ask the user which folder he wants to save the file in
  2. Create and persist bookmark data for the folder URL the user selected
  3. Make the resource referenced by the url accessible to the process
  4. Perform read/write operations
  5. Revoke the access granted to the url

Create and persist bookmark

As we saw in Part 1, once the user have selected the folder we create and persists its bookmark data in UserDefaults.

func persistBookmark(url: URL) throws -> Data {

let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)

UserDefaults.standard.set(bookmarkData, forKey: key(for: url))

return bookmarkData

}

// Helper function that creates the bookmark key

func key(for url: URL) -> String {

String(format: "bd_%@", url.absoluteString)

}

The withSecurityScope key specifies that you want to create a security-scoped bookmark that, when resolved, provides a security-scoped URL allowing read/write access to a file-system resource.

Access URL resources

Now, we can make the resource referenced by the url accessible to the process.

First we create a struct that holds our URL, bookmarkData and permissions.

struct AccessInfo {

public var resolvedUrl: URL?

public var bookmarkData: Data?

public var permissions: Permissions

static let empty = AccessInfo(permissions: .none)

}

Let's start we retrieving fresh bookmark data.

func bookmarkData(for url: URL) -> Data? {

var folderUrl = url

while !folderUrl.path.isEmpty {

if let bookmark = UserDefaults.standard.data(forKey: key(for: folderUrl)) {

return bookmark

}

folderUrl = folderUrl.deletingLastPathComponent()

}

return nil

}

func bookmarkInfo(for url: URL) -> AccessInfo {

// 1.

guard let oldBookmarkData = bookmarkData(for: url) else {

return .empty

}

do {

var bookmarkIsStale: Bool = false

// 2.

let resolvedUrl = try URL(resolvingBookmarkData: oldBookmarkData,

options: [.withSecurityScope, .withoutUI],

relativeTo: nil,

bookmarkDataIsStale: &bookmarkIsStale)

// 3.

if bookmarkIsStale {

clearBookmarkData(for: url)

let newBookmarkData = try persistBookmark(url: resolvedUrl)

// 4.

return AccessInfo(resolvedUrl: resolvedUrl, bookmarkData: newBookmarkData, permissions: .none)

} else {

// 4.

return AccessInfo(resolvedUrl: resolvedUrl, bookmarkData: oldBookmarkData, permissions: .none)

}

} catch {

print("Unable to resolve the bookmark data into a URL.")

return .empty

}

}

In bookmarkData(for:) we retrieve bookmark data for the folder the user have selected or for a parent folder, since if the user granted us permission to access the parent folder, we have the implicit permission to access each child folder.

Then in bookmarkInfo(for:) we:

  1. Get the bookmark data for the url or parent path if we have it
  2. Resolve the bookmark data into a URL object that will allow us to use the file
  3. If the bookmark data is stale we'll attempt to recreate it with the existing url
  4. Return the resolved url and the old or fresh bookmark data

Check permissions

Then we check which permission we have on that specific URL.

First, let's create the OptionSet that will define the Permissions we can have.

struct Permissions: OptionSet {

let rawValue: Int

static let bookmark = Permissions(rawValue: 1 << 0)

static let darwinReadOnly = Permissions(rawValue: 1 << 1)

static let darwinboxReadWrite = Permissions(rawValue: 1 << 2)

static let none: Permissions = []

static let readOnly: Permissions = [.bookmark, .darwinboxReadOnly]

static let readWrite: Permissions = [.bookmark, .darwinboxReadWrite]

var canRead: Bool {

self.contains(.bookmark) || self.contains(.darwinboxReadOnly)

}

var canWrite: Bool {

self.contains(.bookmark) || self.contains(.darwinboxReadWrite)

}

init(rawValue newRawValue: Int) {

rawValue = newRawValue

}

func matches(permissions: Permissions) -> Bool {

if permissions == .none {

return true

}

return !self.intersection(permissions).isEmpty

}

}

And then the modes we will use to check for low level permissions.

enum DarwinAccess {

enum AccessMode {

case readOnly

case readWrite

var permission: Int32 {

switch self {

case .readOnly: return R_OK

case .readWrite: return (ROK | WOK)

}

}

}

static func canAccess(url: URL, mode: AccessMode) -> Bool {

let path = url.path as NSString

return Darwin.access(path.fileSystemRepresentation, mode.permission) == 0

}

}

Then we get the bookmarkInfo(for:) and check the Permissions.

func accessInfo(for url: URL) -> AccessInfo {

// 1.

let standardizedFileURL = url.standardizedFileURL.resolvingSymlinksInPath()

// 2.

var accessInfo = bookmarkInfo(for: standardizedFileURL)

// 3.

if accessInfo.resolvedUrl != nil {

accessInfo.permissions.insert(.bookmark)

}

// 4.

if DarwinAccess.canAccess(url: url, mode: .readOnly) {

accessInfo.permissions.insert(.darwinReadOnly)

}

if DarwinAccess.canAccess(url: url, mode: .readWrite) {

accessInfo.permissions.insert(.darwinboxReadWrite)

}

return accessInfo

}

In the function above we:

  1. Standardize the file url and remove any symlinks
  2. If url bookmark data are stored, then we'll get the resolvedUrl and its associated bookmarkData
  3. If the resolvedUrl is not nil, it means that we successfully resolved the bookmarkData and we got back the URL with read/write permissions
  4. Check if the OS let us read/write, this properties we will come into play only if resolvedUrl is nil

Access the url

Finally, we can put all together and check if the user granted us the permissions we need.

func access(url: URL, permissions: Permissions, performManipulation: (URL) throws -> Void) throws {

// 1.

let accessInfo = accessInfo(for: url)

// 2.

if accessInfo.permissions.matches(permissions: permissions) {

// 3.

try secureAccess(resolvedURL: accessInfo.resolvedURL, fallbackUrl: url, performManipulation: performManipulation)

} else {

// 4.

throw PermissionError.needToAskPermission(accessInfo)

}

}

  1. We get the AccessInfo for the requested URL
  2. Then, we check if the permission the user granted us matches the permissions we need
  3. If the permissions matches, we can access the URL
  4. Else we throw an error

func secureAccess(resolvedURL: URL?, fallbackUrl: URL, performManipulation: (URL) throws -> Void) throws {

guard let resolvedURL = url else {

// 1.

try performManipulation(fallbackUrl)

return

}

// 2.

if resolvedURL.startAccessingSecurityScopedResource() {

// 3.

try performManipulation(fallbackUrl)

resolvedURL.stopAccessingSecurityScopedResource()

} else {

// 4.

throw PermissionError.unexpectedlyUnableToAccessBookmark(resolvedURL)

}

}

  1. We don't have the resolvedURL but we do meet the permission so we can perform the URL manipulation without accessing the security scoped resource.
  2. We access the security scoped resource and perform read/write operation on the URL
  3. Here we pass the fallbackUrl that is the original url the user has selected. This is done because the resolvedUrl can be a parent of the selected url
  4. Throw an error in case we are not able to access the security scoped resource of the URL

Usage

do {

let selectedFolder = openPanel()

try persistBookmark(url: selectedFolder)

try access(url: selectedFolder, permissions: .readWrite) { url in

data.write(to: url)

}

} catch {

print(error)

}

Conclusion

This is all I understood about read/write operations with Sandboxed apps.

The App Sandbox is a really important feature provided by Apple to keep the users' data safe and many developers don't know it's there until they need to write outside the app container.

So, I hope this guides helped you better understand what it is and how to use it.

If you already know this topic and have improvements to give about the article, I will be very thankful to add your suggestions.

If you have any question about this article, feel free to email me or tweet me @franceleonidev and share your opinion.

Thank you for reading and see you in the next article!

Share this article

Related articles


Read and Write Data in a Sandboxed App (Part 1)

Discover how to get read and write permission to user's Mac folders and files in an app with Sandbox enabled.

4 min read

MacOSFile system

Combine CoreData and SwiftUI

See how to use CoreData database with SwiftUI. Syncing changes from CoreData to every View of your app.

5 min read

SwiftUICoreData

How to Create Swift Macros with Xcode 15

Discover the new Swift Macro, speed up your workflow and make your code easier to read. Available for Swift 5.9 and Xcode 15.

8 min read

Swift