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.
• 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.
- Ask the user which folder he wants to save the file in
- Create and persist bookmark data for the folder URL the user selected
- Make the resource referenced by the url accessible to the process
- Perform read/write operations
- 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:
- Get the bookmark data for the url or parent path if we have it
- Resolve the bookmark data into a URL object that will allow us to use the file
- If the bookmark data is stale we'll attempt to recreate it with the existing url
- 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:
- Standardize the file url and remove any symlinks
- If url bookmark data are stored, then we'll get the
resolvedUrl
and its associatedbookmarkData
- If the
resolvedUrl
is notnil
, it means that we successfully resolved thebookmarkData
and we got back theURL
with read/write permissions - 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)
}
}
- We get the
AccessInfo
for the requestedURL
- Then, we check if the permission the user granted us matches the permissions we need
- If the permissions matches, we can access the
URL
- 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)
}
}
- 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.
- We access the security scoped resource and perform read/write operation on the
URL
- Here we pass the
fallbackUrl
that is the original url the user has selected. This is done because theresolvedUrl
can be a parent of the selected url - 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