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: track unavailable shared-album assets (1.0.0-beta.5)

Shared-album assets whose iCloud derivative has failed server-side are
now detected via LadderKit's new ExportError.unavailable flag, recorded
in ~/.attic/unavailable-assets.json, and skipped on subsequent backups
instead of being retried forever through the AppleScript fallback
(which goes through the same broken shared-stream pipeline).

Bumps LadderKit to 0.4.0.

+308 -5
+11
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 1.0.0-beta.5 4 + 5 + - **Shared-album unavailable tracking** — assets in iCloud Shared Albums whose 6 + derivative has failed server-side are detected, marked as unavailable, and 7 + skipped on subsequent backups instead of being retried forever 8 + - **Persistent unavailable store** — records live at 9 + `~/.attic/unavailable-assets.json` with attempt counts and last-failure 10 + reason; entries are never auto-cleared (unlike the retry queue) 11 + - Bumps LadderKit dependency to 0.4.0 for the new `isShared` and 12 + `ExportError.unavailable` APIs 13 + 3 14 ## 1.0.0-alpha.2 4 15 5 16 Animated preparation spinner for the backup command.
+1 -1
Package.swift
··· 11 11 dependencies: [ 12 12 .package(url: "https://github.com/adam-fowler/aws-signer-v4.git", from: "3.0.0"), 13 13 .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), 14 - .package(url: "https://github.com/tijs/ladder.git", from: "0.3.4"), 14 + .package(url: "https://github.com/tijs/ladder.git", from: "0.4.0"), 15 15 .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), 16 16 ], 17 17 targets: [
+1
Sources/AtticCLI/BackupCommand.swift
··· 76 76 progress: progress, 77 77 networkMonitor: NWPathNetworkMonitor(), 78 78 retryQueue: FileRetryQueueStore(), 79 + unavailableStore: FileUnavailableAssetStore(), 79 80 ) 80 81 81 82 _ = powerAssertion // prevent unused warning, released in deinit
+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.4" 5 + public static let version = "1.0.0-beta.5" 6 6 }
+43 -3
Sources/AtticCore/BackupPipeline.swift
··· 63 63 progress: any BackupProgressDelegate = NullProgressDelegate(), 64 64 networkMonitor: (any NetworkMonitoring)? = nil, 65 65 retryQueue: (any RetryQueueProviding)? = nil, 66 + unavailableStore: (any UnavailableAssetStoring)? = nil, 66 67 ) async throws -> BackupReport { 68 + var unavailable = unavailableStore?.load() ?? UnavailableAssets() 69 + 67 70 // Filter to pending assets, optionally by type 68 71 var pending = assets.filter { asset in 69 72 if manifest.isBackedUp(asset.uuid) { return false } 73 + if unavailable.contains(asset.uuid) { return false } 70 74 if let type = options.type, asset.kind != type { return false } 71 75 return true 72 76 } ··· 119 123 maxPauseRetries: options.maxPauseRetries, 120 124 ) 121 125 126 + // Helper: record a classified "unavailable" error. Returns true if the 127 + // error was an unavailable-marker (already tracked; should not be retried). 128 + // LadderKit may report errors using the full PhotoKit identifier 129 + // ("UUID/L0/001"); normalize to bare UUID so `pending` filter matches. 130 + func recordIfUnavailable(_ err: LadderKit.ExportError) -> Bool { 131 + guard err.unavailable else { return false } 132 + let bareUUID = err.uuid.split(separator: "/").first.map(String.init) ?? err.uuid 133 + let asset = assetByUUID[bareUUID] ?? assetByUUID[err.uuid] 134 + unavailable.record( 135 + uuid: bareUUID, 136 + filename: asset?.originalFilename, 137 + reason: err.message, 138 + ) 139 + return true 140 + } 141 + 122 142 // Process in batches (wrapped to save manifest on cancellation) 123 143 let totalBatches = (pending.count + options.batchSize - 1) / options.batchSize 124 144 ··· 175 195 let filename = assetByUUID[uuid]?.originalFilename ?? uuid 176 196 progress.assetFailed(uuid: uuid, filename: filename, message: msg) 177 197 } 198 + } 199 + for err in combinedErrors { 200 + _ = recordIfUnavailable(err) 178 201 } 179 202 let combined = ExportResponse(results: combinedResults, errors: combinedErrors) 180 203 try await uploadExported( ··· 208 231 } 209 232 } 210 233 234 + for err in batchResult.errors { 235 + _ = recordIfUnavailable(err) 236 + } 237 + 211 238 // 2. Upload exported assets 212 239 try await uploadExported( 213 240 batchResult, ctx: ctx, ··· 222 249 try Task.checkCancellation() 223 250 do { 224 251 let result = try await exporter.exportBatch(uuids: [uuid]) 252 + for err in result.errors { 253 + _ = recordIfUnavailable(err) 254 + } 225 255 try await uploadExported( 226 256 result, ctx: ctx, 227 257 manifest: &manifest, report: &report, ··· 242 272 try? await manifestStore.save(manifest) 243 273 progress.manifestSaved(entriesCount: manifest.entries.count) 244 274 } 275 + try? unavailableStore?.save(unavailable) 245 276 throw CancellationError() 246 277 } 247 278 ··· 251 282 progress.manifestSaved(entriesCount: manifest.entries.count) 252 283 } 253 284 254 - // Update retry queue: save failed UUIDs for next run, or clear on full success 255 - if report.errors.isEmpty { 285 + // Persist unavailable set so these assets are skipped on future runs. 286 + do { 287 + try unavailableStore?.save(unavailable) 288 + } catch { 289 + debugPrint("Failed to save unavailable assets store: \(error)") 290 + } 291 + 292 + // Update retry queue: save failed UUIDs for next run, or clear on full success. 293 + // Exclude UUIDs we just marked unavailable — retrying them is futile. 294 + let retryableErrors = report.errors.filter { !unavailable.contains($0.uuid) } 295 + if retryableErrors.isEmpty { 256 296 do { 257 297 try retryQueue?.clear() 258 298 } catch { 259 299 debugPrint("Failed to clear retry queue: \(error)") 260 300 } 261 301 } else { 262 - let failedUUIDs = report.errors.map(\.uuid) 302 + let failedUUIDs = retryableErrors.map(\.uuid) 263 303 let queue = RetryQueue( 264 304 failedUUIDs: failedUUIDs, 265 305 updatedAt: formatISO8601(Date()),
+100
Sources/AtticCore/UnavailableAssets.swift
··· 1 + import Foundation 2 + 3 + /// Record of an asset that iCloud cannot currently deliver (e.g. shared-album 4 + /// asset whose derivative has failed server-side). These are excluded from 5 + /// future backup runs to avoid wasting time on retries that will reliably 6 + /// time out at ~5 minutes each. 7 + public struct UnavailableAsset: Codable, Sendable, Equatable { 8 + public var uuid: String 9 + public var filename: String? 10 + public var reason: String 11 + public var firstFailedAt: String 12 + public var lastAttemptedAt: String 13 + public var attempts: Int 14 + 15 + public init( 16 + uuid: String, 17 + filename: String?, 18 + reason: String, 19 + firstFailedAt: String, 20 + lastAttemptedAt: String, 21 + attempts: Int, 22 + ) { 23 + self.uuid = uuid 24 + self.filename = filename 25 + self.reason = reason 26 + self.firstFailedAt = firstFailedAt 27 + self.lastAttemptedAt = lastAttemptedAt 28 + self.attempts = attempts 29 + } 30 + } 31 + 32 + public struct UnavailableAssets: Codable, Sendable { 33 + public var entries: [String: UnavailableAsset] 34 + 35 + public init(entries: [String: UnavailableAsset] = [:]) { 36 + self.entries = entries 37 + } 38 + 39 + public func contains(_ uuid: String) -> Bool { 40 + entries[uuid] != nil 41 + } 42 + 43 + public mutating func record( 44 + uuid: String, 45 + filename: String?, 46 + reason: String, 47 + now: Date = Date(), 48 + ) { 49 + let ts = formatISO8601(now) 50 + if var existing = entries[uuid] { 51 + existing.lastAttemptedAt = ts 52 + existing.attempts += 1 53 + existing.reason = reason 54 + if let filename { existing.filename = filename } 55 + entries[uuid] = existing 56 + } else { 57 + entries[uuid] = UnavailableAsset( 58 + uuid: uuid, 59 + filename: filename, 60 + reason: reason, 61 + firstFailedAt: ts, 62 + lastAttemptedAt: ts, 63 + attempts: 1, 64 + ) 65 + } 66 + } 67 + } 68 + 69 + public protocol UnavailableAssetStoring: Sendable { 70 + func load() -> UnavailableAssets 71 + func save(_ assets: UnavailableAssets) throws 72 + } 73 + 74 + /// File-backed store at `~/.attic/unavailable-assets.json`. 75 + public struct FileUnavailableAssetStore: UnavailableAssetStoring { 76 + private let fileURL: URL 77 + 78 + public init(directory: URL? = nil) { 79 + let dir = directory ?? FileConfigProvider.defaultDirectory 80 + fileURL = dir.appendingPathComponent("unavailable-assets.json") 81 + } 82 + 83 + public func load() -> UnavailableAssets { 84 + guard let data = try? Data(contentsOf: fileURL) else { 85 + return UnavailableAssets() 86 + } 87 + return (try? JSONDecoder().decode(UnavailableAssets.self, from: data)) 88 + ?? UnavailableAssets() 89 + } 90 + 91 + public func save(_ assets: UnavailableAssets) throws { 92 + let encoder = JSONEncoder() 93 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 94 + let data = try encoder.encode(assets) 95 + 96 + let dir = fileURL.deletingLastPathComponent() 97 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 98 + try data.write(to: fileURL, options: .atomic) 99 + } 100 + }
+90
Tests/AtticCoreTests/BackupPipelineTests.swift
··· 437 437 let loaded = try await manifestStore.load() 438 438 #expect(loaded.isBackedUp("uuid-1")) 439 439 } 440 + 441 + @Test func recordsUnavailableErrorsAndSkipsThemNextRun() async throws { 442 + let tempDir = FileManager.default.temporaryDirectory 443 + .appendingPathComponent("unavailable-pipeline-\(UUID().uuidString)") 444 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 445 + defer { try? FileManager.default.removeItem(at: tempDir) } 446 + 447 + let exporter = UnavailableMarkingExporter( 448 + unavailableUUIDs: ["shared-1"], 449 + availableAssets: ["ok-1": ("a.jpg", Data("a".utf8))], 450 + ) 451 + let store = FileUnavailableAssetStore(directory: tempDir) 452 + let (s3, manifestStore) = try await createTestContext() 453 + var manifest = try await manifestStore.load() 454 + 455 + let assets = [makeTestAsset(uuid: "shared-1"), makeTestAsset(uuid: "ok-1")] 456 + let report = try await runBackup( 457 + assets: assets, 458 + manifest: &manifest, 459 + manifestStore: manifestStore, 460 + exporter: exporter, 461 + s3: s3, 462 + options: BackupOptions(batchSize: 10), 463 + unavailableStore: store, 464 + ) 465 + 466 + #expect(report.uploaded == 1) 467 + #expect(report.failed == 1) 468 + #expect(store.load().contains("shared-1")) 469 + 470 + // Second run with the same assets should not re-attempt the unavailable one. 471 + let report2 = try await runBackup( 472 + assets: assets, 473 + manifest: &manifest, 474 + manifestStore: manifestStore, 475 + exporter: exporter, 476 + s3: s3, 477 + options: BackupOptions(batchSize: 10), 478 + unavailableStore: store, 479 + ) 480 + #expect(report2.uploaded == 0) 481 + #expect(report2.failed == 0) 482 + } 483 + } 484 + 485 + /// Exporter that marks configured UUIDs as `unavailable` errors. 486 + struct UnavailableMarkingExporter: ExportProviding { 487 + let unavailableUUIDs: Set<String> 488 + let availableAssets: [String: (filename: String, data: Data)] 489 + let stagingDir: URL 490 + 491 + init( 492 + unavailableUUIDs: Set<String>, 493 + availableAssets: [String: (filename: String, data: Data)], 494 + ) { 495 + self.unavailableUUIDs = unavailableUUIDs 496 + self.availableAssets = availableAssets 497 + stagingDir = FileManager.default.temporaryDirectory 498 + .appendingPathComponent("unav-exporter-\(UUID().uuidString)") 499 + } 500 + 501 + func exportBatch(uuids: [String]) async throws -> ExportResponse { 502 + let fm = FileManager.default 503 + if !fm.fileExists(atPath: stagingDir.path) { 504 + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) 505 + } 506 + var results: [ExportResult] = [] 507 + var errors: [LadderKit.ExportError] = [] 508 + for uuid in uuids { 509 + if unavailableUUIDs.contains(uuid) { 510 + errors.append(LadderKit.ExportError( 511 + uuid: uuid, 512 + message: "Shared-album asset unavailable", 513 + unavailable: true, 514 + )) 515 + } else if let asset = availableAssets[uuid] { 516 + let path = stagingDir.appendingPathComponent(asset.filename) 517 + try asset.data.write(to: path) 518 + results.append(ExportResult( 519 + uuid: uuid, path: path.path, 520 + size: Int64(asset.data.count), sha256: "fake_\(uuid)", 521 + )) 522 + } else { 523 + errors.append(LadderKit.ExportError(uuid: uuid, message: "missing")) 524 + } 525 + } 526 + return ExportResponse(results: results, errors: errors) 527 + } 528 + 529 + func checkPermissions() async throws {} 440 530 }
+61
Tests/AtticCoreTests/UnavailableAssetsTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 3 + import Testing 4 + 5 + struct UnavailableAssetsTests { 6 + private func makeTempDir() throws -> URL { 7 + let dir = FileManager.default.temporaryDirectory 8 + .appendingPathComponent("unavailable-test-\(UUID().uuidString)") 9 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 10 + return dir 11 + } 12 + 13 + @Test func loadReturnsEmptyWhenNoFile() throws { 14 + let dir = try makeTempDir() 15 + defer { try? FileManager.default.removeItem(at: dir) } 16 + 17 + let store = FileUnavailableAssetStore(directory: dir) 18 + #expect(store.load().entries.isEmpty) 19 + } 20 + 21 + @Test func recordNewEntryCreatesAttemptOne() { 22 + var assets = UnavailableAssets() 23 + assets.record(uuid: "u1", filename: "IMG.HEIC", reason: "shared unavailable") 24 + 25 + let entry = assets.entries["u1"] 26 + #expect(entry?.attempts == 1) 27 + #expect(entry?.filename == "IMG.HEIC") 28 + #expect(entry?.firstFailedAt == entry?.lastAttemptedAt) 29 + } 30 + 31 + @Test func recordSameUUIDIncrementsAttempts() { 32 + var assets = UnavailableAssets() 33 + let t1 = Date(timeIntervalSince1970: 1_000_000) 34 + let t2 = Date(timeIntervalSince1970: 1_000_100) 35 + 36 + assets.record(uuid: "u1", filename: "IMG.HEIC", reason: "first fail", now: t1) 37 + assets.record(uuid: "u1", filename: nil, reason: "second fail", now: t2) 38 + 39 + let entry = assets.entries["u1"] 40 + #expect(entry?.attempts == 2) 41 + #expect(entry?.firstFailedAt != entry?.lastAttemptedAt) 42 + #expect(entry?.filename == "IMG.HEIC") // nil does not overwrite existing 43 + #expect(entry?.reason == "second fail") 44 + } 45 + 46 + @Test func saveAndLoadRoundTrip() throws { 47 + let dir = try makeTempDir() 48 + defer { try? FileManager.default.removeItem(at: dir) } 49 + 50 + let store = FileUnavailableAssetStore(directory: dir) 51 + var assets = UnavailableAssets() 52 + assets.record(uuid: "u1", filename: "a.jpg", reason: "r1") 53 + assets.record(uuid: "u2", filename: nil, reason: "r2") 54 + try store.save(assets) 55 + 56 + let loaded = store.load() 57 + #expect(loaded.contains("u1")) 58 + #expect(loaded.contains("u2")) 59 + #expect(loaded.entries.count == 2) 60 + } 61 + }