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.

Retry PhotoKit export failures via AppleScript fallback

Previously only assets invisible to fetchAssets() were sent to the
AppleScript path. Now assets that PhotoKit finds but fails to export
(e.g. PHPhotosErrorDomain 3169 for iCloud download failures) are also
retried via AppleScript. Clean up partial files on export failure so
the fallback path can reuse the destination.

+64 -7
+18 -7
Sources/LadderKit/PhotoExporter.swift
··· 58 58 59 59 var allResults: [ExportResult] = [] 60 60 var allErrors: [ExportError] = [] 61 + var photoKitFailedUUIDs: [String] = [] 61 62 for result in exportResults { 62 63 switch result { 63 64 case .success(let exportResult): 64 65 allResults.append(exportResult) 65 66 case .failure(let pair): 66 - allErrors.append(ExportError(uuid: pair.uuid, message: pair.message)) 67 + photoKitFailedUUIDs.append(pair.uuid) 67 68 } 68 69 } 69 70 70 - // AppleScript fallback for missing UUIDs (iCloud-only assets) 71 - if !missingUUIDs.isEmpty { 72 - let (fallbackResults, fallbackErrors) = await exportViaAppleScript(uuids: missingUUIDs) 71 + // AppleScript fallback for: 72 + // 1. UUIDs that PhotoKit couldn't find at all (iCloud-only, invisible to fetchAssets) 73 + // 2. UUIDs that PhotoKit found but failed to export (e.g. iCloud download errors) 74 + let fallbackUUIDs = missingUUIDs + photoKitFailedUUIDs 75 + if !fallbackUUIDs.isEmpty { 76 + let (fallbackResults, fallbackErrors) = await exportViaAppleScript(uuids: fallbackUUIDs) 73 77 allResults.append(contentsOf: fallbackResults) 74 78 allErrors.append(contentsOf: fallbackErrors) 75 79 } ··· 155 159 uuid: String, 156 160 handle: AssetHandle 157 161 ) async -> Result<ExportResult, ExportErrorPair> { 162 + let destURL: URL 158 163 do { 159 - let destURL = try PathSafety.safeDestination( 164 + destURL = try PathSafety.safeDestination( 160 165 stagingDir: stagingDir, 161 166 uuid: uuid, 162 167 originalFilename: handle.originalFilename 163 168 ) 169 + } catch { 170 + return .failure(ExportErrorPair(uuid: uuid, message: error.localizedDescription)) 171 + } 164 172 165 - // Create empty file for writing 166 - FileManager.default.createFile(atPath: destURL.path, contents: nil) 173 + // Create empty file for writing 174 + FileManager.default.createFile(atPath: destURL.path, contents: nil) 167 175 176 + do { 168 177 // Stream data: write to file + hash simultaneously 169 178 let hasher = StreamingHasher() 170 179 let size = try await handle.writeData( ··· 181 190 sha256: sha256 182 191 )) 183 192 } catch { 193 + // Clean up the empty/partial file so AppleScript fallback can reuse the path 194 + try? FileManager.default.removeItem(at: destURL) 184 195 return .failure(ExportErrorPair(uuid: uuid, message: error.localizedDescription)) 185 196 } 186 197 }
+46
Tests/AppleScriptExporterTests.swift
··· 34 34 } 35 35 } 36 36 37 + /// Mock asset handle that always fails (simulates iCloud download failure). 38 + struct FailingAssetHandle: AssetHandle { 39 + let originalFilename: String 40 + let resourceType: PHAssetResourceType = .photo 41 + 42 + func writeData( 43 + to destinationURL: URL, 44 + networkAccessAllowed: Bool, 45 + chunkHandler: @escaping @Sendable (Data) -> Void 46 + ) async throws -> Int64 { 47 + throw NSError(domain: "PHPhotosErrorDomain", code: 3169) 48 + } 49 + } 50 + 37 51 @Suite("AppleScript Exporter") 38 52 struct AppleScriptExporterTests { 39 53 ··· 207 221 208 222 #expect(response.results.count == 1) 209 223 #expect(response.results[0].uuid == "ABC-123/L0/001") 224 + } 225 + 226 + @Test("PhotoKit export failure falls back to AppleScript") 227 + func photoKitFailureFallsBackToAppleScript() async throws { 228 + let tempDir = FileManager.default.temporaryDirectory 229 + .appendingPathComponent("ladder-test-\(UUID().uuidString)") 230 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 231 + defer { try? FileManager.default.removeItem(at: tempDir) } 232 + 233 + let appleScriptData = Data("recovered via applescript".utf8) 234 + 235 + // PhotoKit finds the asset but writeData throws (simulates iCloud download failure) 236 + let library = MockPhotoLibrary(assets: [ 237 + "fail-uuid": FailingAssetHandle(originalFilename: "IMG_FAIL.HEIC"), 238 + ]) 239 + 240 + let scriptExporter = MockScriptExporter(assets: [ 241 + "fail-uuid": ("IMG_FAIL.HEIC", appleScriptData), 242 + ]) 243 + 244 + let exporter = PhotoExporter( 245 + stagingDir: tempDir, 246 + library: library, 247 + scriptExporter: scriptExporter 248 + ) 249 + 250 + let response = await exporter.export(uuids: ["fail-uuid"]) 251 + 252 + // Should succeed via AppleScript fallback, not fail 253 + #expect(response.results.count == 1) 254 + #expect(response.errors.isEmpty) 255 + #expect(response.results[0].uuid == "fail-uuid") 210 256 } 211 257 212 258 // MARK: - Error types