The file operator is the biggest file in the AutoShelf codebase at 873 lines. It is an actor that handles moving, copying, trashing, renaming, tagging, archiving, optimizing, converting, and importing files to Photos.
Every one of these operations has to work inside a Mac App Store sandbox. That constraint shaped the entire design.
The Sandbox Reality
Mac App Store apps cannot access arbitrary files. They can only access:
- Files the user explicitly picks with an NSOpenPanel or NSSavePanel
- Files in specific known locations (Downloads, Documents, Desktop) if the app declares usage descriptions
- Files inside the app's own container
For AutoShelf, folder access is the core requirement. The user picks a folder, the app remembers it, and later it moves files in and out of that folder. This is exactly what security-scoped bookmarks are designed for.
When the user picks a folder with NSOpenPanel, the app gets a security-scoped bookmark. This bookmark is a Data blob that can be saved to disk and used later to regain access to that folder, even after the app restarts.
Here is how the bookmark is saved:
guard let url = try await folderPicker() else { return }
let bookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
// Save bookmarkData to UserDefaults or a JSON file
And how it is later resolved:
private func resolveAndAccess(_ bookmarkData: Data) -> URL? {
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else { return nil }
guard url.startAccessingSecurityScopedResource() else { return nil }
return url
}
The startAccessingSecurityScopedResource() call is critical. Without it, the sandbox denies file access even though you have a valid bookmark. You also need to call stopAccessingSecurityScopedResource() when you are done, or the system leaks kernel resources.
Why the File Operator Is an Actor
File operations are inherently concurrent. The user might have two rules that trigger at the same time, or the same rule might match two files simultaneously. Without proper isolation, you could get race conditions where two operations try to modify the same file.
Swift actors solve this perfectly. An actor serializes all access to its mutable state and ensures that only one operation runs at a time.
actor FileOperator {
func execute(action: RuleAction, on fileURL: URL) async throws -> FileOperationResult {
switch action {
case .moveTo(let bookmarkData):
return try await moveToFolder(bookmarkData, file: fileURL)
case .trash:
return try await trashFile(fileURL)
case .rename(let pattern):
return try await renameFile(fileURL, pattern: pattern)
// ... 10 more cases
}
}
}
Each method is an async throws function that returns a description of what happened and the resulting file URL. The caller (AutoShelfEngine) logs this to the activity store.
Handling Name Collisions
When a file is moved or copied to a destination folder, there might already be a file with the same name. I handle this by appending a number:
private func uniqueURL(for destination: URL) -> URL {
let fm = FileManager.default
guard fm.fileExists(atPath: destination.path) else { return destination }
let base = destination.deletingPathExtension().lastPathComponent
let ext = destination.pathExtension
let dir = destination.deletingLastPathComponent()
for i in 1...999 {
let newName = ext.isEmpty ? "\(base) \(i)" : "\(base) \(i).\(ext)"
let newURL = dir.appendingPathComponent(newName)
if !fm.fileExists(atPath: newURL.path) {
return newURL
}
}
return destination
}
999 attempts should be enough for anyone. If you have 1000 files with the same name in one folder, you have a bigger problem than name collisions.
The Image Optimization Pipeline
One of the Pro features is image optimization. The user can choose a quality level (lossless through extreme) and whether to strip metadata. The implementation uses Core Graphics:
private func optimizeImage(at url: URL, settings: ImageOptimizationSettings) throws -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let uti = CGImageSourceGetType(source) ?? "public.png" as CFString
let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(data, uti, 1, nil) else { return nil }
let properties: NSDictionary = [
kCGImageDestinationLossyCompressionQuality: settings.jpegLevel.jpegQuality ?? 1.0
]
CGImageDestinationAddImageFromSource(destination, source, 0, properties)
guard CGImageDestinationFinalize(destination) else { return nil }
return data as Data
}
The tricky part is that the optimized file can sometimes be larger than the original. JPEG re-encoding, in particular, can increase file size if the original was already highly compressed. I always compare the output size against the input size and skip the operation if it did not actually save space.
Converting Media Files
Media conversion was the hardest set of actions to implement. Video and audio conversion use AVFoundation, which has its own sandbox constraints.
For video conversion:
private func convertVideo(at sourceURL: URL, to format: String, codec: String, quality: String) async throws -> URL {
let asset = AVAsset(url: sourceURL)
guard let session = AVAssetExportSession(asset: asset, presetName: preset) else {
throw ConversionError.invalidConfiguration
}
let outputURL = tempURL(withExtension: format)
session.outputURL = outputURL
session.outputFileType = format == "mp4" ? .mp4 : .mov
session.videoComposition = videoComposition // handles codec selection
await session.export()
// Check session.status for success/failure
}
The export session writes to a temporary directory within the sandbox container. Then the file operator moves the result to the user's chosen destination using the security-scoped bookmark.
Photos import uses PHPhotoLibrary and requires the com.apple.security.personal-information.photos-library entitlement. The authorization flow is:
- Request
.addOnlyauthorization - If denied, show an alert directing the user to System Settings
- Create a
PHAssetChangeRequestwith the file URL - Call
PHPhotoLibrary.shared().performChanges
Only images and videos are supported for Photos import. Other file types are silently skipped.
ZIP Archiving
ZIP archiving is the only operation that requires an external dependency. I use ZIPFoundation, a Swift package that adds zip/unzip capabilities to FileManager.
private func archiveToZip(at sourceURL: URL, destinationDir: URL) throws -> URL {
let zipURL = destinationDir.appendingPathComponent(sourceURL.lastPathComponent + ".zip")
try FileManager.default.zipItem(at: sourceURL, to: zipURL)
try FileManager.default.removeItem(at: sourceURL)
return zipURL
}
The file is zipped and the original is deleted. The user gets a clean archive without the source file cluttering the folder.
Error Handling Philosophy
File operations fail. The disk is full. The file is locked. The sandbox denies access. The network drive disconnected.
Every operation wraps its work in a do-catch and returns a meaningful error message instead of crashing. The activity store logs the failure so the user can see what went wrong. The engine continues processing other files even if one operation fails.
I also compare file sizes before and after optimization. If the optimized version is larger, I keep the original. This prevents the frustrating experience of your files getting bigger instead of smaller.