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.

fix: resolve swiftformat issues for CI

+78 -82
+2 -2
Sources/AtticCLI/ViewerCommand.swift
··· 5 5 struct ViewerCommand: AsyncParsableCommand { 6 6 static let configuration = CommandConfiguration( 7 7 commandName: "viewer", 8 - abstract: "Browse backed-up photos in your browser." 8 + abstract: "Browse backed-up photos in your browser.", 9 9 ) 10 10 11 11 @Option(name: .long, help: "Port to bind to (0 for automatic).") ··· 24 24 let thumbnailService = ThumbnailService(s3: s3, dataStore: dataStore) 25 25 let server = ViewerServer( 26 26 dataStore: dataStore, s3: s3, 27 - thumbnailProvider: thumbnailService, port: port 27 + thumbnailProvider: thumbnailService, port: port, 28 28 ) 29 29 30 30 // Start metadata loading in the background — assets become
+10 -10
Sources/AtticCLI/ViewerServer.swift
··· 36 36 dataStore: ViewerDataStore, 37 37 s3: S3Providing, 38 38 thumbnailProvider: ThumbnailProviding, 39 - port: Int = 0 39 + port: Int = 0, 40 40 ) { 41 41 self.dataStore = dataStore 42 42 self.s3 = s3 ··· 52 52 onServerRunning: { channel in 53 53 let actualPort = channel.localAddress?.port ?? 8080 54 54 onReady(actualPort) 55 - } 55 + }, 56 56 ) 57 57 try await app.runService() 58 58 } ··· 78 78 .init("X-Frame-Options")!: "DENY", 79 79 .init("Content-Security-Policy")!: Self.csp, 80 80 ], 81 - body: .init(byteBuffer: .init(string: html)) 81 + body: .init(byteBuffer: .init(string: html)), 82 82 ) 83 83 } 84 84 } ··· 92 92 let mediaType = params.get("type", as: String.self) 93 93 94 94 let opts = await dataStore.filterOptions( 95 - year: year, album: album, favorites: favorites, mediaType: mediaType 95 + year: year, album: album, favorites: favorites, mediaType: mediaType, 96 96 ) 97 97 let data = try JSONEncoder().encode(opts) 98 98 return Response( 99 99 status: .ok, 100 100 headers: [.contentType: "application/json"], 101 - body: .init(byteBuffer: .init(data: data)) 101 + body: .init(byteBuffer: .init(data: data)), 102 102 ) 103 103 } 104 104 ··· 113 113 114 114 let result = await dataStore.query( 115 115 year: year, album: album, favorites: favorites, 116 - mediaType: mediaType, page: page, pageSize: pageSize 116 + mediaType: mediaType, page: page, pageSize: pageSize, 117 117 ) 118 118 119 119 let assetsWithURLs = result.assets.map { asset in ··· 124 124 assets: assetsWithURLs, 125 125 totalCount: result.totalCount, 126 126 page: page, 127 - pageSize: pageSize 127 + pageSize: pageSize, 128 128 ) 129 129 } 130 130 ··· 142 142 return Response( 143 143 status: .ok, 144 144 headers: [.contentType: "application/json"], 145 - body: .init(byteBuffer: .init(data: data)) 145 + body: .init(byteBuffer: .init(data: data)), 146 146 ) 147 147 } 148 148 ··· 159 159 .contentType: "image/jpeg", 160 160 .cacheControl: "public, max-age=31536000, immutable", 161 161 ], 162 - body: .init(byteBuffer: .init(data: data)) 162 + body: .init(byteBuffer: .init(data: data)), 163 163 ) 164 164 } catch { 165 165 return Response(status: .notFound) ··· 179 179 width: asset.width, 180 180 height: asset.height, 181 181 imageURL: s3.presignedURL(key: asset.s3Key, expires: expires) 182 - .absoluteString 182 + .absoluteString, 183 183 ) 184 184 } 185 185 }
+2 -1
Sources/AtticCore/ExportProviding.swift
··· 43 43 /// Map a file extension to its MIME content type using the system type database. 44 44 public func contentTypeForExtension(_ ext: String) -> String { 45 45 if let utType = UTType(filenameExtension: ext), 46 - let mimeType = utType.preferredMIMEType { 46 + let mimeType = utType.preferredMIMEType 47 + { 47 48 return mimeType 48 49 } 49 50 return "application/octet-stream"
+2 -2
Sources/AtticCore/ImageThumbnailer.swift
··· 17 17 public static func thumbnail( 18 18 from data: Data, 19 19 maxDimension: Int = 400, 20 - quality: Double = 0.8 20 + quality: Double = 0.8, 21 21 ) throws -> Data { 22 22 guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { 23 23 throw ThumbnailError.decodeFailed("CGImageSourceCreateWithData returned nil") ··· 43 43 data as CFMutableData, 44 44 UTType.jpeg.identifier as CFString, 45 45 1, 46 - nil 46 + nil, 47 47 ) else { 48 48 throw ThumbnailError.decodeFailed("CGImageDestinationCreateWithData failed") 49 49 }
+7 -8
Sources/AtticCore/ThumbnailService.swift
··· 20 20 cache: ThumbnailCache = ThumbnailCache(), 21 21 s3: S3Providing, 22 22 dataStore: ViewerDataStore, 23 - maxConcurrent: Int = 6 23 + maxConcurrent: Int = 6, 24 24 ) { 25 25 self.cache = cache 26 26 self.s3 = s3 ··· 55 55 } 56 56 57 57 return try await self.generateThumbnail( 58 - for: asset, uuid: uuid, thumbKey: thumbKey 58 + for: asset, uuid: uuid, thumbKey: thumbKey, 59 59 ) 60 60 } 61 61 ··· 76 76 /// Download original, generate thumbnail, save to cache + S3. 77 77 /// Acquires and releases a concurrency slot synchronously within actor context. 78 78 private func generateThumbnail( 79 - for asset: AssetView, uuid: String, thumbKey: String 79 + for asset: AssetView, uuid: String, thumbKey: String, 80 80 ) async throws -> Data { 81 81 await acquireSlot() 82 82 defer { releaseSlot() } ··· 88 88 throw ThumbnailError.s3Failure(uuid, error) 89 89 } 90 90 91 - let jpegData: Data 92 - if asset.isVideo { 93 - jpegData = try VideoThumbnailer.thumbnail(from: originalData) 91 + let jpegData: Data = if asset.isVideo { 92 + try VideoThumbnailer.thumbnail(from: originalData) 94 93 } else { 95 - jpegData = try ImageThumbnailer.thumbnail(from: originalData) 94 + try ImageThumbnailer.thumbnail(from: originalData) 96 95 } 97 96 98 97 // Save to local cache ··· 100 99 101 100 // Best-effort upload to S3 (don't fail if upload errors) 102 101 try? await s3.putObject( 103 - key: thumbKey, body: jpegData, contentType: "image/jpeg" 102 + key: thumbKey, body: jpegData, contentType: "image/jpeg", 104 103 ) 105 104 106 105 return jpegData
+2 -2
Sources/AtticCore/VideoThumbnailer.swift
··· 12 12 static func thumbnail( 13 13 from fileURL: URL, 14 14 maxDimension: Int = 400, 15 - quality: Double = 0.8 15 + quality: Double = 0.8, 16 16 ) throws -> Data { 17 17 let asset = AVURLAsset(url: fileURL) 18 18 let generator = AVAssetImageGenerator(asset: asset) ··· 43 43 public static func thumbnail( 44 44 from data: Data, 45 45 maxDimension: Int = 400, 46 - quality: Double = 0.8 46 + quality: Double = 0.8, 47 47 ) throws -> Data { 48 48 let tempURL = FileManager.default.temporaryDirectory 49 49 .appendingPathComponent("attic-video-\(UUID().uuidString).mov")
+29 -28
Sources/AtticCore/ViewerDataStore.swift
··· 17 17 public init( 18 18 uuid: String, filename: String, dateCreated: String?, 19 19 year: Int?, albums: [String], isFavorite: Bool, 20 - isVideo: Bool, width: Int, height: Int, s3Key: String 20 + isVideo: Bool, width: Int, height: Int, s3Key: String, 21 21 ) { 22 22 self.uuid = uuid 23 23 self.filename = filename ··· 79 79 public func load( 80 80 manifest: Manifest, 81 81 s3: S3Providing, 82 - onProgress: @escaping @Sendable (Int, Int) -> Void = { _, _ in } 82 + onProgress: @escaping @Sendable (Int, Int) -> Void = { _, _ in }, 83 83 ) async { 84 84 let entries = Array(manifest.entries.values) 85 85 _expectedTotal = entries.count ··· 126 126 // Sort by date descending (newest first), nil dates last 127 127 assets.sort { a, b in 128 128 switch (a.dateCreated, b.dateCreated) { 129 - case let (dateA?, dateB?): return dateA > dateB 130 - case (nil, _): return false 131 - case (_, nil): return true 129 + case let (dateA?, dateB?): dateA > dateB 130 + case (nil, _): false 131 + case (_, nil): true 132 132 } 133 133 } 134 134 ··· 156 156 favorites: Bool? = nil, 157 157 mediaType: String? = nil, 158 158 page: Int = 1, 159 - pageSize: Int = 50 159 + pageSize: Int = 50, 160 160 ) -> AssetPage { 161 161 let filtered = applyFilters( 162 - year: year, album: album, favorites: favorites, mediaType: mediaType 162 + year: year, album: album, favorites: favorites, mediaType: mediaType, 163 163 ) 164 164 165 165 let totalCount = filtered.count 166 166 let startIndex = (page - 1) * pageSize 167 167 let endIndex = min(startIndex + pageSize, totalCount) 168 168 let pageAssets = startIndex < totalCount 169 - ? Array(filtered[startIndex..<endIndex]) 169 + ? Array(filtered[startIndex ..< endIndex]) 170 170 : [] 171 171 172 172 return AssetPage(assets: pageAssets, totalCount: totalCount) ··· 179 179 year: Int? = nil, 180 180 album: String? = nil, 181 181 favorites: Bool? = nil, 182 - mediaType: String? = nil 182 + mediaType: String? = nil, 183 183 ) -> FilterOptions { 184 184 var yearCounts: [Int: Int] = [:] 185 185 var albumCounts: [String: Int] = [:] ··· 190 190 let matchesYear = year == nil || asset.year == year 191 191 let matchesAlbum = album == nil || asset.albums.contains(album!) 192 192 let matchesFav = favorites != true || asset.isFavorite 193 - let matchesType: Bool 194 - switch mediaType { 195 - case "photo": matchesType = !asset.isVideo 196 - case "video": matchesType = asset.isVideo 197 - default: matchesType = true 193 + let matchesType: Bool = switch mediaType { 194 + case "photo": !asset.isVideo 195 + case "video": asset.isVideo 196 + default: true 198 197 } 199 198 200 199 // Year counts: apply all filters except year 201 - if matchesAlbum && matchesFav && matchesType { 200 + if matchesAlbum, matchesFav, matchesType { 202 201 if let y = asset.year { yearCounts[y, default: 0] += 1 } 203 202 } 204 203 // Album counts: apply all filters except album 205 - if matchesYear && matchesFav && matchesType { 206 - for a in asset.albums { albumCounts[a, default: 0] += 1 } 204 + if matchesYear, matchesFav, matchesType { 205 + for a in asset.albums { 206 + albumCounts[a, default: 0] += 1 207 + } 207 208 } 208 209 // Totals: all filters applied 209 - if matchesYear && matchesAlbum && matchesFav && matchesType { 210 + if matchesYear, matchesAlbum, matchesFav, matchesType { 210 211 if asset.isVideo { videoCount += 1 } else { photoCount += 1 } 211 212 } 212 213 } ··· 221 222 totalVideos: videoCount, 222 223 isLoading: _isLoading, 223 224 loaded: _loadedCount, 224 - totalInLibrary: _isLoading ? _expectedTotal : assets.count 225 + totalInLibrary: _isLoading ? _expectedTotal : assets.count, 225 226 ) 226 227 } 227 228 ··· 233 234 // MARK: - Private 234 235 235 236 private func applyFilters( 236 - year: Int?, album: String?, favorites: Bool?, mediaType: String? 237 + year: Int?, album: String?, favorites: Bool?, mediaType: String?, 237 238 ) -> [AssetView] { 238 239 var filtered = assets 239 240 if let year { ··· 243 244 filtered = filtered.filter { $0.albums.contains(album) } 244 245 } 245 246 if let favorites, favorites { 246 - filtered = filtered.filter { $0.isFavorite } 247 + filtered = filtered.filter(\.isFavorite) 247 248 } 248 249 if let mediaType { 249 250 switch mediaType { 250 251 case "photo": filtered = filtered.filter { !$0.isVideo } 251 - case "video": filtered = filtered.filter { $0.isVideo } 252 + case "video": filtered = filtered.filter(\.isVideo) 252 253 default: break 253 254 } 254 255 } ··· 261 262 } 262 263 263 264 private static func assetView(from meta: AssetMetadata) -> AssetView { 264 - let year: Int? 265 - if let dateStr = meta.dateCreated, 266 - let date = try? Date.ISO8601FormatStyle().parse(dateStr) { 267 - year = Calendar.current.component(.year, from: date) 265 + let year: Int? = if let dateStr = meta.dateCreated, 266 + let date = try? Date.ISO8601FormatStyle().parse(dateStr) 267 + { 268 + Calendar.current.component(.year, from: date) 268 269 } else { 269 - year = nil 270 + nil 270 271 } 271 272 272 273 let isVideo = meta.type.map { isVideoUTI($0) } ?? false ··· 281 282 isVideo: isVideo, 282 283 width: meta.width, 283 284 height: meta.height, 284 - s3Key: meta.s3Key 285 + s3Key: meta.s3Key, 285 286 ) 286 287 } 287 288 }
+3 -4
Tests/AtticCoreTests/PreSignedURLTests.swift
··· 2 2 import Foundation 3 3 import Testing 4 4 5 - @Suite 6 5 struct PreSignedURLTests { 7 - @Test func mockReturnsExpectedURL() async throws { 6 + @Test func mockReturnsExpectedURL() async { 8 7 let s3 = MockS3Provider() 9 8 let url = await s3.presignedURL(key: "originals/2024/07/test-uuid.heic", expires: 14400) 10 9 #expect(url.absoluteString == "http://mock-s3/originals/2024/07/test-uuid.heic?expires=14400") 11 10 } 12 11 13 - @Test func mockURLIncludesExpiryValue() async throws { 12 + @Test func mockURLIncludesExpiryValue() async { 14 13 let s3 = MockS3Provider() 15 14 let url = await s3.presignedURL(key: "originals/2024/01/abc.jpg", expires: 3600) 16 15 #expect(url.absoluteString.contains("expires=3600")) 17 16 } 18 17 19 - @Test func mockURLPreservesS3Key() async throws { 18 + @Test func mockURLPreservesS3Key() async { 20 19 let s3 = MockS3Provider() 21 20 let key = "metadata/assets/8A3B1C2D-4E5F-6789-ABCD-EF0123456789.json" 22 21 let url = await s3.presignedURL(key: key, expires: 14400)
+10 -14
Tests/AtticCoreTests/ThumbnailTests.swift
··· 5 5 6 6 // MARK: - ThumbnailCache Tests 7 7 8 - @Suite 9 8 struct ThumbnailCacheTests { 10 9 private func makeTempDir() throws -> URL { 11 10 let dir = FileManager.default.temporaryDirectory ··· 68 67 69 68 // MARK: - S3Paths.thumbnailKey Tests 70 69 71 - @Suite 72 70 struct ThumbnailKeyTests { 73 71 @Test func thumbnailKeyGeneratesCorrectPath() throws { 74 72 let key = try S3Paths.thumbnailKey(uuid: "abc-123") ··· 87 85 88 86 // MARK: - ImageThumbnailer Tests 89 87 90 - @Suite 91 88 struct ImageThumbnailerTests { 92 89 @Test func thumbnailFromValidJPEGData() throws { 93 90 // Create a minimal valid JPEG via CGImage ··· 116 113 #expect(data[1] == 0xD8) 117 114 } 118 115 119 - // Helper to create a test JPEG 116 + /// Helper to create a test JPEG 120 117 private func createTestJPEG(width: Int, height: Int) throws -> Data { 121 118 let cgImage = try createTestCGImage(width: width, height: height) 122 119 return try ImageThumbnailer.encodeJPEG(image: cgImage, quality: 0.9) ··· 128 125 data: nil, width: width, height: height, 129 126 bitsPerComponent: 8, bytesPerRow: width * 4, 130 127 space: colorSpace, 131 - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 128 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, 132 129 ) else { 133 130 throw ThumbnailError.decodeFailed("Could not create test CGContext") 134 131 } ··· 143 140 144 141 // MARK: - ThumbnailService Tests 145 142 146 - @Suite 147 143 struct ThumbnailServiceTests { 148 144 private func makeAssetView( 149 145 uuid: String, 150 - isVideo: Bool = false 146 + isVideo: Bool = false, 151 147 ) -> AssetView { 152 148 AssetView( 153 149 uuid: uuid, ··· 159 155 isVideo: isVideo, 160 156 width: 4032, 161 157 height: 3024, 162 - s3Key: "originals/2024/07/\(uuid).heic" 158 + s3Key: "originals/2024/07/\(uuid).heic", 163 159 ) 164 160 } 165 161 ··· 169 165 data: nil, width: 100, height: 100, 170 166 bitsPerComponent: 8, bytesPerRow: 400, 171 167 space: colorSpace, 172 - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 168 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, 173 169 ), 174 170 let image = ctx.makeImage() 175 171 else { ··· 191 187 let dataStore = ViewerDataStore() 192 188 193 189 let service = ThumbnailService( 194 - cache: cache, s3: s3, dataStore: dataStore 190 + cache: cache, s3: s3, dataStore: dataStore, 195 191 ) 196 192 197 193 let result = try await service.thumbnail(uuid: "test-uuid") ··· 214 210 let cache = ThumbnailCache(directory: dir) 215 211 216 212 let service = ThumbnailService( 217 - cache: cache, s3: s3, dataStore: dataStore 213 + cache: cache, s3: s3, dataStore: dataStore, 218 214 ) 219 215 220 216 let result = try await service.thumbnail(uuid: "test-uuid") ··· 240 236 241 237 let cache = ThumbnailCache(directory: dir) 242 238 let service = ThumbnailService( 243 - cache: cache, s3: s3, dataStore: dataStore 239 + cache: cache, s3: s3, dataStore: dataStore, 244 240 ) 245 241 246 242 let result = try await service.thumbnail(uuid: "photo-uuid") ··· 267 263 _ = try await service.thumbnail(uuid: "nonexistent") 268 264 #expect(Bool(false), "Should have thrown") 269 265 } catch let error as ThumbnailError { 270 - if case .notFound(let uuid) = error { 266 + if case let .notFound(uuid) = error { 271 267 #expect(uuid == "nonexistent") 272 268 } else { 273 269 #expect(Bool(false), "Wrong error case: \(error)") ··· 290 286 291 287 let cache = ThumbnailCache(directory: dir) 292 288 let service = ThumbnailService( 293 - cache: cache, s3: s3, dataStore: dataStore 289 + cache: cache, s3: s3, dataStore: dataStore, 294 290 ) 295 291 296 292 // Launch multiple concurrent requests for the same UUID
+11 -11
Tests/AtticCoreTests/ViewerDataStoreTests.swift
··· 2 2 import Foundation 3 3 import Testing 4 4 5 - @Suite 6 5 struct ViewerDataStoreTests { 7 6 // MARK: - Test helpers 8 7 ··· 11 10 year: Int? = 2024, 12 11 albums: [String] = [], 13 12 isFavorite: Bool = false, 14 - isVideo: Bool = false 13 + isVideo: Bool = false, 15 14 ) -> AssetView { 16 15 AssetView( 17 16 uuid: uuid, ··· 23 22 isVideo: isVideo, 24 23 width: 4032, 25 24 height: 3024, 26 - s3Key: "originals/\(year ?? 0)/07/\(uuid).heic" 25 + s3Key: "originals/\(year ?? 0)/07/\(uuid).heic", 27 26 ) 28 27 } 29 28 ··· 31 30 32 31 @Test func queryReturnsAllAssetsUnfiltered() async { 33 32 let store = ViewerDataStore() 34 - let assets = (1...5).map { makeAssetView(uuid: "uuid-\($0)") } 33 + let assets = (1 ... 5).map { makeAssetView(uuid: "uuid-\($0)") } 35 34 await store.load(assets: assets) 36 35 37 36 let result = await store.query() ··· 77 76 78 77 let result = await store.query(favorites: true) 79 78 #expect(result.totalCount == 2) 79 + // swiftformat:disable:next preferKeyPath 80 80 #expect(result.assets.allSatisfy { $0.isFavorite }) 81 81 } 82 82 ··· 98 98 99 99 @Test func queryPaginates() async { 100 100 let store = ViewerDataStore() 101 - let assets = (1...10).map { makeAssetView(uuid: "uuid-\($0)") } 101 + let assets = (1 ... 10).map { makeAssetView(uuid: "uuid-\($0)") } 102 102 await store.load(assets: assets) 103 103 104 104 let page1 = await store.query(page: 1, pageSize: 3) ··· 168 168 169 169 @Test func corruptMetadataIsSkipped() async throws { 170 170 let store = ViewerDataStore() 171 - let s3 = MockS3Provider(objects: [ 172 - "metadata/assets/good-uuid.json": try JSONEncoder().encode( 171 + let s3 = try MockS3Provider(objects: [ 172 + "metadata/assets/good-uuid.json": JSONEncoder().encode( 173 173 AssetMetadata( 174 174 uuid: "good-uuid", originalFilename: "IMG.HEIC", 175 175 dateCreated: "2024-01-15T12:00:00Z", ··· 180 180 albums: [], keywords: [], people: [], 181 181 hasEdit: false, editedAt: nil, editor: nil, 182 182 s3Key: "originals/2024/01/good-uuid.heic", 183 - checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z" 184 - ) 183 + checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z", 184 + ), 185 185 ), 186 186 "metadata/assets/bad-uuid.json": Data("not json".utf8), 187 187 ]) ··· 189 189 var manifest = Manifest() 190 190 manifest.entries["good-uuid"] = ManifestEntry( 191 191 uuid: "good-uuid", s3Key: "originals/2024/01/good-uuid.heic", 192 - checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z" 192 + checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z", 193 193 ) 194 194 manifest.entries["bad-uuid"] = ManifestEntry( 195 195 uuid: "bad-uuid", s3Key: "originals/2024/01/bad-uuid.heic", 196 - checksum: "sha256:def", backedUpAt: "2024-01-15T12:00:00Z" 196 + checksum: "sha256:def", backedUpAt: "2024-01-15T12:00:00Z", 197 197 ) 198 198 199 199 await store.load(manifest: manifest, s3: s3)