A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add README and CLAUDE.md

+138
+24
CLAUDE.md
··· 1 + # Ladder 2 + 3 + Swift PhotoKit export helper for iCloud Photos backup. Part of the photo-cloud system (companion: [attic](https://github.com/tijs/attic)). 4 + 5 + ## Commands 6 + 7 + ```bash 8 + swift build -c release | xcsift # Build release binary 9 + swift test | xcsift # Run tests (19 tests) 10 + swiftlint --fix # Auto-fix lint issues 11 + ``` 12 + 13 + ## Architecture 14 + 15 + - **LadderKit** (library): `PhotoExporter`, `StreamingHasher`, `FileHasher`, model types, path safety 16 + - **CLI** (executable): Reads JSON request from stdin, calls PhotoExporter, writes JSON response to stdout 17 + 18 + ## Conventions 19 + 20 + - Use `debugPrint()` instead of `print()` (stripped by compiler in release) 21 + - Dependencies are injected for testability — no real PhotoKit calls in tests 22 + - `StreamingHasher` uses `@unchecked Sendable` with `NSLock` — documented safety invariant in `Hasher.swift`. TODO: migrate to `Mutex<CC_SHA256_CTX>` when targeting macOS 15+ 23 + - Pipe all xcodebuild/swift commands through `xcsift` for clean output 24 + - Files should stay under 500 lines
+114
README.md
··· 1 + # ladder 2 + 3 + A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit. 4 + 5 + ## What it does 6 + 7 + 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](../attic), the Deno/TypeScript component of the photo-cloud backup system. 8 + 9 + ## How it works 10 + 11 + 1. Reads a JSON `ExportRequest` from stdin (or from a file path passed as the first argument) 12 + 2. Requests Photos library authorization via PhotoKit 13 + 3. Fetches assets by their local identifiers (UUIDs) 14 + 4. Exports each asset's original resource to the staging directory with bounded concurrency (default: 6) 15 + 5. Computes SHA-256 inline while streaming data to disk (no second pass over the file) 16 + 6. Writes a JSON `ExportResponse` to stdout 17 + 18 + ## Building 19 + 20 + Requires macOS 13+ and Swift 5.9+. 21 + 22 + ``` 23 + swift build -c release 24 + ``` 25 + 26 + The binary is at `.build/release/ladder`. 27 + 28 + ## Usage 29 + 30 + ### Input (ExportRequest) 31 + 32 + ```json 33 + { 34 + "uuids": [ 35 + "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001", 36 + "3FA8GH5M-BMMH-H123-ABCD-1234567890AB/L0/001" 37 + ], 38 + "stagingDir": "/tmp/photo-export" 39 + } 40 + ``` 41 + 42 + - `uuids` -- Photos library local identifiers to export 43 + - `stagingDir` -- absolute path where exported files will be written (must not be inside system directories like `/System`, `/Library`, `/usr`, etc.) 44 + 45 + ### Running 46 + 47 + ```bash 48 + # From stdin 49 + echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | .build/release/ladder 50 + 51 + # From a file 52 + .build/release/ladder request.json 53 + ``` 54 + 55 + ### Output (ExportResponse) 56 + 57 + Written to stdout: 58 + 59 + ```json 60 + { 61 + "errors": [ 62 + { 63 + "message": "Asset not found in Photos library", 64 + "uuid": "missing-uuid" 65 + } 66 + ], 67 + "results": [ 68 + { 69 + "path": "/tmp/photo-export/B84E8479-475C-4727_IMG_0001.HEIC", 70 + "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447", 71 + "size": 3158112, 72 + "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001" 73 + } 74 + ] 75 + } 76 + ``` 77 + 78 + 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. 79 + 80 + ## Required permissions 81 + 82 + - **Photos access** -- ladder requests read/write authorization on launch. Grant access in System Settings > Privacy & Security > Photos. 83 + - **Full Disk Access** -- may be required depending on how the Photos library is stored. Grant in System Settings > Privacy & Security > Full Disk Access. 84 + 85 + ## Testing 86 + 87 + ``` 88 + swift test 89 + ``` 90 + 91 + Tests use protocol-based dependency injection (`PhotoLibrary` / `AssetHandle` protocols) with mock implementations, so they run without Photos library access. 92 + 93 + ## Project structure 94 + 95 + ``` 96 + Sources/ 97 + CLI/ 98 + Main.swift -- entry point, stdin parsing, authorization 99 + LadderKit/ 100 + Models.swift -- ExportRequest, ExportResponse, ExportResult, ExportError 101 + PhotoExporter.swift -- concurrent export orchestration 102 + PhotoLibrary.swift -- PhotoKit abstraction (protocol + real implementation) 103 + Hasher.swift -- streaming SHA-256 (inline with export) 104 + PathSafety.swift -- filename sanitization and path traversal prevention 105 + Tests/ 106 + ModelsTests.swift 107 + PhotoExporterTests.swift 108 + HasherTests.swift 109 + PathSafetyTests.swift 110 + ``` 111 + 112 + ## How it fits with attic 113 + 114 + 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.