Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

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

feat: retry queue, staging reuse, and upload reliability (1.0.0-beta.4)

Failed assets now retry first on the next run via a local queue
(~/.attic/retry-queue.json). Staging directory is persisted at
~/.attic/staging/ so already-exported files (including iCloud downloads)
are reclaimed across runs instead of re-exported, with dedup when
PhotoKit and AppleScript both produce a copy.

Upload path simplified: removed the nested TaskGroup timeout race that
caused a rare malloc heap corruption abort mid-upload. URLSession's
built-in timeoutIntervalForResource provides the per-upload cap.

Also switches date formatting to Date.ISO8601FormatStyle (value-type,
concurrency-safe) and tightens the terminal renderer's locking.

+602 -100
+27
CLAUDE.md
··· 97 97 traversal prevention) 98 98 - All dependencies injected via protocols 99 99 - Swift 6 strict concurrency — all types are `Sendable` where needed 100 + 101 + ## Design Context 102 + 103 + ### Users 104 + Photo-enthusiast developers — people at the intersection of "comfortable running 105 + S3 infrastructure" and "cares deeply about their photo library." They use Attic 106 + to verify and browse their backed-up iCloud Photos library through a local web 107 + viewer. 108 + 109 + ### Brand Personality 110 + **Precise, trustworthy, quiet.** Attic fades into the background and just works. 111 + Every element serves a purpose — nothing decorative, nothing performative. 112 + 113 + ### Aesthetic Direction 114 + - **Tone:** Confident and precise — like a well-made instrument. 115 + - **References:** Transmit/Panic apps (detail-obsessed, personality without 116 + frivolity), Linear/Raycast (fast, minimal chrome, every pixel earned). 117 + - **Anti-references:** No generic dark SaaS (blue-on-dark card grids). No dev 118 + dashboard aesthetics (no monospace, no terminal vibes in the web UI). 119 + - **Theme:** Designer's choice — serve the "quiet, precise" personality. 120 + 121 + ### Design Principles 122 + 1. **Photos first** — Minimize chrome, maximize content. 123 + 2. **Earned trust** — Reliable, precise status and metadata. No vague states. 124 + 3. **Quiet confidence** — Hierarchy through spacing, weight, and restraint. 125 + 4. **Intentional details** — Small touches that reward attention. 126 + 5. **Not a prototype** — Handles empty states, loading, and edge cases.
+12 -3
Sources/AtticCLI/BackupCommand.swift
··· 41 41 default: nil 42 42 } 43 43 44 - let stagingDir = FileManager.default.temporaryDirectory 45 - .appendingPathComponent("attic-staging-\(UUID().uuidString)") 44 + // Stable staging dir — files persist across runs for reuse, cleaned per-asset after upload 45 + let stagingDir = FileConfigProvider.defaultDirectory.appendingPathComponent("staging") 46 46 try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) 47 - defer { try? FileManager.default.removeItem(at: stagingDir) } 48 47 49 48 let exporter = LadderKitExportProvider(stagingDir: stagingDir) 50 49 ··· 61 60 limit: limit, 62 61 type: assetKind, 63 62 dryRun: dryRun, 63 + stagingDir: stagingDir, 64 64 ) 65 65 66 66 // Prevent idle sleep during backup (released automatically via deinit) ··· 75 75 options: options, 76 76 progress: progress, 77 77 networkMonitor: NWPathNetworkMonitor(), 78 + retryQueue: FileRetryQueueStore(), 78 79 ) 79 80 80 81 _ = powerAssertion // prevent unused warning, released in deinit ··· 95 96 96 97 func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) { 97 98 print("Batch \(batchNumber)/\(totalBatches) (\(assetCount) assets)") 99 + } 100 + 101 + func assetStarting(uuid: String, filename: String, size: Int) { 102 + print(" → \(filename) (\(formatBytes(size)))") 103 + } 104 + 105 + func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) { 106 + print(" ↻ \(filename) — retry \(attempt)/\(maxAttempts)") 98 107 } 99 108 100 109 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) {
+54 -11
Sources/AtticCLI/TerminalRenderer.swift
··· 13 13 private var lastRenderTime: Date? 14 14 private let spinner: PreparationSpinner? 15 15 private var originalTermios: termios? 16 + private var tickTask: Task<Void, Never>? 16 17 17 18 init(spinner: PreparationSpinner? = nil) { 18 19 self.spinner = spinner ··· 66 67 startTime = Date() 67 68 } 68 69 render() 70 + startTick() 71 + } 72 + 73 + /// Refresh the display every second so elapsed time and speed stay current. 74 + private func startTick() { 75 + tickTask = Task { [weak self] in 76 + while !Task.isCancelled { 77 + try? await Task.sleep(for: .seconds(1)) 78 + guard !Task.isCancelled else { break } 79 + self?.render() 80 + } 81 + } 69 82 } 70 83 71 84 func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) { ··· 76 89 render() 77 90 } 78 91 92 + func assetStarting(uuid: String, filename: String, size: Int) { 93 + lock.withLock { 94 + state.currentFile = "\(filename) (\(formatBytes(size)))" 95 + } 96 + render() 97 + } 98 + 79 99 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) { 80 100 lock.withLock { 81 101 state.uploaded += 1 ··· 86 106 render() 87 107 } 88 108 109 + func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) { 110 + lock.withLock { 111 + state.currentFile = "\u{1b}[33m\(filename) — retry \(attempt)/\(maxAttempts)\u{1b}[0m" 112 + } 113 + render() 114 + } 115 + 89 116 func assetFailed(uuid: String, filename: String, message: String) { 90 117 lock.withLock { 91 118 state.failed += 1 92 119 state.currentFile = "\(filename) — \(message)" 93 - state.failedAssets.append((filename: filename, message: message)) 120 + if state.failedAssets.count < 50 { 121 + state.failedAssets.append((filename: filename, message: message)) 122 + } 94 123 } 95 124 render() 96 125 } ··· 121 150 } 122 151 123 152 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 153 + tickTask?.cancel() 154 + tickTask = nil 124 155 lock.withLock { 125 156 state.uploaded = uploaded 126 157 state.failed = failed ··· 199 230 200 231 let s: RenderState = lock.withLock { state } 201 232 let elapsed = lock.withLock { startTime.map { Date().timeIntervalSince($0) } ?? 0 } 233 + let speed = elapsed > 0 ? Double(s.totalBytes) / elapsed : 0 202 234 203 - // Clear the live display 235 + // Overwrite the live display in-place 204 236 let lineCount = 8 205 237 print("\u{1b}[\(lineCount)A", terminator: "") 206 - for _ in 0 ..< lineCount { 207 - print("\u{1b}[2K") 208 - } 209 - print("\u{1b}[\(lineCount)A", terminator: "") 210 238 211 - print("Backup complete in \(formatDuration(elapsed))") 212 - print(" Uploaded: \(s.uploaded) (\(formatBytes(s.totalBytes)))") 239 + let status = s.failed > 0 ? "Completed with \(s.failed) error\(s.failed == 1 ? "" : "s")" : "Complete" 240 + print("\u{1b}[2K \u{1b}[32m✓\u{1b}[0m \(status) — \(formatBytes(s.totalBytes)) in \(formatDuration(elapsed))") 241 + print("\u{1b}[2K Photos \(s.uploadedPhotos) uploaded") 242 + print("\u{1b}[2K Videos \(s.uploadedVideos) uploaded") 243 + print("\u{1b}[2K Speed \(formatBytes(Int(speed)))/s avg") 244 + print("\u{1b}[2K Errors \(s.failed)") 245 + 246 + // Use remaining lines for failures or clear them 213 247 if s.failed > 0 { 214 - print(" Failed: \(s.failed)") 215 - print("") 216 - print("Failed assets:") 248 + print("\u{1b}[2K") 249 + print("\u{1b}[2K Failed assets:") 250 + // Clear the last live-display line (Elapsed) before printing failure details 251 + print("\u{1b}[2K", terminator: "") 217 252 for failure in s.failedAssets { 218 253 print(" ✗ \(failure.filename): \(failure.message)") 219 254 } 255 + if s.failed > s.failedAssets.count { 256 + print(" ... and \(s.failed - s.failedAssets.count) more") 257 + } 220 258 print("") 221 259 print("Tip: Run `attic backup` again to retry failed assets.") 260 + } else { 261 + // Clear the remaining 3 lines (blank, Current, Elapsed) 262 + for _ in 0 ..< 3 { 263 + print("\u{1b}[2K") 264 + } 222 265 } 223 266 224 267 fflush(stdout)
+1 -1
Sources/AtticCore/AtticCore.swift
··· 2 2 /// 3 3 /// Used by both the Attic CLI and the Attic menu bar app. 4 4 public enum AtticCore { 5 - public static let version = "1.0.0-beta.3" 5 + public static let version = "1.0.0-beta.4" 6 6 }
+12
Sources/AtticCore/BackupConstants.swift
··· 1 + import Foundation 2 + 3 + /// Format a date as ISO 8601. Uses the value-type `Date.ISO8601FormatStyle` 4 + /// instead of `ISO8601DateFormatter` (an NSObject subclass that is not 5 + /// thread-safe when shared and can cause malloc zone issues when many 6 + /// instances are created/destroyed concurrently). 7 + func formatISO8601(_ date: Date) -> String { 8 + date.formatted(.iso8601) 9 + } 10 + 11 + /// Maximum number of errors to keep in a report (prevents unbounded growth). 12 + let maxReportErrors = 1000
+106 -63
Sources/AtticCore/BackupPipeline.swift
··· 1 1 import Foundation 2 2 import LadderKit 3 3 4 - /// Shared ISO8601 formatter — reused across all pipeline operations. 5 - nonisolated(unsafe) let isoFormatter = ISO8601DateFormatter() 6 - 7 - /// Shared JSON encoder for metadata uploads. 8 - let metadataEncoder: JSONEncoder = { 9 - let encoder = JSONEncoder() 10 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 11 - return encoder 12 - }() 13 - 14 - /// Maximum number of errors to keep in a report (prevents unbounded growth). 15 - let maxReportErrors = 1000 16 - 17 4 /// Options controlling backup behavior. 18 5 public struct BackupOptions: Sendable { 19 6 public var batchSize: Int ··· 24 11 public var concurrency: Int 25 12 public var networkTimeout: Duration 26 13 public var maxPauseRetries: Int 14 + public var stagingDir: URL? 27 15 28 16 public init( 29 - batchSize: Int = 50, 17 + batchSize: Int = 10, 30 18 limit: Int = 0, 31 19 type: AssetKind? = nil, 32 20 dryRun: Bool = false, 33 - saveInterval: Int = 50, 21 + saveInterval: Int = 25, 34 22 concurrency: Int = 6, 35 23 networkTimeout: Duration = .seconds(900), 36 24 maxPauseRetries: Int = 3, 25 + stagingDir: URL? = nil, 37 26 ) { 38 27 self.batchSize = batchSize 39 28 self.limit = limit ··· 43 32 self.concurrency = concurrency 44 33 self.networkTimeout = networkTimeout 45 34 self.maxPauseRetries = maxPauseRetries 35 + self.stagingDir = stagingDir 46 36 } 47 37 } 48 38 ··· 72 62 options: BackupOptions = BackupOptions(), 73 63 progress: any BackupProgressDelegate = NullProgressDelegate(), 74 64 networkMonitor: (any NetworkMonitoring)? = nil, 65 + retryQueue: (any RetryQueueProviding)? = nil, 75 66 ) async throws -> BackupReport { 76 67 // Filter to pending assets, optionally by type 77 68 var pending = assets.filter { asset in ··· 80 71 return true 81 72 } 82 73 74 + // Partition retry-queue UUIDs to the front so failed assets are retried first 75 + if let retryUUIDs = retryQueue?.load()?.failedUUIDs { 76 + let retrySet = Set(retryUUIDs) 77 + _ = pending.partition { !retrySet.contains($0.uuid) } 78 + } 79 + 83 80 // Apply limit 84 81 if options.limit > 0 { 85 82 pending = Array(pending.prefix(options.limit)) ··· 140 137 assetCount: batch.count, 141 138 ) 142 139 143 - // 1. Export via LadderKit 140 + // 1. Reclaim previously-staged files, then export the rest via LadderKit 141 + var reclaimedResults: [ExportResult] = [] 142 + var uuidsToExport = batchUUIDs 143 + if let stagingDir = options.stagingDir { 144 + let reclaim = reclaimStagedFiles(uuids: batchUUIDs, stagingDir: stagingDir) 145 + reclaimedResults = reclaim.reclaimed 146 + uuidsToExport = reclaim.remaining 147 + } 148 + 144 149 let batchResult: ExportResponse 145 - do { 146 - batchResult = try await exporter.exportBatch(uuids: batchUUIDs) 147 - } catch let error as ExportProviderError where error.isTimeout { 148 - // Batch timeout: retry each asset individually 149 - var combinedResults: [ExportResult] = [] 150 - var combinedErrors: [LadderKit.ExportError] = [] 151 - for uuid in batchUUIDs { 152 - try Task.checkCancellation() 153 - do { 154 - let result = try await exporter.exportBatch(uuids: [uuid]) 155 - combinedResults.append(contentsOf: result.results) 156 - combinedErrors.append(contentsOf: result.errors) 157 - } catch let innerError as ExportProviderError where innerError.isTimeout { 158 - deferred.append(uuid) 159 - } catch { 160 - let msg = String(describing: error) 150 + if uuidsToExport.isEmpty { 151 + batchResult = ExportResponse(results: reclaimedResults, errors: []) 152 + } else { 153 + do { 154 + let exported = try await exporter.exportBatch(uuids: uuidsToExport) 155 + batchResult = ExportResponse( 156 + results: reclaimedResults + exported.results, 157 + errors: exported.errors, 158 + ) 159 + } catch let error as ExportProviderError where error.isTimeout { 160 + // Batch timeout: retry each asset individually 161 + var combinedResults: [ExportResult] = reclaimedResults 162 + var combinedErrors: [LadderKit.ExportError] = [] 163 + for uuid in uuidsToExport { 164 + try Task.checkCancellation() 165 + do { 166 + let result = try await exporter.exportBatch(uuids: [uuid]) 167 + combinedResults.append(contentsOf: result.results) 168 + combinedErrors.append(contentsOf: result.errors) 169 + } catch let innerError as ExportProviderError where innerError.isTimeout { 170 + deferred.append(uuid) 171 + } catch { 172 + let msg = String(describing: error) 173 + report.appendError(uuid: uuid, message: msg) 174 + report.failed += 1 175 + let filename = assetByUUID[uuid]?.originalFilename ?? uuid 176 + progress.assetFailed(uuid: uuid, filename: filename, message: msg) 177 + } 178 + } 179 + let combined = ExportResponse(results: combinedResults, errors: combinedErrors) 180 + try await uploadExported( 181 + combined, ctx: ctx, 182 + manifest: &manifest, report: &report, 183 + sinceLastSave: &sinceLastSave, 184 + ) 185 + continue 186 + } catch let error as ExportProviderError where error.isPermission { 187 + // Permission error: abort all remaining batches 188 + let msg = String(describing: error) 189 + for uuid in batchUUIDs { 190 + report.appendError(uuid: uuid, message: msg) 191 + report.failed += 1 192 + } 193 + for asset in pending[end...] { 194 + report.appendError(uuid: asset.uuid, message: msg) 195 + report.failed += 1 196 + } 197 + break 198 + } catch { 199 + // Non-timeout error: fail the whole batch 200 + let msg = String(describing: error) 201 + for uuid in batchUUIDs { 161 202 report.appendError(uuid: uuid, message: msg) 162 203 report.failed += 1 163 204 let filename = assetByUUID[uuid]?.originalFilename ?? uuid 164 205 progress.assetFailed(uuid: uuid, filename: filename, message: msg) 165 206 } 166 - } 167 - let combined = ExportResponse(results: combinedResults, errors: combinedErrors) 168 - try await uploadExported( 169 - combined, ctx: ctx, 170 - manifest: &manifest, report: &report, 171 - sinceLastSave: &sinceLastSave, 172 - ) 173 - continue 174 - } catch let error as ExportProviderError where error.isPermission { 175 - // Permission error: abort all remaining batches 176 - let msg = String(describing: error) 177 - for uuid in batchUUIDs { 178 - report.appendError(uuid: uuid, message: msg) 179 - report.failed += 1 180 - } 181 - for asset in pending[end...] { 182 - report.appendError(uuid: asset.uuid, message: msg) 183 - report.failed += 1 184 - } 185 - break 186 - } catch { 187 - // Non-timeout error: fail the whole batch 188 - let msg = String(describing: error) 189 - for uuid in batchUUIDs { 190 - report.appendError(uuid: uuid, message: msg) 191 - report.failed += 1 192 - let filename = assetByUUID[uuid]?.originalFilename ?? uuid 193 - progress.assetFailed(uuid: uuid, filename: filename, message: msg) 207 + continue 194 208 } 195 - continue 196 209 } 197 210 198 211 // 2. Upload exported assets ··· 238 251 progress.manifestSaved(entriesCount: manifest.entries.count) 239 252 } 240 253 254 + // Update retry queue: save failed UUIDs for next run, or clear on full success 255 + if report.errors.isEmpty { 256 + do { 257 + try retryQueue?.clear() 258 + } catch { 259 + debugPrint("Failed to clear retry queue: \(error)") 260 + } 261 + } else { 262 + let failedUUIDs = report.errors.map(\.uuid) 263 + let queue = RetryQueue( 264 + failedUUIDs: failedUUIDs, 265 + updatedAt: formatISO8601(Date()), 266 + ) 267 + do { 268 + try retryQueue?.save(queue) 269 + } catch { 270 + debugPrint("Failed to save retry queue: \(error)") 271 + } 272 + } 273 + 241 274 progress.backupCompleted( 242 275 uploaded: report.uploaded, 243 276 failed: report.failed, ··· 317 350 for _ in 0 ..< min(effectiveConcurrency, inputs.count) { 318 351 let input = inputs[cursor] 319 352 cursor += 1 353 + ctx.progress.assetStarting( 354 + uuid: input.asset.uuid, 355 + filename: input.asset.originalFilename ?? "unknown", 356 + size: actualFileSize(input), 357 + ) 320 358 group.addTask { 321 - await uploadSingleAsset(input: input, s3: ctx.s3) 359 + await uploadSingleAsset(input: input, s3: ctx.s3, progress: ctx.progress) 322 360 } 323 361 } 324 362 ··· 383 421 if !networkPaused, cursor < inputs.count { 384 422 let input = inputs[cursor] 385 423 cursor += 1 424 + ctx.progress.assetStarting( 425 + uuid: input.asset.uuid, 426 + filename: input.asset.originalFilename ?? "unknown", 427 + size: actualFileSize(input), 428 + ) 386 429 group.addTask { 387 - await uploadSingleAsset(input: input, s3: ctx.s3) 430 + await uploadSingleAsset(input: input, s3: ctx.s3, progress: ctx.progress) 388 431 } 389 432 } 390 433 }
+10
Sources/AtticCore/BackupProgress.swift
··· 11 11 /// A batch is starting. 12 12 func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) 13 13 14 + /// A single asset is about to start uploading. 15 + func assetStarting(uuid: String, filename: String, size: Int) 16 + 14 17 /// A single asset was uploaded successfully. 15 18 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) 16 19 20 + /// A single asset upload is being retried after a transient error. 21 + func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) 22 + 17 23 /// A single asset failed. 18 24 func assetFailed(uuid: String, filename: String, message: String) 19 25 ··· 32 38 33 39 /// Default no-op implementations for optional delegate methods. 34 40 public extension BackupProgressDelegate { 41 + func assetStarting(uuid: String, filename: String, size: Int) {} 42 + func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) {} 35 43 func backupPaused(reason: String) {} 36 44 func backupResumed() {} 37 45 } ··· 41 49 public init() {} 42 50 public func backupStarted(pending: Int, photos: Int, videos: Int) {} 43 51 public func batchStarted(batchNumber: Int, totalBatches: Int, assetCount: Int) {} 52 + public func assetStarting(uuid: String, filename: String, size: Int) {} 53 + public func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) {} 44 54 public func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) {} 45 55 public func assetFailed(uuid: String, filename: String, message: String) {} 46 56 public func manifestSaved(entriesCount: Int) {}
+35 -15
Sources/AtticCore/BackupUpload.swift
··· 22 22 let ext: String 23 23 } 24 24 25 + /// Actual file size on disk (export metadata size can be stale for iCloud-downloaded assets). 26 + func actualFileSize(_ input: UploadInput) -> Int { 27 + if let attrs = try? FileManager.default.attributesOfItem(atPath: input.exported.path), 28 + let size = attrs[.size] as? Int, size > 0 29 + { 30 + return size 31 + } 32 + return Int(input.exported.size) 33 + } 34 + 25 35 /// Upload a single asset (original + metadata) to S3. Returns an UploadResult. 26 36 /// 27 37 /// This is a pure `@Sendable` function — no mutation of shared state. ··· 29 39 func uploadSingleAsset( 30 40 input: UploadInput, 31 41 s3: any S3Providing, 42 + progress: (any BackupProgressDelegate)? = nil, 32 43 ) async -> UploadResult { 33 44 let exported = input.exported 34 45 let asset = input.asset 35 46 let s3Key = input.s3Key 36 47 let ext = input.ext 48 + let filename = asset.originalFilename ?? "unknown" 49 + 50 + let fileURL = URL(fileURLWithPath: exported.path) 51 + let actualSize = actualFileSize(input) 37 52 38 53 do { 39 54 // Upload original via file URL (avoids loading into memory) 40 - let fileURL = URL(fileURLWithPath: exported.path) 41 - try await withRetry { 42 - try await s3.putObject( 43 - key: s3Key, 44 - fileURL: fileURL, 45 - contentType: contentTypeForExtension(ext), 46 - ) 47 - } 55 + try await withRetry( 56 + onRetry: { attempt, max in 57 + progress?.assetRetrying( 58 + uuid: asset.uuid, filename: filename, attempt: attempt, maxAttempts: max, 59 + ) 60 + }, 61 + operation: { 62 + try await s3.putObject( 63 + key: s3Key, 64 + fileURL: fileURL, 65 + contentType: contentTypeForExtension(ext), 66 + ) 67 + } 68 + ) 48 69 49 - // Build and upload metadata (per-call formatter for thread safety) 50 - let formatter = ISO8601DateFormatter() 51 - let isoNow = formatter.string(from: Date()) 70 + // Build and upload metadata 71 + let isoNow = formatISO8601(Date()) 52 72 let meta = buildMetadataJSON( 53 73 asset: asset, 54 74 s3Key: s3Key, ··· 71 91 uuid: asset.uuid, 72 92 s3Key: s3Key, 73 93 checksum: "sha256:\(exported.sha256)", 74 - filename: asset.originalFilename ?? "unknown", 94 + filename: filename, 75 95 type: asset.kind, 76 - size: Int(exported.size), 96 + size: actualSize, 77 97 error: nil, 78 98 isNetworkDownError: false, 79 99 path: exported.path, ··· 83 103 uuid: asset.uuid, 84 104 s3Key: s3Key, 85 105 checksum: nil, 86 - filename: asset.originalFilename ?? "unknown", 106 + filename: filename, 87 107 type: asset.kind, 88 - size: Int(exported.size), 108 + size: actualSize, 89 109 error: String(describing: error), 90 110 isNetworkDownError: isNetworkDown(error), 91 111 path: exported.path,
+1 -1
Sources/AtticCore/Manifest.swift
··· 48 48 uuid: uuid, 49 49 s3Key: s3Key, 50 50 checksum: checksum, 51 - backedUpAt: backedUpAt ?? isoFormatter.string(from: Date()), 51 + backedUpAt: backedUpAt ?? formatISO8601(Date()), 52 52 size: size, 53 53 ) 54 54 }
+2 -2
Sources/AtticCore/MetadataBuilder.swift
··· 58 58 AssetMetadata( 59 59 uuid: asset.uuid, 60 60 originalFilename: asset.originalFilename ?? "unknown", 61 - dateCreated: asset.creationDate.map { isoFormatter.string(from: $0) }, 61 + dateCreated: asset.creationDate.map { formatISO8601($0) }, 62 62 width: asset.pixelWidth, 63 63 height: asset.pixelHeight, 64 64 latitude: asset.latitude, ··· 72 72 keywords: asset.keywords, 73 73 people: asset.people.map { PersonRef(uuid: $0.uuid, displayName: $0.displayName) }, 74 74 hasEdit: asset.hasEdit, 75 - editedAt: asset.editedAt.map { isoFormatter.string(from: $0) }, 75 + editedAt: asset.editedAt.map { formatISO8601($0) }, 76 76 editor: asset.editor, 77 77 s3Key: s3Key, 78 78 checksum: checksum,
+1 -1
Sources/AtticCore/RebuildManifest.swift
··· 42 42 uuid: parsed.uuid, 43 43 s3Key: parsed.s3Key, 44 44 checksum: parsed.checksum, 45 - backedUpAt: parsed.backedUpAt ?? isoFormatter.string(from: Date()), 45 + backedUpAt: parsed.backedUpAt ?? formatISO8601(Date()), 46 46 ) 47 47 report.recovered += 1 48 48 } catch {
+3 -1
Sources/AtticCore/RefreshMetadata.swift
··· 152 152 checksum: entry.checksum, 153 153 backedUpAt: entry.backedUpAt, 154 154 ) 155 - let data = try metadataEncoder.encode(meta) 155 + let encoder = JSONEncoder() 156 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 157 + let data = try encoder.encode(meta) 156 158 let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 157 159 158 160 try await withRetry {
+3
Sources/AtticCore/RetryPolicy.swift
··· 10 10 maxAttempts: Int = 3, 11 11 baseDelay: Duration = .seconds(1), 12 12 maxDelay: Duration = .seconds(30), 13 + onRetry: (@Sendable (_ attempt: Int, _ maxAttempts: Int) -> Void)? = nil, 13 14 operation: @Sendable () async throws -> T, 14 15 ) async throws -> T { 15 16 for attempt in 1 ... maxAttempts { ··· 26 27 27 28 // Only retry on transient server errors 28 29 guard isTransient(error) else { throw error } 30 + 31 + onRetry?(attempt + 1, maxAttempts) 29 32 30 33 // Exponential backoff with jitter 31 34 let exponential = baseDelay * Int(pow(2.0, Double(attempt - 1)))
+51
Sources/AtticCore/RetryQueue.swift
··· 1 + import Foundation 2 + 3 + /// UUIDs that failed in the most recent backup run, persisted for retry-first priority. 4 + public struct RetryQueue: Codable, Sendable { 5 + public var failedUUIDs: [String] 6 + public var updatedAt: String 7 + 8 + public init(failedUUIDs: [String], updatedAt: String) { 9 + self.failedUUIDs = failedUUIDs 10 + self.updatedAt = updatedAt 11 + } 12 + } 13 + 14 + /// Persistence for the retry queue. 15 + public protocol RetryQueueProviding: Sendable { 16 + func load() -> RetryQueue? 17 + func save(_ queue: RetryQueue) throws 18 + func clear() throws 19 + } 20 + 21 + /// File-backed retry queue at `~/.attic/retry-queue.json`. 22 + public struct FileRetryQueueStore: RetryQueueProviding { 23 + private let fileURL: URL 24 + 25 + public init(directory: URL? = nil) { 26 + let dir = directory ?? FileConfigProvider.defaultDirectory 27 + fileURL = dir.appendingPathComponent("retry-queue.json") 28 + } 29 + 30 + public func load() -> RetryQueue? { 31 + guard let data = try? Data(contentsOf: fileURL) else { return nil } 32 + return try? JSONDecoder().decode(RetryQueue.self, from: data) 33 + } 34 + 35 + public func save(_ queue: RetryQueue) throws { 36 + let encoder = JSONEncoder() 37 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 38 + let data = try encoder.encode(queue) 39 + 40 + // Ensure directory exists 41 + let dir = fileURL.deletingLastPathComponent() 42 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 43 + 44 + try data.write(to: fileURL, options: .atomic) 45 + } 46 + 47 + public func clear() throws { 48 + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } 49 + try FileManager.default.removeItem(at: fileURL) 50 + } 51 + }
+89
Sources/AtticCore/StagingReclaim.swift
··· 1 + import Foundation 2 + import LadderKit 3 + 4 + /// Result of scanning the staging directory for previously-exported files. 5 + public struct ReclaimResult: Sendable { 6 + /// Export results built from files already on disk (SHA-256 recomputed). 7 + public var reclaimed: [ExportResult] 8 + /// UUIDs that have no staged file and still need fresh export. 9 + public var remaining: [String] 10 + } 11 + 12 + /// Scan `stagingDir` for files matching `uuids` from a previous export. 13 + /// 14 + /// For each UUID, checks if a file whose name starts with `{sanitizedUUID}_` exists. 15 + /// If found, recomputes SHA-256 and builds an `ExportResult`. If multiple files match 16 + /// a single UUID (e.g. PhotoKit + AppleScript variants), keeps the first and deletes 17 + /// the rest. 18 + /// 19 + /// Returns reclaimed results and the UUIDs that still need fresh export. 20 + public func reclaimStagedFiles( 21 + uuids: [String], 22 + stagingDir: URL, 23 + ) -> ReclaimResult { 24 + let contents: [URL] 25 + do { 26 + contents = try FileManager.default.contentsOfDirectory( 27 + at: stagingDir, 28 + includingPropertiesForKeys: [.fileSizeKey], 29 + ) 30 + } catch { 31 + // Can't read staging dir — everything needs fresh export 32 + return ReclaimResult(reclaimed: [], remaining: uuids) 33 + } 34 + 35 + // Pre-compute sanitized prefixes to avoid redundant work in the inner loop 36 + var prefixToUUID: [String: String] = [:] 37 + for uuid in uuids { 38 + prefixToUUID[PathSafety.sanitizeFilename(uuid) + "_"] = uuid 39 + } 40 + 41 + // Build a filename lookup: files grouped by their UUID prefix 42 + var filesByUUID: [String: [URL]] = [:] 43 + for url in contents { 44 + let name = url.lastPathComponent 45 + for (prefix, uuid) in prefixToUUID { 46 + if name.hasPrefix(prefix) { 47 + filesByUUID[uuid, default: []].append(url) 48 + break 49 + } 50 + } 51 + } 52 + 53 + var reclaimed: [ExportResult] = [] 54 + var remaining: [String] = [] 55 + 56 + for uuid in uuids { 57 + guard var matches = filesByUUID[uuid], !matches.isEmpty else { 58 + remaining.append(uuid) 59 + continue 60 + } 61 + 62 + let kept = matches.removeFirst() 63 + 64 + // Delete duplicates (e.g. PhotoKit + AppleScript variants) 65 + for extra in matches { 66 + try? FileManager.default.removeItem(at: extra) 67 + } 68 + 69 + // Recompute SHA-256 to guarantee integrity 70 + do { 71 + let sha256 = try FileHasher.sha256(fileAt: kept) 72 + let attrs = try FileManager.default.attributesOfItem(atPath: kept.path) 73 + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 74 + 75 + reclaimed.append(ExportResult( 76 + uuid: uuid, 77 + path: kept.path, 78 + size: size, 79 + sha256: sha256, 80 + )) 81 + } catch { 82 + // File is corrupt or unreadable — delete it and re-export 83 + try? FileManager.default.removeItem(at: kept) 84 + remaining.append(uuid) 85 + } 86 + } 87 + 88 + return ReclaimResult(reclaimed: reclaimed, remaining: remaining) 89 + }
+2 -2
Sources/AtticCore/URLSessionS3Client.swift
··· 37 37 signer = AWSSigner(credentials: creds, name: "s3", region: region) 38 38 39 39 let config = URLSessionConfiguration.default 40 - config.timeoutIntervalForRequest = 30 41 - config.timeoutIntervalForResource = 600 40 + config.timeoutIntervalForRequest = 60 41 + config.timeoutIntervalForResource = 3600 42 42 session = URLSession(configuration: config) 43 43 } 44 44
+8
Tests/AtticCoreTests/NetworkPauseTests.swift
··· 139 139 record("batch(\(batchNumber))") 140 140 } 141 141 142 + func assetStarting(uuid: String, filename: String, size: Int) { 143 + record("starting(\(uuid))") 144 + } 145 + 146 + func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) { 147 + record("retrying(\(uuid),\(attempt)/\(maxAttempts))") 148 + } 149 + 142 150 func assetUploaded(uuid: String, filename: String, type: AssetKind, size: Int) { 143 151 record("uploaded(\(uuid))") 144 152 }
+71
Tests/AtticCoreTests/RetryQueueTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import AtticCore 5 + 6 + @Suite struct RetryQueueTests { 7 + private func makeTempDir() throws -> URL { 8 + let dir = FileManager.default.temporaryDirectory 9 + .appendingPathComponent("retry-queue-test-\(UUID().uuidString)") 10 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 11 + return dir 12 + } 13 + 14 + @Test func loadReturnsNilWhenNoFile() throws { 15 + let dir = try makeTempDir() 16 + defer { try? FileManager.default.removeItem(at: dir) } 17 + 18 + let store = FileRetryQueueStore(directory: dir) 19 + #expect(store.load() == nil) 20 + } 21 + 22 + @Test func saveAndLoadRoundTrip() throws { 23 + let dir = try makeTempDir() 24 + defer { try? FileManager.default.removeItem(at: dir) } 25 + 26 + let store = FileRetryQueueStore(directory: dir) 27 + let queue = RetryQueue( 28 + failedUUIDs: ["uuid-1", "uuid-2", "uuid-3"], 29 + updatedAt: "2025-01-15T12:00:00Z" 30 + ) 31 + try store.save(queue) 32 + 33 + let loaded = store.load() 34 + #expect(loaded != nil) 35 + #expect(loaded?.failedUUIDs == ["uuid-1", "uuid-2", "uuid-3"]) 36 + #expect(loaded?.updatedAt == "2025-01-15T12:00:00Z") 37 + } 38 + 39 + @Test func clearRemovesFile() throws { 40 + let dir = try makeTempDir() 41 + defer { try? FileManager.default.removeItem(at: dir) } 42 + 43 + let store = FileRetryQueueStore(directory: dir) 44 + let queue = RetryQueue(failedUUIDs: ["uuid-1"], updatedAt: "2025-01-15T12:00:00Z") 45 + try store.save(queue) 46 + #expect(store.load() != nil) 47 + 48 + try store.clear() 49 + #expect(store.load() == nil) 50 + } 51 + 52 + @Test func clearNoOpWhenNoFile() throws { 53 + let dir = try makeTempDir() 54 + defer { try? FileManager.default.removeItem(at: dir) } 55 + 56 + let store = FileRetryQueueStore(directory: dir) 57 + try store.clear() // should not throw 58 + } 59 + 60 + @Test func saveOverwritesPrevious() throws { 61 + let dir = try makeTempDir() 62 + defer { try? FileManager.default.removeItem(at: dir) } 63 + 64 + let store = FileRetryQueueStore(directory: dir) 65 + try store.save(RetryQueue(failedUUIDs: ["old"], updatedAt: "2025-01-01T00:00:00Z")) 66 + try store.save(RetryQueue(failedUUIDs: ["new-1", "new-2"], updatedAt: "2025-01-02T00:00:00Z")) 67 + 68 + let loaded = store.load() 69 + #expect(loaded?.failedUUIDs == ["new-1", "new-2"]) 70 + } 71 + }
+114
Tests/AtticCoreTests/StagingReclaimTests.swift
··· 1 + import Foundation 2 + import LadderKit 3 + import Testing 4 + 5 + @testable import AtticCore 6 + 7 + @Suite struct StagingReclaimTests { 8 + private func makeTempDir() throws -> URL { 9 + let dir = FileManager.default.temporaryDirectory 10 + .appendingPathComponent("staging-reclaim-test-\(UUID().uuidString)") 11 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 12 + return dir 13 + } 14 + 15 + private func writeFile(_ dir: URL, name: String, content: String = "test data") throws -> URL { 16 + let url = dir.appendingPathComponent(name) 17 + try content.write(to: url, atomically: true, encoding: .utf8) 18 + return url 19 + } 20 + 21 + @Test func emptyDirReturnsAllAsRemaining() throws { 22 + let dir = try makeTempDir() 23 + defer { try? FileManager.default.removeItem(at: dir) } 24 + 25 + let result = reclaimStagedFiles(uuids: ["uuid-1", "uuid-2"], stagingDir: dir) 26 + #expect(result.reclaimed.isEmpty) 27 + #expect(result.remaining == ["uuid-1", "uuid-2"]) 28 + } 29 + 30 + @Test func reclaimsExistingFile() throws { 31 + let dir = try makeTempDir() 32 + defer { try? FileManager.default.removeItem(at: dir) } 33 + 34 + _ = try writeFile(dir, name: "uuid-1_IMG_0001.HEIC", content: "photo data") 35 + 36 + let result = reclaimStagedFiles(uuids: ["uuid-1"], stagingDir: dir) 37 + #expect(result.reclaimed.count == 1) 38 + #expect(result.reclaimed[0].uuid == "uuid-1") 39 + #expect(result.reclaimed[0].size > 0) 40 + #expect(!result.reclaimed[0].sha256.isEmpty) 41 + #expect(result.remaining.isEmpty) 42 + } 43 + 44 + @Test func mixOfReclaimedAndRemaining() throws { 45 + let dir = try makeTempDir() 46 + defer { try? FileManager.default.removeItem(at: dir) } 47 + 48 + _ = try writeFile(dir, name: "uuid-1_IMG_0001.HEIC") 49 + // uuid-2 has no file 50 + 51 + let result = reclaimStagedFiles(uuids: ["uuid-1", "uuid-2"], stagingDir: dir) 52 + #expect(result.reclaimed.count == 1) 53 + #expect(result.reclaimed[0].uuid == "uuid-1") 54 + #expect(result.remaining == ["uuid-2"]) 55 + } 56 + 57 + @Test func deduplicatesMultipleFilesPerUUID() throws { 58 + let dir = try makeTempDir() 59 + defer { try? FileManager.default.removeItem(at: dir) } 60 + 61 + // Simulate PhotoKit + AppleScript both producing files 62 + _ = try writeFile(dir, name: "uuid-1_IMG_2383.MOV", content: "photokit version") 63 + _ = try writeFile(dir, name: "uuid-1_L0_001_IMG_2383.MOV", content: "applescript version") 64 + 65 + let result = reclaimStagedFiles(uuids: ["uuid-1"], stagingDir: dir) 66 + #expect(result.reclaimed.count == 1) 67 + #expect(result.remaining.isEmpty) 68 + 69 + // Only one file should remain in the dir 70 + let remaining = try FileManager.default.contentsOfDirectory( 71 + at: dir, includingPropertiesForKeys: nil 72 + ) 73 + #expect(remaining.count == 1) 74 + } 75 + 76 + @Test func ignoresFilesNotMatchingRequestedUUIDs() throws { 77 + let dir = try makeTempDir() 78 + defer { try? FileManager.default.removeItem(at: dir) } 79 + 80 + _ = try writeFile(dir, name: "other-uuid_IMG_0001.HEIC") 81 + _ = try writeFile(dir, name: "uuid-1_IMG_0002.HEIC") 82 + 83 + let result = reclaimStagedFiles(uuids: ["uuid-1"], stagingDir: dir) 84 + #expect(result.reclaimed.count == 1) 85 + #expect(result.reclaimed[0].uuid == "uuid-1") 86 + 87 + // The other-uuid file should still exist (untouched) 88 + let allFiles = try FileManager.default.contentsOfDirectory( 89 + at: dir, includingPropertiesForKeys: nil 90 + ) 91 + #expect(allFiles.count == 2) 92 + } 93 + 94 + @Test func recomputesSHA256Correctly() throws { 95 + let dir = try makeTempDir() 96 + defer { try? FileManager.default.removeItem(at: dir) } 97 + 98 + let content = "known content for hashing" 99 + let url = try writeFile(dir, name: "uuid-1_test.txt", content: content) 100 + 101 + // Compute expected hash 102 + let expectedHash = try FileHasher.sha256(fileAt: url) 103 + 104 + let result = reclaimStagedFiles(uuids: ["uuid-1"], stagingDir: dir) 105 + #expect(result.reclaimed[0].sha256 == expectedHash) 106 + } 107 + 108 + @Test func handlesNonexistentStagingDir() { 109 + let bogus = URL(fileURLWithPath: "/tmp/nonexistent-staging-\(UUID().uuidString)") 110 + let result = reclaimStagedFiles(uuids: ["uuid-1"], stagingDir: bogus) 111 + #expect(result.reclaimed.isEmpty) 112 + #expect(result.remaining == ["uuid-1"]) 113 + } 114 + }