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.

Rewrite README with library API, contract, and data types

+182 -68
+182 -68
README.md
··· 4 4 5 5 # ladder 6 6 7 - A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit. 7 + 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. 8 8 9 - ## What it does 9 + ## LadderKit Library 10 10 11 - 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. 11 + Add LadderKit as a dependency in your `Package.swift`: 12 12 13 - ## How it works 13 + ```swift 14 + dependencies: [ 15 + .package(url: "https://github.com/tijs/ladder", from: "0.2.0"), 16 + ], 17 + targets: [ 18 + .target( 19 + name: "YourApp", 20 + dependencies: [.product(name: "LadderKit", package: "ladder")] 21 + ), 22 + ] 23 + ``` 14 24 15 - 1. Reads a JSON `ExportRequest` from stdin (or from a file path passed as the first argument) 16 - 2. Requests Photos library authorization via PhotoKit 17 - 3. Fetches assets by their local identifiers (UUIDs) 18 - 4. Exports each asset's original resource to the staging directory with bounded concurrency (default: 6) 19 - 5. Computes SHA-256 inline while streaming data to disk (no second pass over the file) 20 - 6. Writes a JSON `ExportResponse` to stdout 25 + Requires macOS 13+. 21 26 22 - ## Installing 27 + ## API 23 28 24 - Requires macOS 13+ and Swift 5.9+. 29 + ### Asset Discovery 30 + 31 + Enumerate all non-trashed assets in the Photos library via PhotoKit: 32 + 33 + ```swift 34 + import LadderKit 25 35 36 + let library = PhotoKitLibrary() 37 + let count = library.totalAssetCount() 38 + var assets = library.enumerateAssets() 39 + // assets: [AssetInfo] sorted by creation date, newest first 26 40 ``` 27 - make install 41 + 42 + Each `AssetInfo` contains core PhotoKit fields: identifier, creation date, media type, dimensions, GPS location, and favorite status. 43 + 44 + ### Metadata Enrichment 45 + 46 + PhotoKit doesn't expose keywords, people, descriptions, albums, filenames, or edit details. These come from Photos.sqlite: 47 + 48 + ```swift 49 + // User selects their .photoslibrary bundle (e.g., via NSOpenPanel) 50 + let libraryURL: URL = ... 51 + 52 + // Validate and derive database path 53 + guard PhotosLibraryPath.validate(libraryURL).isValid, 54 + let dbPath = PhotosLibraryPath.databasePath(for: libraryURL) 55 + else { return } 56 + 57 + // Read enrichment data and apply it 58 + let enrichment = PhotosDatabase.readEnrichment(dbPath: dbPath) 59 + PhotosDatabase.enrich(&assets, with: enrichment) 60 + 61 + // assets now have: originalFilename, uniformTypeIdentifier, albums, 62 + // keywords, people, description, hasEdit, editedAt, editor 28 63 ``` 29 64 30 - This builds a release binary and installs it to `/usr/local/bin/ladder`. To install elsewhere: 65 + ### File Export 66 + 67 + Export original photo/video files to a staging directory with inline SHA-256 hashing: 31 68 69 + ```swift 70 + let stagingDir = try PathSafety.validateStagingDir("/tmp/photo-export") 71 + let exporter = PhotoExporter(stagingDir: stagingDir, library: library) 72 + let response = await exporter.export(uuids: ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"]) 73 + 74 + for result in response.results { 75 + print(result.path) // exported file path 76 + print(result.sha256) // hash computed during streaming write 77 + print(result.size) // file size in bytes 78 + } 32 79 ``` 33 - make install PREFIX=~/.local 80 + 81 + 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). 82 + 83 + ### Standalone Hashing 84 + 85 + ```swift 86 + // Streaming hasher for incremental use 87 + let hasher = StreamingHasher() 88 + hasher.update(chunk1) 89 + hasher.update(chunk2) 90 + let hash = hasher.finalize() // hex-encoded SHA-256 91 + 92 + // One-shot file hashing (8 MB chunks, memory-efficient) 93 + let fileHash = try FileHasher.sha256(fileAt: fileURL) 34 94 ``` 35 95 36 - To uninstall: 96 + ## API Contract 97 + 98 + ### Provided (what LadderKit gives you) 99 + 100 + | Protocol / Type | Purpose | 101 + |---|---| 102 + | `PhotoLibrary` | Asset discovery and fetch by identifier | 103 + | `AssetHandle` | Single asset's exportable resource | 104 + | `PhotoExporter` | Concurrent export with inline hashing | 105 + | `PhotosDatabase` | Photos.sqlite enrichment reader | 106 + | `PhotosLibraryPath` | Library bundle validation and path derivation | 107 + | `StreamingHasher` | Incremental SHA-256 | 108 + | `FileHasher` | One-shot file SHA-256 | 109 + | `PathSafety` | Filename sanitization and path traversal prevention | 110 + 111 + ### Required (what your app must provide) 112 + 113 + | Requirement | How | 114 + |---|---| 115 + | **Photos permission** | Call `PHPhotoLibrary.requestAuthorization(for: .readWrite)` before using `PhotoKitLibrary` | 116 + | **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. | 117 + | **Staging directory** | Provide an absolute path for exported files. Validate with `PathSafety.validateStagingDir()`. | 118 + 119 + ### Data Types 120 + 121 + **AssetInfo** — metadata for a single photo or video: 37 122 38 123 ``` 39 - make uninstall 124 + identifier String PhotoKit local identifier (e.g., "UUID/L0/001") 125 + uuid String UUID portion extracted from identifier 126 + creationDate Date? when the photo was taken 127 + kind AssetKind .photo (0) or .video (1) 128 + pixelWidth Int 129 + pixelHeight Int 130 + latitude Double? GPS coordinates 131 + longitude Double? 132 + isFavorite Bool 133 + originalFilename String? from Photos.sqlite enrichment 134 + uniformTypeIdentifier String? e.g., "public.heic" 135 + hasEdit Bool true when both adjustment + rendered resource exist 136 + albums [AlbumInfo] album membership 137 + keywords [String] user-assigned keywords 138 + people [PersonInfo] recognized faces with names 139 + assetDescription String? user-written caption (JSON key: "description") 140 + editedAt Date? when the edit was made 141 + editor String? editor identifier (e.g., "com.apple.photos") 40 142 ``` 41 143 42 - ## Usage 144 + `AssetInfo` conforms to `Codable`. The `assetDescription` field serializes as `"description"` in JSON. 145 + 146 + **AlbumInfo** — `{ identifier: String, title: String }` 147 + 148 + **PersonInfo** — `{ uuid: String, displayName: String }` 149 + 150 + **ExportResult** — `{ uuid: String, path: String, size: Int64, sha256: String }` 151 + 152 + ### Testability 153 + 154 + All external dependencies are behind protocols: 155 + 156 + - `PhotoLibrary` — inject a mock that returns pre-configured assets 157 + - `AssetHandle` — inject a mock that writes known data 158 + 159 + Tests run without Photos library access, Photos permission, or network. See `Tests/PhotoExporterTests.swift` for examples. 160 + 161 + ## CLI 162 + 163 + The CLI wraps LadderKit for use as a subprocess (used by [attic](https://github.com/tijs/attic)). 43 164 44 - ### Input (ExportRequest) 165 + ### Installing 45 166 46 - ```json 47 - { 48 - "uuids": [ 49 - "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001", 50 - "3FA8GH5M-BMMH-H123-ABCD-1234567890AB/L0/001" 51 - ], 52 - "stagingDir": "/tmp/photo-export" 53 - } 167 + ``` 168 + make install 54 169 ``` 55 170 56 - - `uuids` -- Photos library local identifiers to export 57 - - `stagingDir` -- absolute path where exported files will be written (must not be inside system directories like `/System`, `/Library`, `/usr`, etc.) 171 + Installs to `/usr/local/bin/ladder`. Use `make install PREFIX=~/.local` for a different location. 58 172 59 - ### Running 173 + ### Usage 60 174 61 175 ```bash 62 - # From stdin 63 - echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | .build/release/ladder 176 + echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | ladder 177 + # or 178 + ladder request.json 179 + ``` 64 180 65 - # From a file 66 - .build/release/ladder request.json 181 + **Input** (`ExportRequest`): 182 + ```json 183 + { 184 + "uuids": ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"], 185 + "stagingDir": "/tmp/photo-export" 186 + } 67 187 ``` 68 188 69 - ### Output (ExportResponse) 70 - 71 - Written to stdout: 72 - 189 + **Output** (`ExportResponse`): 73 190 ```json 74 191 { 75 - "errors": [ 76 - { 77 - "message": "Asset not found in Photos library", 78 - "uuid": "missing-uuid" 79 - } 80 - ], 81 192 "results": [ 82 193 { 194 + "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001", 83 195 "path": "/tmp/photo-export/B84E8479-475C-4727_IMG_0001.HEIC", 84 - "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447", 85 196 "size": 3158112, 86 - "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001" 197 + "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" 87 198 } 199 + ], 200 + "errors": [ 201 + { "uuid": "missing-uuid", "message": "Asset not found in Photos library" } 88 202 ] 89 203 } 90 204 ``` 91 205 92 - 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. 93 - 94 - ## Required permissions 95 - 96 - - **Photos access** -- ladder requests read/write authorization on launch. Grant access in System Settings > Privacy & Security > Photos. 97 - - **Full Disk Access** -- may be required depending on how the Photos library is stored. Grant in System Settings > Privacy & Security > Full Disk Access. 98 - 99 - ## Testing 100 - 101 - ``` 102 - swift test 103 - ``` 206 + ### Required permissions 104 207 105 - Tests use protocol-based dependency injection (`PhotoLibrary` / `AssetHandle` protocols) with mock implementations, so they run without Photos library access. 208 + - **Photos access** — grant in System Settings > Privacy & Security > Photos 209 + - **Full Disk Access** — may be needed depending on library location 106 210 107 211 ## Project structure 108 212 109 213 ``` 110 214 Sources/ 111 215 CLI/ 112 - Main.swift -- entry point, stdin parsing, authorization 216 + Main.swift entry point, stdin/stdout JSON protocol 113 217 LadderKit/ 114 - Models.swift -- ExportRequest, ExportResponse, ExportResult, ExportError 115 - PhotoExporter.swift -- concurrent export orchestration 116 - PhotoLibrary.swift -- PhotoKit abstraction (protocol + real implementation) 117 - Hasher.swift -- streaming SHA-256 (inline with export) 118 - PathSafety.swift -- filename sanitization and path traversal prevention 218 + AssetInfo.swift AssetInfo, AssetKind, AlbumInfo, PersonInfo 219 + PhotoLibrary.swift PhotoLibrary protocol + PhotoKit implementation 220 + PhotoExporter.swift concurrent export with inline hashing 221 + PhotosDatabase.swift Photos.sqlite enrichment reader 222 + PhotosLibraryPath.swift library bundle validation 223 + Hasher.swift StreamingHasher + FileHasher 224 + Models.swift ExportRequest, ExportResponse (CLI types) 225 + PathSafety.swift filename sanitization, path traversal prevention 119 226 Tests/ 120 - ModelsTests.swift 227 + AssetInfoTests.swift 121 228 PhotoExporterTests.swift 229 + PhotosDatabaseTests.swift 230 + PhotosLibraryPathTests.swift 122 231 HasherTests.swift 232 + ModelsTests.swift 123 233 PathSafetyTests.swift 124 234 ``` 125 235 126 - ## How it fits with attic 236 + ## Testing 237 + 238 + ``` 239 + swift test 240 + ``` 127 241 128 - 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. 242 + 44 tests across 8 suites. All tests use mock implementations — no Photos library, credentials, or network required.