Security-Scoped URLs

Security-Scoped URLs

RawCull is a sandboxed macOS app. Any access outside the app container must come from user consent, usually a file/folder picker. RawCull uses two security-scope patterns:

  1. active catalog access for browsing/culling and as the rsync source,
  2. a persistent bookmark for the rsync destination.

Source Map

AreaFiles
Active catalog scopeRawCullViewModel.swift, RawCullViewModel+Catalog.swift, RawCullApp.swift
Catalog scan scopeActors/ScanFiles.swift
CLIP indexingRawCullViewModel+Similarity.swift, SimilarityScoringModel.swift, RawCullVisionSimilarityService.swift
Semantic searchRawCullViewModel+Similarity.swift, SimilarityScoringModel.swift, RawCullSemanticSearchService.swift
Similarity artifact cacheIntelligence/Persistence/PerFileAnalysisArtifactStore.swift
Copy-folder bookmarksViews/CopyFiles/OpencatalogView.swift, SourceAndDestinationSection.swift
rsync runtime scopeModel/ParametersRsync/ExecuteCopyFiles.swift
Selected JPG exportExtractJPGsSheetView.swift, RawCullViewModel+Thumbnails.swift, ExtractAndSaveJPGs.swift, SaveJPGImage.swift
App terminationMain/RawCullApp.swift, CullingModel.swift

API Basics

The core calls are:

let ok = url.startAccessingSecurityScopedResource()
url.stopAccessingSecurityScopedResource()

Persistent access is stored as bookmark data:

let data = try url.bookmarkData(options: .withSecurityScope, ...)
let url = try URL(resolvingBookmarkData: data, options: .withSecurityScope, ...)

Every successful startAccessing... must eventually be paired with stopAccessing....

Active Catalog Scope

The catalog browsing flow is owned by RawCullViewModel.

sequenceDiagram
    participant UI as Sidebar picker
    participant VM as RawCullViewModel
    participant Work as Scan/thumbnail/export work
    UI->>VM: startCatalogLoad(source)
    VM->>VM: cancelCatalogLoad()
    VM->>VM: startSecurityScopedAccess(url)
    VM->>Work: scan and preload
    Work-->>VM: results/progress
    VM->>VM: stopActiveSecurityScopedAccess() on cancel/empty/deinit/app cleanup

startSecurityScopedAccess(for:) is idempotent for the currently active URL. If a different catalog is selected, it stops the previous active scope before starting the new one.

cancelCatalogLoad() releases the active scope and cancels related work. An empty scan also releases it. RawCullViewModel.deinit is the final defensive release.

Catalog changes are persistence boundaries: startCatalogLoad(for:) waits for CullingModel.flushPersistence() before it cancels the old catalog and its scope. If the flush fails, RawCull restores the previous selection and keeps the old catalog active.

ScanFiles Scope

ScanFiles.scanFiles(url:onProgress:) also starts and stops access around directory scanning:

let didStartSecurityScope = url.startAccessingSecurityScopedResource()
defer {
    if didStartSecurityScope {
        url.stopAccessingSecurityScopedResource()
    }
}

This is a local defensive scope for the scan actor. It stops only when its own start succeeded. The broader catalog scope remains owned by RawCullViewModel so later preload, diagnostics, export, zoom, and AI work can still access files while the catalog is active.

Scope Ownership Matrix

The owner is the component that records a successful start and is therefore responsible for the matching stop. A borrower may use URLs covered by a longer-lived owner, but must not stop that owner’s scope.

OperationScope ownerStartStopFailure and cancellation cleanup
Active catalogRawCullViewModelstartSecurityScopedAccess(for:) before catalog workCatalog cancel/change, empty scan, successful app termination, or deinitA failed start is not recorded. Switching first flushes culling persistence; a failed flush retains the old catalog and scope.
Directory scanScanFiles.scanFilesLocal startAccessingSecurityScopedResource()defer, but only when the local start returned truedefer covers success, thrown filesystem errors, cancellation, and early return. The view model’s broader scope is not stopped.
Selected JPG exportRawCullViewModel.startSelectedJPGExtractionStart the chosen destination immediately before creating ExtractAndSaveJPGsOn return from extractAndSavejpgs(), before publishing completion or failure UIFailed destination start aborts without a stop. Per-file failures are collected; the operation-level stop still runs after the actor returns. Source reads borrow the active catalog scope.
rsync copyExecuteCopyFilesStart the selected catalog URL, then resolve and start destBookmarkIdempotent cleanup() after normal completion, close/cancel, startup failure, launch failure, or deinitThere is no direct-path fallback. If destination setup fails, cleanup stops the already-started source. didCleanUp prevents duplicate stops and include-file removal.
AI indexingActive catalog (RawCullViewModel)No per-file start; indexing borrows the selected directory scopeNo per-file stopIndex cancellation stops AI work, not the catalog scope. Catalog cancellation/change releases the owner scope after cancelling related work.
Semantic queryNone for ranking; active catalog remains open for follow-on actionsNo start; ranking reads hydrated in-memory artifactsNo stopQuery cancellation discards query work. Any subsequent preview/export uses the appropriate catalog or export scope.

CLIP indexing and semantic search use the active catalog scope differently. Indexing reads source images, while a search query operates on cached embeddings.

flowchart LR
    A["Active security-scoped catalog"] --> B["FileItem URLs"]
    B --> C["Decode RAW thumbnail, max 512 px"]
    C --> D["CLIP image encoder"]
    D --> E["Validated similarity artifact"]
    E --> F["Application Support cache"]
    Q["Text query"] --> T["CLIP text encoder"]
    F --> S["Cosine similarity ranking"]
    T --> S
    S --> R["Ranked catalog selection"]

CLIP indexing requires the catalog scope

RawCullViewModel.indexSimilarity() first hydrates reusable artifacts and then asks SimilarityScoringModel.indexFiles(_:) to generate any missing or stale artifacts. Each FileItem becomes an AIImageSource containing the file URL.

For an artifact that must be generated, RawCullSimilarityImageDecoder reads the source URL. It first asks RawParserKitImageLoader for a thumbnail with a maximum dimension of 512 pixels and then tries ImageIO as a fallback. Because these URLs point into the user-selected catalog, decoding depends on the catalog directory’s security-scoped access still being active.

RawCull does not call startAccessingSecurityScopedResource() for every image. Access was already started for the selected directory by RawCullViewModel, and that scope covers its files. The view model deliberately keeps the directory scope open after the initial scan so indexing, thumbnail generation, previews, exports, and other catalog operations can read the same URLs.

The decoded image is passed to the selected local similarity backend. When the selected backend is CLIP, PhotoAIKit creates a normalized image embedding. Semantic-search coverage can only be populated when the active similarity backend produces artifacts compatible with the selected CLIP semantic-search backend. If Vision similarity is selected, the UI asks the user to enable Use selected CLIP model for similarity before building missing semantic-search artifacts.

Successfully validated artifacts are written one file at a time to:

~/Library/Application Support/RawCull/AnalysisArtifacts/Similarity/

In the sandbox, that resolves inside RawCull’s container. It is app-owned storage and does not need a security-scoped URL. Cache records are keyed and validated against the source fingerprint, artifact schema, model/backend descriptor, and RawCull’s embedding pipeline signature. A moved, renamed, changed, incompatible, or corrupt source is therefore treated as a cache miss and must be indexed again while the catalog scope is active.

Semantic search reuses cached CLIP artifacts

Opening a catalog hydrates compatible artifacts from the app-owned cache into semanticArtifacts. A semantic query then:

  1. applies the ordinary catalog admission rules, such as filename and rating filters,
  2. keeps only files with an artifact compatible with the currently selected CLIP backend,
  3. encodes the literal text query with the local CLIP text encoder,
  4. computes cosine similarity between that temporary text embedding and the cached image embeddings,
  5. sorts the matches and exposes the selected highest-ranked files as the active catalog working set.

searchSemantically(for:) and rankSemantically(query:files:) do not decode RAW files, generate image embeddings, or read image contents from the catalog. The text-query embedding exists only for that search call and is not persisted. Consequently, semantic ranking itself does not acquire a new security scope; it uses in-memory artifacts that were restored or created earlier.

The catalog scope nevertheless remains active during semantic search. Ranked files can immediately flow into preview, zoom, export, culling, burst, or Deep Review operations that do need their source URLs. Clearing a search changes the working set, not the security-scope owner or lifetime.

Scope lifetime for the AI workflow

sequenceDiagram
    participant User
    participant VM as RawCullViewModel
    participant Index as CLIP indexing
    participant Cache as App-owned artifact cache
    participant Search as Semantic search
    User->>VM: Select catalog
    VM->>VM: startAccessing catalog URL
    VM->>Cache: Hydrate compatible artifacts
    User->>Index: Index Similarity
    Index->>VM: Read source URLs under active scope
    Index->>Cache: Persist validated image artifacts
    User->>Search: Submit text query
    Search->>Cache: Use hydrated CLIP artifacts
    Note over Search: No source decoding and no new scope
    User->>VM: Close, cancel, or select another catalog
    VM->>VM: stopAccessing catalog URL

Copy Workflow Bookmark

The copy workflow reuses the active catalog as its source and persists only the destination. OpencatalogView creates destBookmark when the user picks that folder.

flowchart TD
    A["User picks destination"] --> B["startAccessing"]
    B --> C["bookmarkData(.withSecurityScope)"]
    C --> D["UserDefaults destBookmark"]
    D --> E["stopAccessing"]
    E --> F["Later: ExecuteCopyFiles resolves bookmark"]
    F --> G["start destination scope during rsync"]
    S["Selected catalog URL"] --> H["start source scope during rsync"]
    G --> I["cleanup stops both scopes"]
    H --> I

If the selected catalog scope cannot be started, the user is asked to reopen the catalog. If destBookmark is missing or cannot be resolved, the user must reselect the destination. A stale bookmark is regenerated while its resolved scope is active. If source access succeeds but destination access fails, cleanup() releases the source before returning the startup error.

rsync Runtime Cleanup

ExecuteCopyFiles stores the accessed URLs in:

  • sourceAccessedURL,
  • destAccessedURL.

cleanup() finishes the progress stream, stops both security-scoped resources, clears process references, and is guarded by didCleanUp so multiple termination paths are safe.

close() sets isClosing, cancels the process, and calls cleanup. Normal termination constructs a CopyDataResult containing the output, typed outcome, and immutable CopyOperation source/destination snapshot, invokes completion, and then cleans up. There is no timing-delay dependency in the completion path.

The include list is written under Application Support/RawCull/CopyLists, not the user-selected source or destination. Cleanup removes the per-operation list on every path after it has been created.

Selected JPG Export Destination

The export sheet can use an existing catalog or a folder returned by its Choose… file importer. extractJPGDestination remembers that choice for the lifetime of the RawCullViewModel; it is not a persistent rsync-style bookmark.

Pressing Extract starts a separate security scope for the destination immediately before ExtractAndSaveJPGs begins. The scope remains active for the entire batch, including detached atomic writes performed by SaveJPGImage, and is stopped when extractAndSavejpgs() returns. The active source catalog scope remains a different ownership unit and covers RAW/JPEG reads. If source and destination happen to be the same URL, each successful start still belongs to its own operation and must receive its own stop.

Destination access failure prevents actor creation and presents Export Not Started. Individual extraction or write failures do not shorten the destination lifetime: the actor returns an aggregate result, the view model stops the destination scope, and then it presents Export Incomplete if needed.

App Termination And Persistence

AppDelegate.applicationShouldTerminate(_:) returns .terminateLater and starts one termination task. That task awaits cullingModel.flushPersistence() before releasing the active catalog scope. A second termination request while the task is running also returns .terminateLater rather than starting another flush.

If persistence succeeds, the app stops the active catalog scope and replies true to AppKit. If persistence fails, a modal recovery choice offers retry, cancel quitting, or Quit Without Saving. Retry repeats the flush; cancel keeps the app and scope alive; discard releases the scope and terminates while explicitly acknowledging unsaved culling changes. Only one termination task is active. Export and rsync retain their own cleanup ownership.

File Writes

Writes outside the app container require an active user-granted scope. The main examples are:

WriteScope source
Extracted JPEG sidecars next to RAW filesactive catalog scope
Selected JPG export folderoperation-owned destination scope
rsync destination writesdestBookmark scope
rsync include fileapp Application Support folder, no external scope required

App-owned JSON/cache files under Application Support or Caches do not need security-scoped access.

What To Check When Changing This Area

  • Keep one clear owner for each long-lived scope.
  • Pair every successful start with a stop on all exit paths.
  • Keep catalog switching and app termination behind a successful culling-state persistence flush.
  • Keep the selected JPG destination scope open until the whole export actor returns; do not stop it after scheduling detached writes.
  • Keep the active catalog scope alive for CLIP indexing and for operations launched from semantic-search results.
  • Do not add per-file security-scope calls inside the CLIP indexer; the selected catalog directory owns that access.
  • Keep semantic ranking cache-only. If it begins decoding source images, its security assumptions and UI behavior must be revisited.
  • Store similarity artifacts and query-independent embeddings in app-owned storage, not beside the RAW files.
  • Use bookmarks for persistent copy-folder access, not for every temporary catalog scan.
  • When adding a new file write, ask whether it targets the app container or a user folder.
  • If copy closes early, verify ExecuteCopyFiles.cleanup() still runs exactly once.
  • Test partial rsync startup (source succeeds, destination fails) and partial JPG export failures as ownership cases, not only as UI errors.

Last modified September 15, 2026: sam3 (7b120c4)