A Swift library for accessing the macOS Photos library. LadderKit provides asset discovery, metadata enrichment, and file export via PhotoKit and Photos.sqlite.
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+.
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 firstEach AssetInfo contains core PhotoKit fields: identifier, creation date, media type, dimensions, GPS location, and favorite status.
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, editorOr enumerate and enrich in one call:
let assets = try PhotoKitLibrary.loadEnrichedAssets(libraryURL: libraryURL)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.
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.
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.
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
}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)
}// 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)| 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 |
| 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(). |
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 }
All external dependencies are behind protocols:
PhotoLibrary— inject a mock that returns pre-configured assetsAssetHandle— inject a mock that writes known dataScriptExporter— inject a mock for AppleScript fallback (ornilto disable)
Tests run without Photos library access, Photos permission, or network. See Tests/PhotoExporterTests.swift for examples.
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
swift test
58 tests. All tests use mock implementations — no Photos library, credentials, or network required.