Skip to content

tijs/ladder

Repository files navigation

ladder logo

ladder

A Swift library for accessing the macOS Photos library. LadderKit provides asset discovery, metadata enrichment, and file export via PhotoKit and Photos.sqlite.

Installation

Add LadderKit as a dependency in your Package.swift:

dependencies: [
    .package(url: "https://github.com/tijs/ladder", from: "0.6.0"),
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [.product(name: "LadderKit", package: "ladder")]
    ),
]

Requires macOS 13+.

API

Asset Discovery

Enumerate all non-trashed assets in the Photos library via PhotoKit:

import LadderKit

let library = PhotoKitLibrary()
let count = library.totalAssetCount()
var assets = library.enumerateAssets()
// assets: [AssetInfo] sorted by creation date, newest first

Each AssetInfo contains core PhotoKit fields: identifier, creation date, media type, dimensions, GPS location, and favorite status.

Metadata Enrichment

PhotoKit doesn't expose keywords, people, descriptions, albums, filenames, or edit details. These come from Photos.sqlite:

// User selects their .photoslibrary bundle (e.g., via NSOpenPanel)
let libraryURL: URL = ...

// Validate and derive database path
guard PhotosLibraryPath.validate(libraryURL).isValid,
      let dbPath = PhotosLibraryPath.databasePath(for: libraryURL)
else { return }

// Read enrichment data and apply it
let enrichment = PhotosDatabase.readEnrichment(dbPath: dbPath)
PhotosDatabase.enrich(&assets, with: enrichment)

// assets now have: originalFilename, uniformTypeIdentifier, albums,
// keywords, people, description, hasEdit, editedAt, editor

Or enumerate and enrich in one call:

let assets = try PhotoKitLibrary.loadEnrichedAssets(libraryURL: libraryURL)

File Export

Export original photo/video files to a staging directory with inline SHA-256 hashing:

let stagingDir = try PathSafety.validateStagingDir("/tmp/photo-export")
let exporter = PhotoExporter(stagingDir: stagingDir, library: library)
let response = await exporter.export(uuids: ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"])

for result in response.results {
    print(result.path)   // exported file path
    print(result.sha256) // hash computed during streaming write
    print(result.size)   // file size in bytes
}

Files are streamed from PhotoKit to disk. SHA-256 is computed inline during the write — no second pass.

iCloud-only assets

When "Optimize Mac Storage" is enabled, some assets exist only in iCloud and are invisible to PhotoKit's fetchAssets(). For these assets, PhotoExporter falls back to AppleScript via Photos.app, which handles the iCloud download transparently:

let exporter = PhotoExporter(
    stagingDir: stagingDir,
    library: library,
    scriptExporter: AppleScriptRunner() // enables iCloud fallback
)

The fallback runs sequentially (one asset at a time) after all PhotoKit exports complete. SHA-256 is computed after export using FileHasher. Pass scriptExporter: nil to disable the fallback.

When Photos.app reports -1728 "Can't get media item" (typical for shared-album assets whose derivative has gone missing server-side), the runner raises AppleScriptError.assetUnavailable and the exporter classifies the error as .permanentlyUnavailable so callers can skip-forever instead of retrying.

This approach is inspired by osxphotos (MIT license) by Rhet Turnbull.

Additional permission required: The AppleScript fallback needs Automation permission (System Settings > Privacy & Security > Automation > your app > Photos). Call exporter.checkPermissions() to pre-flight before a batch.

Adaptive iCloud-lane throttling

When "Optimize Mac Storage" is enabled, a batch typically mixes locally-cached assets (fast, parallel-safe) with iCloud-only assets (slow, easily throttled by iCloud). PhotoExporter can partition the batch and apply separate concurrency limits per lane.

let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
let exporter = PhotoExporter(
    stagingDir: stagingDir,
    library: library,
    scriptExporter: AppleScriptRunner(),
    localAvailability: availability,
    adaptiveController: MyAIMDController() // your policy
)

AdaptiveConcurrencyControlling is observation-only:

public protocol AdaptiveConcurrencyControlling: Sendable {
    func currentLimit() async -> Int
    func record(_ outcome: ExportOutcome) async
}

public enum ExportOutcome: Sendable {
    case success
    case transientFailure       // iCloud throttling / network blip — tune down
    case permanentFailure       // asset gone — ignore for tuning
}

LadderKit ships no concrete controller. Implement your own (AIMD, EWMA, whatever fits) or pass nil to run the iCloud lane at the exporter's static maxConcurrency.

Local-availability lookup

PhotosDatabaseLocalAvailability reads ZINTERNALRESOURCE.ZLOCALAVAILABILITY to determine which asset originals are cached locally:

guard let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
else { return }

if availability.isLocallyAvailable(uuid: "B84E8479-475C-4727-A7F4-B3D5E5D71923") {
    // export will be fast, no iCloud round-trip
}

Error classification

ExportError.classification distinguishes genuinely-dead assets from transient failures, so callers can route retry/skip decisions without parsing messages:

public enum ExportClassification: Sendable {
    case other                  // unclassified
    case transientCloud         // retry later
    case permanentlyUnavailable // skip forever (e.g., -1728)
}

Standalone Hashing

// Streaming hasher for incremental use
let hasher = StreamingHasher()
hasher.update(chunk1)
hasher.update(chunk2)
let hash = hasher.finalize() // hex-encoded SHA-256

// One-shot file hashing (8 MB chunks, memory-efficient)
let fileHash = try FileHasher.sha256(fileAt: fileURL)

API Contract

Provided (what LadderKit gives you)

Protocol / Type Purpose
PhotoLibrary Asset discovery and fetch by identifier (keys preserved)
PhotoKitLibrary.loadEnrichedAssets(libraryURL:) Enumerate + enrich in one call
AssetHandle Single asset's exportable resource (isShared flag for shared-album detection)
PhotoExporter Concurrent export with inline hashing, local/iCloud lane partition
AdaptiveConcurrencyControlling / ExportOutcome Throttle the iCloud lane with your own policy
LocalAvailabilityProviding / PhotosDatabaseLocalAvailability Which assets are cached locally
ScriptExporter / AppleScriptRunner AppleScript fallback for iCloud-only assets
AppleScriptError Includes .assetUnavailable for permanent -1728 failures
ExportError.classification .other / .transientCloud / .permanentlyUnavailable
PhotosDatabase Photos.sqlite enrichment reader (+ localAvailableUUIDs)
PhotosLibraryPath Library bundle validation and path derivation
StreamingHasher Incremental SHA-256
FileHasher One-shot file SHA-256
PathSafety Filename sanitization and path traversal prevention

Required (what your app must provide)

Requirement How
Photos permission Call PHPhotoLibrary.requestAuthorization(for: .readWrite) before using PhotoKitLibrary
Photos library path User selects their .photoslibrary bundle. Use PhotosLibraryPath.validate() to verify, then databasePath(for:) to get the sqlite path. A security-scoped bookmark from NSOpenPanel grants file access without Full Disk Access.
Staging directory Provide an absolute path for exported files. Validate with PathSafety.validateStagingDir().
Automation permission (only if using AppleScriptRunner) Grant in System Settings > Privacy & Security > Automation. Pre-flight with PhotoExporter.checkPermissions().

Data Types

AssetInfo — metadata for a single photo or video:

identifier          String       PhotoKit local identifier (e.g., "UUID/L0/001")
uuid                String       UUID portion extracted from identifier
creationDate        Date?        when the photo was taken
kind                AssetKind    .photo (0) or .video (1)
pixelWidth          Int
pixelHeight         Int
latitude            Double?      GPS coordinates
longitude           Double?
isFavorite          Bool
originalFilename    String?      from Photos.sqlite enrichment
uniformTypeIdentifier String?    e.g., "public.heic"
hasEdit             Bool         true when both adjustment + rendered resource exist
albums              [AlbumInfo]  album membership
keywords            [String]     user-assigned keywords
people              [PersonInfo] recognized faces with names
assetDescription    String?      user-written caption (JSON key: "description")
editedAt            Date?        when the edit was made
editor              String?      editor identifier (e.g., "com.apple.photos")

AssetInfo conforms to Codable. The assetDescription field serializes as "description" in JSON.

AlbumInfo{ identifier: String, title: String }

PersonInfo{ uuid: String, displayName: String }

ExportResult{ uuid: String, path: String, size: Int64, sha256: String }

ExportError{ uuid: String, message: String, classification: ExportClassification }

Testability

All external dependencies are behind protocols:

  • PhotoLibrary — inject a mock that returns pre-configured assets
  • AssetHandle — inject a mock that writes known data
  • ScriptExporter — inject a mock for AppleScript fallback (or nil to disable)

Tests run without Photos library access, Photos permission, or network. See Tests/PhotoExporterTests.swift for examples.

Project structure

Sources/LadderKit/
  AssetInfo.swift           AssetInfo, AssetKind, AlbumInfo, PersonInfo
  PhotoLibrary.swift        PhotoLibrary protocol + PhotoKit implementation
  PhotoExporter.swift       concurrent export, local/iCloud lane partition, inline hashing
  AdaptiveConcurrency.swift AdaptiveConcurrencyControlling + ExportOutcome
  LocalAvailability.swift   LocalAvailabilityProviding + PhotosDatabaseLocalAvailability
  AppleScriptExporter.swift iCloud-only fallback via Photos.app
  PhotosDatabase.swift      Photos.sqlite enrichment reader
  PhotosLibraryPath.swift   library bundle validation
  Hasher.swift              StreamingHasher + FileHasher
  Models.swift              ExportResult, ExportResponse, ExportError, ExportClassification
  PathSafety.swift          filename sanitization, path traversal prevention
Tests/
  AssetInfoTests.swift
  PhotoExporterTests.swift
  AppleScriptExporterTests.swift
  PhotosDatabaseTests.swift
  PhotosLibraryPathTests.swift
  HasherTests.swift
  ModelsTests.swift
  PathSafetyTests.swift

Testing

swift test

58 tests. All tests use mock implementations — no Photos library, credentials, or network required.

About

PhotoKit export helper for iCloud Photos backup

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages