Security-Scoped URLs

Security-scoped URLs are a cornerstone of macOS App Sandbox security. RawCull uses them to gain persistent, user-approved access to source and destination folders while remaining fully sandbox-compliant. This article walks through exactly how the implementation works, tracing the code from user interaction through to file operations.


What Are Security-Scoped URLs?

A security-scoped URL is a special file URL that carries a cryptographic capability granted by macOS, representing explicit user consent to access a specific file or folder. Without it, a sandboxed app cannot read or write anything outside its own container.

Key properties:

  • Created only from user-granted file access (file picker, drag-and-drop)
  • Grants temporary access to files outside the app sandbox
  • Must be explicitly activated (startAccessingSecurityScopedResource()) before use and deactivated (stopAccessingSecurityScopedResource()) after
  • Can be serialized as a bookmark — a persistent token stored in UserDefaults that survives app restarts

Core API:

// Activate access — must be called before any file operations on the URL
let granted = url.startAccessingSecurityScopedResource()  // returns Bool

// Deactivate — must always be paired with a successful start call
url.stopAccessingSecurityScopedResource()

// Serialize to persistent bookmark data
let bookmarkData = try url.bookmarkData(
    options: .withSecurityScope,
    includingResourceValuesForKeys: nil,
    relativeTo: nil
)

// Restore from bookmark (across app launches)
var isStale = false
let restoredURL = try URL(
    resolvingBookmarkData: bookmarkData,
    options: .withSecurityScope,
    relativeTo: nil,
    bookmarkDataIsStale: &isStale
)

Architecture in RawCull

RawCull’s security-scoped URL system has three distinct layers, each with a specific responsibility.


Layer 1 — Initial User Selection (OpencatalogView)

OpencatalogView presents the macOS folder picker using SwiftUI’s .fileImporter() modifier. When the user selects a folder, the resulting URL is a short-lived security-scoped URL. The view immediately converts it into a persistent bookmark.

File: RawCull/Views/CopyFiles/OpencatalogView.swift

.fileImporter(
    isPresented: $isImporting,
    allowedContentTypes: [.directory]
) { result in
    switch result {
    case .success(let url):
        // Activate access immediately — required to create a bookmark
        guard url.startAccessingSecurityScopedResource() else {
            Logger.process.errorMessageOnly("Failed to start accessing resource")
            return
        }

        // Store the path string for immediate UI use
        selecteditem = url.path

        // Serialize the URL to a persistent bookmark while access is active
        do {
            let bookmarkData = try url.bookmarkData(
                options: .withSecurityScope,
                includingResourceValuesForKeys: nil,
                relativeTo: nil
            )
            UserDefaults.standard.set(bookmarkData, forKey: bookmarkKey)
        } catch {
            Logger.process.warning("Could not create bookmark: \(error)")
        }

        // Release access — will be reacquired via bookmark when needed
        url.stopAccessingSecurityScopedResource()

    case .failure(let error):
        Logger.process.errorMessageOnly("File picker error: \(error)")
    }
}

bookmarkKey is either "sourceBookmark" or "destBookmark" — the two folder roles in RawCull.

What this layer guarantees:

  • Bookmark is created while access is still active (the only valid window for bookmark creation)
  • Access is released immediately after — the bookmark takes over for future launches
  • The path is captured before releasing access, so the UI can display it without holding an open security scope

Layer 2 — Bookmark Restoration (ExecuteCopyFiles)

When the user initiates a copy operation on a subsequent launch, ExecuteCopyFiles resolves the stored bookmarks back into live, access-granted URLs.

File: RawCull/Model/ParametersRsync/ExecuteCopyFiles.swift

func getAccessedURL(fromBookmarkKey key: String, fallbackPath: String) -> URL? {
    // Primary path: restore from persisted bookmark
    if let bookmarkData = UserDefaults.standard.data(forKey: key) {
        do {
            var isStale = false

            let url = try URL(
                resolvingBookmarkData: bookmarkData,
                options: .withSecurityScope,
                relativeTo: nil,
                bookmarkDataIsStale: &isStale
            )

            // Activate access on the resolved URL
            guard url.startAccessingSecurityScopedResource() else {
                Logger.process.errorMessageOnly("Failed to start accessing bookmark for \(key)")
                return tryFallbackPath(fallbackPath, key: key)
            }

            // Warn if the folder was moved (bookmark is stale)
            if isStale {
                Logger.process.warning("Bookmark is stale for \(key) — user may need to reselect")
            }

            return url  // Caller is responsible for stopAccessingSecurityScopedResource()

        } catch {
            Logger.process.errorMessageOnly("Bookmark resolution failed for \(key): \(error)")
            return tryFallbackPath(fallbackPath, key: key)
        }
    }

    return tryFallbackPath(fallbackPath, key: key)
}

private func tryFallbackPath(_ fallbackPath: String, key: String) -> URL? {
    let fallbackURL = URL(fileURLWithPath: fallbackPath)
    guard fallbackURL.startAccessingSecurityScopedResource() else {
        Logger.process.errorMessageOnly("Failed to access fallback path for \(key)")
        return nil
    }
    return fallbackURL
}

The returned URL has startAccessingSecurityScopedResource() already called. The calling code in ExecuteCopyFiles is responsible for calling stopAccessingSecurityScopedResource() on each URL once the rsync operation completes.

What this layer handles:

  • Normal case: bookmark resolves cleanly → URL returned with access active
  • Stale bookmark: folder was moved → logged as warning, access still attempted
  • Bookmark resolution throws: falls back to direct path access
  • No bookmark stored at all: falls back to direct path access

Layer 3 — Scoped Access During File Operations (ScanFiles)

When scanning a directory for ARW files, the ScanFiles actor activates and deactivates security-scoped access for the duration of the scan only.

File: RawCull/Actors/ScanFiles.swift

actor ScanFiles {
    func scanFiles(url: URL, onProgress: @escaping (Double) -> Void) async -> [FileItem] {
        // Activate access for this URL
        guard url.startAccessingSecurityScopedResource() else {
            return []
        }
        // defer guarantees deactivation even if the function throws or returns early
        defer { url.stopAccessingSecurityScopedResource() }

        let manager = FileManager.default
        guard let contents = try? manager.contentsOfDirectory(
            at: url,
            includingPropertiesForKeys: [
                .fileSizeKey,
                .contentModificationDateKey,
                .typeIdentifierKey
            ],
            options: [.skipsHiddenFiles]
        ) else { return [] }

        return await processContents(contents, onProgress: onProgress)
    }
}

The defer pattern is critical here: it guarantees that stopAccessingSecurityScopedResource() is called regardless of whether the function completes normally, returns early, or the Swift runtime unwinds the stack. This prevents security-scoped resources from being “leaked” (left open indefinitely).

Actor isolation: Because ScanFiles is a Swift actor, all file operations on its state are serialized by the runtime — concurrent reads of the same directory cannot race each other.


Global Access Tracking in RawCullViewModel

The main view model maintains a comprehensive registry of all URLs for which startAccessingSecurityScopedResource() has been called, ensuring nothing is left open when the app quits.

File: RawCull/Model/ViewModels/RawCullViewModel.swift

@Observable @MainActor
final class RawCullViewModel {
    private var securityScopedURLs: Set<URL> = []

    func trackSecurityScopedAccess(for url: URL) {
        securityScopedURLs.insert(url)
    }

    func stopSecurityScopedAccess(for url: URL) {
        guard securityScopedURLs.contains(url) else { return }
        url.stopAccessingSecurityScopedResource()
        securityScopedURLs.remove(url)
    }

    deinit {
        // Release all remaining security-scoped access on teardown
        for url in securityScopedURLs {
            url.stopAccessingSecurityScopedResource()
        }
    }
}

This acts as a safety net: even if a call path omits an explicit stop, the deinit cleans up everything before the app exits. Combined with defer in the actors, this gives double coverage against resource leaks.


End-to-End Flow

User selects destination folder via file picker
    ↓
OpencatalogView.fileImporter result handler
    1. url.startAccessingSecurityScopedResource()
    2. selecteditem = url.path                    (UI binding)
    3. bookmarkData = try url.bookmarkData(options: .withSecurityScope)
    4. UserDefaults.set(bookmarkData, forKey: "destBookmark")
    5. url.stopAccessingSecurityScopedResource()
    ↓
    [App may be quit and relaunched here]
    ↓
User initiates copy operation
    ↓
ExecuteCopyFiles.performCopyTask()
    1. getAccessedURL(fromBookmarkKey: "sourceBookmark", ...)
       → resolves bookmark → startAccessingSecurityScopedResource() → returns URL
    2. getAccessedURL(fromBookmarkKey: "destBookmark", ...)
       → resolves bookmark → startAccessingSecurityScopedResource() → returns URL
    3. Builds rsync argument list using both paths
    4. Spawns /usr/bin/rsync via RsyncProcessStreaming
    5. After rsync completes:
       sourceURL.stopAccessingSecurityScopedResource()
       destURL.stopAccessingSecurityScopedResource()
    ↓
ScanFiles.scanFiles(url: sourceURL)
    1. url.startAccessingSecurityScopedResource()
    2. defer { url.stopAccessingSecurityScopedResource() }
    3. FileManager.contentsOfDirectory(at: url, ...)
    4. Returns [FileItem]   ← defer fires here, access released
    ↓
RawCullViewModel.deinit (on app quit)
    → stopAccessingSecurityScopedResource() for any remaining tracked URLs

Security Model Summary

AspectImplementationGuarantee
User consentFile picker only — no programmatic path constructionApp never accesses a folder the user did not explicitly choose
PersistenceBookmark serialized to UserDefaultsUser does not re-select folders on every launch
Minimal scope durationdefer and explicit stop calls bound access to the operationSecurity-scoped access is held only as long as needed
Leak preventionSet<URL> in view model + deinit cleanupNo access token outlives the app session
Stale bookmark detectionbookmarkDataIsStale checked on every resolveUser is informed if a folder has been moved
Fallback resilienceDirect path access if bookmark resolution failsGraceful degradation, operation still attempted
Audit trailOSLog records every start, stop, failure, and stale eventSecurity events are observable via Console.app

Common Pitfalls (and How RawCull Avoids Them)

1. Forgetting to call startAccessingSecurityScopedResource() before file operations → RawCull guards every file operation with an explicit start call; failure returns nil or [] rather than crashing.

2. Not calling stopAccessingSecurityScopedResource() — leaking the scopedefer in actors and deinit in the view model provide two independent cleanup layers.

3. Creating a bookmark while access is not activeOpencatalogView always creates the bookmark inside the startAccessing… / stopAccessing… window.

4. Ignoring the isStale flag → RawCull logs a warning when bookmarkDataIsStale is true, making stale bookmarks visible in diagnostics.

5. Using the resolved URL after calling stop → The view model tracks active URLs and guards against double-stop via the contains check before removing from the set.

Last modified March 23, 2026: update (01b62f1)