···4455# ladder
6677-A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
77+A Swift library and CLI for accessing the macOS Photos library. LadderKit provides asset discovery, metadata enrichment, and file export via PhotoKit and Photos.sqlite.
8899-## What it does
99+## LadderKit Library
10101111-ladder takes a JSON request on stdin (or from a file argument), exports the requested assets from the Photos library to a staging directory, computes a SHA-256 hash for each file during export, and writes a JSON response to stdout. It is designed to be called as a subprocess by [attic](https://github.com/tijs/attic), the Deno/TypeScript component of the photo-cloud backup system.
1111+Add LadderKit as a dependency in your `Package.swift`:
12121313-## How it works
1313+```swift
1414+dependencies: [
1515+ .package(url: "https://github.com/tijs/ladder", from: "0.2.0"),
1616+],
1717+targets: [
1818+ .target(
1919+ name: "YourApp",
2020+ dependencies: [.product(name: "LadderKit", package: "ladder")]
2121+ ),
2222+]
2323+```
14241515-1. Reads a JSON `ExportRequest` from stdin (or from a file path passed as the first argument)
1616-2. Requests Photos library authorization via PhotoKit
1717-3. Fetches assets by their local identifiers (UUIDs)
1818-4. Exports each asset's original resource to the staging directory with bounded concurrency (default: 6)
1919-5. Computes SHA-256 inline while streaming data to disk (no second pass over the file)
2020-6. Writes a JSON `ExportResponse` to stdout
2525+Requires macOS 13+.
21262222-## Installing
2727+## API
23282424-Requires macOS 13+ and Swift 5.9+.
2929+### Asset Discovery
3030+3131+Enumerate all non-trashed assets in the Photos library via PhotoKit:
3232+3333+```swift
3434+import LadderKit
25353636+let library = PhotoKitLibrary()
3737+let count = library.totalAssetCount()
3838+var assets = library.enumerateAssets()
3939+// assets: [AssetInfo] sorted by creation date, newest first
2640```
2727-make install
4141+4242+Each `AssetInfo` contains core PhotoKit fields: identifier, creation date, media type, dimensions, GPS location, and favorite status.
4343+4444+### Metadata Enrichment
4545+4646+PhotoKit doesn't expose keywords, people, descriptions, albums, filenames, or edit details. These come from Photos.sqlite:
4747+4848+```swift
4949+// User selects their .photoslibrary bundle (e.g., via NSOpenPanel)
5050+let libraryURL: URL = ...
5151+5252+// Validate and derive database path
5353+guard PhotosLibraryPath.validate(libraryURL).isValid,
5454+ let dbPath = PhotosLibraryPath.databasePath(for: libraryURL)
5555+else { return }
5656+5757+// Read enrichment data and apply it
5858+let enrichment = PhotosDatabase.readEnrichment(dbPath: dbPath)
5959+PhotosDatabase.enrich(&assets, with: enrichment)
6060+6161+// assets now have: originalFilename, uniformTypeIdentifier, albums,
6262+// keywords, people, description, hasEdit, editedAt, editor
2863```
29643030-This builds a release binary and installs it to `/usr/local/bin/ladder`. To install elsewhere:
6565+### File Export
6666+6767+Export original photo/video files to a staging directory with inline SHA-256 hashing:
31686969+```swift
7070+let stagingDir = try PathSafety.validateStagingDir("/tmp/photo-export")
7171+let exporter = PhotoExporter(stagingDir: stagingDir, library: library)
7272+let response = await exporter.export(uuids: ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"])
7373+7474+for result in response.results {
7575+ print(result.path) // exported file path
7676+ print(result.sha256) // hash computed during streaming write
7777+ print(result.size) // file size in bytes
7878+}
3279```
3333-make install PREFIX=~/.local
8080+8181+Files are streamed from PhotoKit to disk. SHA-256 is computed inline during the write — no second pass. iCloud-only assets are downloaded transparently when `networkAccessAllowed` is true (the default).
8282+8383+### Standalone Hashing
8484+8585+```swift
8686+// Streaming hasher for incremental use
8787+let hasher = StreamingHasher()
8888+hasher.update(chunk1)
8989+hasher.update(chunk2)
9090+let hash = hasher.finalize() // hex-encoded SHA-256
9191+9292+// One-shot file hashing (8 MB chunks, memory-efficient)
9393+let fileHash = try FileHasher.sha256(fileAt: fileURL)
3494```
35953636-To uninstall:
9696+## API Contract
9797+9898+### Provided (what LadderKit gives you)
9999+100100+| Protocol / Type | Purpose |
101101+|---|---|
102102+| `PhotoLibrary` | Asset discovery and fetch by identifier |
103103+| `AssetHandle` | Single asset's exportable resource |
104104+| `PhotoExporter` | Concurrent export with inline hashing |
105105+| `PhotosDatabase` | Photos.sqlite enrichment reader |
106106+| `PhotosLibraryPath` | Library bundle validation and path derivation |
107107+| `StreamingHasher` | Incremental SHA-256 |
108108+| `FileHasher` | One-shot file SHA-256 |
109109+| `PathSafety` | Filename sanitization and path traversal prevention |
110110+111111+### Required (what your app must provide)
112112+113113+| Requirement | How |
114114+|---|---|
115115+| **Photos permission** | Call `PHPhotoLibrary.requestAuthorization(for: .readWrite)` before using `PhotoKitLibrary` |
116116+| **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. |
117117+| **Staging directory** | Provide an absolute path for exported files. Validate with `PathSafety.validateStagingDir()`. |
118118+119119+### Data Types
120120+121121+**AssetInfo** — metadata for a single photo or video:
3712238123```
3939-make uninstall
124124+identifier String PhotoKit local identifier (e.g., "UUID/L0/001")
125125+uuid String UUID portion extracted from identifier
126126+creationDate Date? when the photo was taken
127127+kind AssetKind .photo (0) or .video (1)
128128+pixelWidth Int
129129+pixelHeight Int
130130+latitude Double? GPS coordinates
131131+longitude Double?
132132+isFavorite Bool
133133+originalFilename String? from Photos.sqlite enrichment
134134+uniformTypeIdentifier String? e.g., "public.heic"
135135+hasEdit Bool true when both adjustment + rendered resource exist
136136+albums [AlbumInfo] album membership
137137+keywords [String] user-assigned keywords
138138+people [PersonInfo] recognized faces with names
139139+assetDescription String? user-written caption (JSON key: "description")
140140+editedAt Date? when the edit was made
141141+editor String? editor identifier (e.g., "com.apple.photos")
40142```
411434242-## Usage
144144+`AssetInfo` conforms to `Codable`. The `assetDescription` field serializes as `"description"` in JSON.
145145+146146+**AlbumInfo** — `{ identifier: String, title: String }`
147147+148148+**PersonInfo** — `{ uuid: String, displayName: String }`
149149+150150+**ExportResult** — `{ uuid: String, path: String, size: Int64, sha256: String }`
151151+152152+### Testability
153153+154154+All external dependencies are behind protocols:
155155+156156+- `PhotoLibrary` — inject a mock that returns pre-configured assets
157157+- `AssetHandle` — inject a mock that writes known data
158158+159159+Tests run without Photos library access, Photos permission, or network. See `Tests/PhotoExporterTests.swift` for examples.
160160+161161+## CLI
162162+163163+The CLI wraps LadderKit for use as a subprocess (used by [attic](https://github.com/tijs/attic)).
431644444-### Input (ExportRequest)
165165+### Installing
451664646-```json
4747-{
4848- "uuids": [
4949- "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001",
5050- "3FA8GH5M-BMMH-H123-ABCD-1234567890AB/L0/001"
5151- ],
5252- "stagingDir": "/tmp/photo-export"
5353-}
167167+```
168168+make install
54169```
551705656-- `uuids` -- Photos library local identifiers to export
5757-- `stagingDir` -- absolute path where exported files will be written (must not be inside system directories like `/System`, `/Library`, `/usr`, etc.)
171171+Installs to `/usr/local/bin/ladder`. Use `make install PREFIX=~/.local` for a different location.
581725959-### Running
173173+### Usage
6017461175```bash
6262-# From stdin
6363-echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | .build/release/ladder
176176+echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | ladder
177177+# or
178178+ladder request.json
179179+```
641806565-# From a file
6666-.build/release/ladder request.json
181181+**Input** (`ExportRequest`):
182182+```json
183183+{
184184+ "uuids": ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"],
185185+ "stagingDir": "/tmp/photo-export"
186186+}
67187```
681886969-### Output (ExportResponse)
7070-7171-Written to stdout:
7272-189189+**Output** (`ExportResponse`):
73190```json
74191{
7575- "errors": [
7676- {
7777- "message": "Asset not found in Photos library",
7878- "uuid": "missing-uuid"
7979- }
8080- ],
81192 "results": [
82193 {
194194+ "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001",
83195 "path": "/tmp/photo-export/B84E8479-475C-4727_IMG_0001.HEIC",
8484- "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447",
85196 "size": 3158112,
8686- "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"
197197+ "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
87198 }
199199+ ],
200200+ "errors": [
201201+ { "uuid": "missing-uuid", "message": "Asset not found in Photos library" }
88202 ]
89203}
90204```
912059292-Each result includes the file path, size in bytes, and SHA-256 hash. Assets that could not be found or exported appear in the `errors` array.
9393-9494-## Required permissions
9595-9696-- **Photos access** -- ladder requests read/write authorization on launch. Grant access in System Settings > Privacy & Security > Photos.
9797-- **Full Disk Access** -- may be required depending on how the Photos library is stored. Grant in System Settings > Privacy & Security > Full Disk Access.
9898-9999-## Testing
100100-101101-```
102102-swift test
103103-```
206206+### Required permissions
104207105105-Tests use protocol-based dependency injection (`PhotoLibrary` / `AssetHandle` protocols) with mock implementations, so they run without Photos library access.
208208+- **Photos access** — grant in System Settings > Privacy & Security > Photos
209209+- **Full Disk Access** — may be needed depending on library location
106210107211## Project structure
108212109213```
110214Sources/
111215 CLI/
112112- Main.swift -- entry point, stdin parsing, authorization
216216+ Main.swift entry point, stdin/stdout JSON protocol
113217 LadderKit/
114114- Models.swift -- ExportRequest, ExportResponse, ExportResult, ExportError
115115- PhotoExporter.swift -- concurrent export orchestration
116116- PhotoLibrary.swift -- PhotoKit abstraction (protocol + real implementation)
117117- Hasher.swift -- streaming SHA-256 (inline with export)
118118- PathSafety.swift -- filename sanitization and path traversal prevention
218218+ AssetInfo.swift AssetInfo, AssetKind, AlbumInfo, PersonInfo
219219+ PhotoLibrary.swift PhotoLibrary protocol + PhotoKit implementation
220220+ PhotoExporter.swift concurrent export with inline hashing
221221+ PhotosDatabase.swift Photos.sqlite enrichment reader
222222+ PhotosLibraryPath.swift library bundle validation
223223+ Hasher.swift StreamingHasher + FileHasher
224224+ Models.swift ExportRequest, ExportResponse (CLI types)
225225+ PathSafety.swift filename sanitization, path traversal prevention
119226Tests/
120120- ModelsTests.swift
227227+ AssetInfoTests.swift
121228 PhotoExporterTests.swift
229229+ PhotosDatabaseTests.swift
230230+ PhotosLibraryPathTests.swift
122231 HasherTests.swift
232232+ ModelsTests.swift
123233 PathSafetyTests.swift
124234```
125235126126-## How it fits with attic
236236+## Testing
237237+238238+```
239239+swift test
240240+```
127241128128-In the photo-cloud system, **attic** (Deno/TypeScript) is the orchestrator that determines which photos need backing up and manages cloud storage. It spawns **ladder** as a subprocess to handle the macOS-specific part: accessing the Photos library via PhotoKit and exporting original files to a staging directory. attic sends a JSON request with the asset UUIDs, ladder exports them and reports back with file paths and hashes, and attic takes it from there.
242242+44 tests across 8 suites. All tests use mock implementations — no Photos library, credentials, or network required.