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(viewer): Add photo viewer with thumbnail generation and caching

Add `attic viewer` command that serves a browser-based photo grid via
Hummingbird. Thumbnails are generated lazily on first request (400px
JPEG) with three-tier caching: local disk, S3, generate from original.
Includes viewport-based memory management, infinite scroll, lightbox
with keyboard nav, filtering by year/album/media type/favorites, and
video poster frame extraction via AVAssetImageGenerator.

+1857 -1
+4 -1
Package.swift
··· 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 14 .package(url: "https://github.com/tijs/ladder.git", from: "0.3.4"), 15 + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), 15 16 ], 16 17 targets: [ 17 18 .target( ··· 27 28 dependencies: [ 28 29 "AtticCore", 29 30 .product(name: "ArgumentParser", package: "swift-argument-parser"), 31 + .product(name: "Hummingbird", package: "hummingbird"), 30 32 ], 31 - path: "Sources/AtticCLI" 33 + path: "Sources/AtticCLI", 34 + resources: [.copy("Resources")] 32 35 ), 33 36 .testTarget( 34 37 name: "AtticCoreTests",
+1
Sources/AtticCLI/AtticCLI.swift
··· 14 14 RefreshMetadataCommand.self, 15 15 RebuildCommand.self, 16 16 InitCommand.self, 17 + ViewerCommand.self, 17 18 ], 18 19 ) 19 20 }
+432
Sources/AtticCLI/Resources/viewer.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Attic Viewer</title> 7 + <style> 8 + :root { 9 + --bg: #fff; 10 + --bg-secondary: #f5f5f7; 11 + --text: #1d1d1f; 12 + --text-secondary: #86868b; 13 + --border: #d2d2d7; 14 + --accent: #0071e3; 15 + --grid-gap: 2px; 16 + --thumb-size: 200px; 17 + } 18 + @media (prefers-color-scheme: dark) { 19 + :root { 20 + --bg: #1d1d1f; 21 + --bg-secondary: #2d2d2f; 22 + --text: #f5f5f7; 23 + --text-secondary: #86868b; 24 + --border: #424245; 25 + --accent: #2997ff; 26 + } 27 + } 28 + * { margin: 0; padding: 0; box-sizing: border-box; } 29 + body { 30 + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; 31 + background: var(--bg); 32 + color: var(--text); 33 + min-height: 100vh; 34 + } 35 + header { 36 + position: sticky; top: 0; z-index: 100; 37 + background: var(--bg); 38 + border-bottom: 1px solid var(--border); 39 + padding: 12px 20px; 40 + } 41 + .header-top { 42 + display: flex; align-items: center; justify-content: space-between; 43 + margin-bottom: 10px; 44 + } 45 + .header-top h1 { font-size: 20px; font-weight: 600; } 46 + .stats { font-size: 13px; color: var(--text-secondary); } 47 + .filters { 48 + display: flex; gap: 8px; flex-wrap: wrap; align-items: center; 49 + } 50 + .filters select, .filters button { 51 + font-family: inherit; font-size: 13px; 52 + padding: 5px 10px; border-radius: 6px; 53 + border: 1px solid var(--border); 54 + background: var(--bg-secondary); 55 + color: var(--text); 56 + cursor: pointer; 57 + } 58 + .filters button.active { 59 + background: var(--accent); color: #fff; border-color: var(--accent); 60 + } 61 + .grid { 62 + display: grid; 63 + grid-template-columns: repeat(auto-fill, minmax(var(--thumb-size), 1fr)); 64 + gap: var(--grid-gap); 65 + padding: var(--grid-gap); 66 + } 67 + .thumb { 68 + aspect-ratio: 1; 69 + overflow: hidden; 70 + cursor: pointer; 71 + position: relative; 72 + background: var(--bg-secondary); 73 + } 74 + .thumb img { 75 + width: 100%; height: 100%; 76 + object-fit: cover; 77 + display: block; 78 + } 79 + .thumb .badge { 80 + position: absolute; bottom: 4px; left: 4px; 81 + background: rgba(0,0,0,0.6); color: #fff; 82 + font-size: 10px; padding: 2px 5px; border-radius: 3px; 83 + } 84 + .thumb .fav { 85 + position: absolute; top: 4px; right: 4px; 86 + font-size: 12px; 87 + } 88 + .thumb .play-indicator { 89 + position: absolute; top: 50%; left: 50%; 90 + transform: translate(-50%, -50%); 91 + width: 40px; height: 40px; 92 + background: rgba(0,0,0,0.5); 93 + border-radius: 50%; 94 + display: flex; align-items: center; justify-content: center; 95 + pointer-events: none; 96 + } 97 + .thumb .play-indicator::after { 98 + content: ''; 99 + display: block; 100 + width: 0; height: 0; 101 + border-style: solid; 102 + border-width: 8px 0 8px 14px; 103 + border-color: transparent transparent transparent #fff; 104 + margin-left: 3px; 105 + } 106 + .empty { 107 + text-align: center; padding: 80px 20px; 108 + color: var(--text-secondary); font-size: 15px; 109 + } 110 + .loading { 111 + text-align: center; padding: 30px; 112 + color: var(--text-secondary); font-size: 14px; 113 + } 114 + 115 + /* Lightbox */ 116 + .lightbox { 117 + display: none; position: fixed; inset: 0; z-index: 200; 118 + background: rgba(0,0,0,0.92); 119 + justify-content: center; align-items: center; 120 + } 121 + .lightbox.open { display: flex; } 122 + .lightbox-content { 123 + position: relative; 124 + max-width: 95vw; max-height: 95vh; 125 + } 126 + .lightbox-content img, .lightbox-content video { 127 + max-width: 95vw; max-height: 90vh; 128 + object-fit: contain; display: block; 129 + border-radius: 4px; 130 + } 131 + .lightbox-info { 132 + position: absolute; bottom: -30px; left: 0; right: 0; 133 + text-align: center; color: #999; font-size: 13px; 134 + } 135 + .lightbox-close { 136 + position: fixed; top: 16px; right: 20px; 137 + background: none; border: none; color: #fff; 138 + font-size: 28px; cursor: pointer; z-index: 210; 139 + } 140 + .lightbox-nav { 141 + position: fixed; top: 50%; transform: translateY(-50%); 142 + background: none; border: none; color: #fff; 143 + font-size: 36px; cursor: pointer; z-index: 210; 144 + padding: 20px; 145 + } 146 + .lightbox-nav.prev { left: 8px; } 147 + .lightbox-nav.next { right: 8px; } 148 + .lightbox-nav:hover, .lightbox-close:hover { opacity: 0.7; } 149 + </style> 150 + </head> 151 + <body> 152 + 153 + <header> 154 + <div class="header-top"> 155 + <h1>Attic Viewer</h1> 156 + <span class="stats" id="stats"></span> 157 + </div> 158 + <div class="filters"> 159 + <select id="yearFilter"><option value="">All years</option></select> 160 + <select id="albumFilter"><option value="">All albums</option></select> 161 + <select id="typeFilter"> 162 + <option value="">All media</option> 163 + <option value="photo">Photos</option> 164 + <option value="video">Videos</option> 165 + </select> 166 + <button id="favFilter">Favorites</button> 167 + </div> 168 + </header> 169 + 170 + <div class="grid" id="grid"></div> 171 + <div class="loading" id="loadingIndicator" style="display:none">Loading more...</div> 172 + <div id="scrollSentinel" style="height:1px"></div> 173 + <div class="empty" id="emptyState" style="display:none">No assets match the current filters.</div> 174 + 175 + <div class="lightbox" id="lightbox"> 176 + <button class="lightbox-close" id="lbClose">&times;</button> 177 + <button class="lightbox-nav prev" id="lbPrev">&#8249;</button> 178 + <button class="lightbox-nav next" id="lbNext">&#8250;</button> 179 + <div class="lightbox-content" id="lbContent"></div> 180 + </div> 181 + 182 + <script> 183 + const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 184 + const state = { 185 + page: 1, 186 + pageSize: 50, 187 + totalCount: 0, 188 + loading: false, 189 + done: false, 190 + assets: [], 191 + filters: { year: '', album: '', type: '', favorites: false }, 192 + lightboxIndex: -1, 193 + }; 194 + 195 + const grid = document.getElementById('grid'); 196 + const loadingIndicator = document.getElementById('loadingIndicator'); 197 + const emptyState = document.getElementById('emptyState'); 198 + const statsEl = document.getElementById('stats'); 199 + 200 + // --- Filters --- 201 + 202 + async function loadFilters() { 203 + const res = await fetch('/api/filters'); 204 + const data = await res.json(); 205 + 206 + const yearSelect = document.getElementById('yearFilter'); 207 + data.years.forEach(y => { 208 + const opt = document.createElement('option'); 209 + opt.value = y.year; 210 + opt.textContent = `${y.year} (${y.count})`; 211 + yearSelect.appendChild(opt); 212 + }); 213 + 214 + const albumSelect = document.getElementById('albumFilter'); 215 + data.albums.forEach(a => { 216 + const opt = document.createElement('option'); 217 + opt.value = a.album; 218 + opt.textContent = `${a.album} (${a.count})`; 219 + albumSelect.appendChild(opt); 220 + }); 221 + 222 + statsEl.textContent = `${data.totalAssets.toLocaleString()} assets \u2022 ${data.totalPhotos.toLocaleString()} photos \u2022 ${data.totalVideos.toLocaleString()} videos`; 223 + } 224 + 225 + document.getElementById('yearFilter').addEventListener('change', e => { 226 + state.filters.year = e.target.value; 227 + resetAndLoad(); 228 + }); 229 + document.getElementById('albumFilter').addEventListener('change', e => { 230 + state.filters.album = e.target.value; 231 + resetAndLoad(); 232 + }); 233 + document.getElementById('typeFilter').addEventListener('change', e => { 234 + state.filters.type = e.target.value; 235 + resetAndLoad(); 236 + }); 237 + document.getElementById('favFilter').addEventListener('click', e => { 238 + state.filters.favorites = !state.filters.favorites; 239 + e.target.classList.toggle('active', state.filters.favorites); 240 + resetAndLoad(); 241 + }); 242 + 243 + function resetAndLoad() { 244 + state.page = 1; 245 + state.done = false; 246 + state.assets = []; 247 + grid.innerHTML = ''; 248 + emptyState.style.display = 'none'; 249 + loadPage(); 250 + } 251 + 252 + // --- Asset loading --- 253 + 254 + async function loadPage() { 255 + if (state.loading || state.done) return; 256 + state.loading = true; 257 + loadingIndicator.style.display = 'block'; 258 + 259 + const params = new URLSearchParams({ page: state.page, pageSize: state.pageSize }); 260 + if (state.filters.year) params.set('year', state.filters.year); 261 + if (state.filters.album) params.set('album', state.filters.album); 262 + if (state.filters.type) params.set('type', state.filters.type); 263 + if (state.filters.favorites) params.set('favorites', 'true'); 264 + 265 + const res = await fetch(`/api/assets?${params}`); 266 + const data = await res.json(); 267 + 268 + state.totalCount = data.totalCount; 269 + 270 + if (data.assets.length === 0 && state.page === 1) { 271 + emptyState.style.display = 'block'; 272 + } 273 + 274 + data.assets.forEach(asset => { 275 + const idx = state.assets.length; 276 + state.assets.push(asset); 277 + 278 + const div = document.createElement('div'); 279 + div.className = 'thumb'; 280 + div.dataset.index = idx; 281 + 282 + const img = document.createElement('img'); 283 + img.alt = asset.filename; 284 + img.dataset.thumbUrl = `/api/thumb/${asset.uuid}`; 285 + img.src = PLACEHOLDER; 286 + div.appendChild(img); 287 + 288 + if (asset.isVideo) { 289 + const play = document.createElement('div'); 290 + play.className = 'play-indicator'; 291 + div.appendChild(play); 292 + } 293 + 294 + if (asset.isFavorite) { 295 + const fav = document.createElement('span'); 296 + fav.className = 'fav'; 297 + fav.textContent = '\u2665'; 298 + div.appendChild(fav); 299 + } 300 + 301 + div.addEventListener('click', () => openLightbox(idx)); 302 + grid.appendChild(div); 303 + visibilityObserver.observe(div); 304 + }); 305 + 306 + if (state.assets.length >= data.totalCount) { 307 + state.done = true; 308 + } else { 309 + state.page++; 310 + } 311 + 312 + state.loading = false; 313 + loadingIndicator.style.display = 'none'; 314 + } 315 + 316 + // --- Viewport-based memory management --- 317 + // Two-zone strategy: load images near viewport, UNLOAD images far from it. 318 + // A 4032x3024 photo decodes to ~49MB bitmap. Without unloading, scrolling 319 + // through hundreds of photos accumulates gigabytes and crashes the tab. 320 + 321 + const visibilityObserver = new IntersectionObserver((entries) => { 322 + entries.forEach(entry => { 323 + const div = entry.target; 324 + const idx = parseInt(div.dataset.index); 325 + const img = div.querySelector('img'); 326 + if (!img) return; 327 + 328 + if (entry.isIntersecting) { 329 + // Entering the load zone — set the thumbnail src 330 + if (!img.dataset.active) { 331 + img.src = img.dataset.thumbUrl || state.assets[idx].imageURL; 332 + img.dataset.active = '1'; 333 + } 334 + } else { 335 + // Left the load zone — release memory 336 + if (img.dataset.active) { 337 + img.src = PLACEHOLDER; 338 + delete img.dataset.active; 339 + } 340 + } 341 + }); 342 + }, { rootMargin: '800px' }); // load zone: viewport + 800px each side 343 + 344 + // --- Infinite scroll --- 345 + 346 + const scrollObserver = new IntersectionObserver((entries) => { 347 + if (entries[0].isIntersecting) loadPage(); 348 + }, { rootMargin: '600px' }); 349 + scrollObserver.observe(document.getElementById('scrollSentinel')); 350 + 351 + // --- Lightbox --- 352 + 353 + function openLightbox(idx) { 354 + state.lightboxIndex = idx; 355 + renderLightbox(); 356 + document.getElementById('lightbox').classList.add('open'); 357 + } 358 + 359 + function closeLightbox() { 360 + const content = document.getElementById('lbContent'); 361 + // Stop any playing video before clearing 362 + const video = content.querySelector('video'); 363 + if (video) { video.pause(); video.src = ''; } 364 + content.innerHTML = ''; 365 + document.getElementById('lightbox').classList.remove('open'); 366 + state.lightboxIndex = -1; 367 + } 368 + 369 + async function renderLightbox() { 370 + const asset = state.assets[state.lightboxIndex]; 371 + if (!asset) return; 372 + 373 + const content = document.getElementById('lbContent'); 374 + 375 + // Stop previous video if navigating 376 + const prevVideo = content.querySelector('video'); 377 + if (prevVideo) { prevVideo.pause(); prevVideo.src = ''; } 378 + 379 + // Fetch fresh pre-signed URL for full-size 380 + const res = await fetch(`/api/assets/${asset.uuid}`); 381 + const detail = await res.json(); 382 + 383 + if (asset.isVideo) { 384 + content.innerHTML = ` 385 + <video controls preload="metadata" src="${detail.imageURL}" 386 + style="max-width:95vw;max-height:90vh"></video> 387 + <div class="lightbox-info">${asset.filename}</div>`; 388 + } else { 389 + content.innerHTML = ` 390 + <img src="${detail.imageURL}" alt="${asset.filename}"> 391 + <div class="lightbox-info">${asset.filename}</div>`; 392 + } 393 + } 394 + 395 + document.getElementById('lbClose').addEventListener('click', closeLightbox); 396 + document.getElementById('lightbox').addEventListener('click', e => { 397 + if (e.target.id === 'lightbox') closeLightbox(); 398 + }); 399 + 400 + document.getElementById('lbPrev').addEventListener('click', () => { 401 + if (state.lightboxIndex > 0) { 402 + state.lightboxIndex--; 403 + renderLightbox(); 404 + } 405 + }); 406 + document.getElementById('lbNext').addEventListener('click', () => { 407 + if (state.lightboxIndex < state.assets.length - 1) { 408 + state.lightboxIndex++; 409 + renderLightbox(); 410 + } 411 + }); 412 + 413 + document.addEventListener('keydown', e => { 414 + if (state.lightboxIndex < 0) return; 415 + if (e.key === 'Escape') closeLightbox(); 416 + if (e.key === 'ArrowLeft' && state.lightboxIndex > 0) { 417 + state.lightboxIndex--; 418 + renderLightbox(); 419 + } 420 + if (e.key === 'ArrowRight' && state.lightboxIndex < state.assets.length - 1) { 421 + state.lightboxIndex++; 422 + renderLightbox(); 423 + } 424 + }); 425 + 426 + // --- Init --- 427 + 428 + loadFilters(); 429 + loadPage(); 430 + </script> 431 + </body> 432 + </html>
+65
Sources/AtticCLI/ViewerCommand.swift
··· 1 + import ArgumentParser 2 + import AtticCore 3 + import Foundation 4 + 5 + struct ViewerCommand: AsyncParsableCommand { 6 + static let configuration = CommandConfiguration( 7 + commandName: "viewer", 8 + abstract: "Browse backed-up photos in your browser." 9 + ) 10 + 11 + @Option(name: .long, help: "Port to bind to (0 for automatic).") 12 + var port: Int = 0 13 + 14 + func run() async throws { 15 + let (_, s3, manifestStore) = try Dependencies.makeBackupDeps() 16 + let manifest = try await Dependencies.loadManifest(store: manifestStore) 17 + 18 + if manifest.entries.isEmpty { 19 + print("No backed-up assets found. Run 'attic backup' first.") 20 + return 21 + } 22 + 23 + let dataStore = ViewerDataStore() 24 + let total = manifest.entries.count 25 + 26 + let spinner = PreparationSpinner() 27 + spinner.updateStatus("Loading metadata... 0 / \(formatCount(total)) assets") 28 + spinner.start() 29 + 30 + await dataStore.load(manifest: manifest, s3: s3) { loaded, total in 31 + spinner.updateStatus( 32 + "Loading metadata... \(formatCount(loaded)) / \(formatCount(total)) assets" 33 + ) 34 + } 35 + 36 + spinner.stop() 37 + print(" Loaded \(formatCount(total)) assets") 38 + 39 + let thumbnailService = ThumbnailService(s3: s3, dataStore: dataStore) 40 + let server = ViewerServer( 41 + dataStore: dataStore, s3: s3, 42 + thumbnailProvider: thumbnailService, port: port 43 + ) 44 + 45 + try await server.start { actualPort in 46 + let url = "http://127.0.0.1:\(actualPort)" 47 + print(" Viewer running at \(url)") 48 + print(" Press Ctrl+C to stop\n") 49 + openBrowser(url: url) 50 + } 51 + } 52 + } 53 + 54 + private func formatCount(_ n: Int) -> String { 55 + let formatter = NumberFormatter() 56 + formatter.numberStyle = .decimal 57 + return formatter.string(from: NSNumber(value: n)) ?? "\(n)" 58 + } 59 + 60 + private func openBrowser(url: String) { 61 + let process = Process() 62 + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") 63 + process.arguments = [url] 64 + try? process.run() 65 + }
+209
Sources/AtticCLI/ViewerServer.swift
··· 1 + import AtticCore 2 + import Foundation 3 + import Hummingbird 4 + 5 + /// API response for a paginated asset list. 6 + struct AssetListResponse: ResponseEncodable { 7 + var assets: [AssetWithURL] 8 + var totalCount: Int 9 + var page: Int 10 + var pageSize: Int 11 + } 12 + 13 + /// Single asset with a pre-signed image URL. 14 + struct AssetWithURL: Codable { 15 + var uuid: String 16 + var filename: String 17 + var dateCreated: String? 18 + var year: Int? 19 + var albums: [String] 20 + var isFavorite: Bool 21 + var isVideo: Bool 22 + var width: Int 23 + var height: Int 24 + var imageURL: String 25 + } 26 + 27 + /// API response for a single asset detail. 28 + struct AssetDetailResponse: ResponseEncodable { 29 + var uuid: String 30 + var filename: String 31 + var dateCreated: String? 32 + var year: Int? 33 + var albums: [String] 34 + var isFavorite: Bool 35 + var isVideo: Bool 36 + var width: Int 37 + var height: Int 38 + var imageURL: String 39 + } 40 + 41 + /// API response for available filter values. 42 + struct FilterOptionsResponse: ResponseEncodable { 43 + var years: [YearCount] 44 + var albums: [AlbumCount] 45 + var totalAssets: Int 46 + var totalPhotos: Int 47 + var totalVideos: Int 48 + } 49 + 50 + /// Localhost HTTP server for the photo viewer. 51 + struct ViewerServer { 52 + let dataStore: ViewerDataStore 53 + let s3: S3Providing 54 + let thumbnailProvider: ThumbnailProviding? 55 + let port: Int 56 + 57 + init( 58 + dataStore: ViewerDataStore, 59 + s3: S3Providing, 60 + thumbnailProvider: ThumbnailProviding? = nil, 61 + port: Int = 0 62 + ) { 63 + self.dataStore = dataStore 64 + self.s3 = s3 65 + self.thumbnailProvider = thumbnailProvider 66 + self.port = port 67 + } 68 + 69 + func start(onReady: @escaping @Sendable (Int) -> Void = { _ in }) async throws { 70 + let router = buildRouter() 71 + let app = Application( 72 + router: router, 73 + configuration: .init(address: .hostname("127.0.0.1", port: port)), 74 + onServerRunning: { channel in 75 + let actualPort = channel.localAddress?.port ?? 8080 76 + onReady(actualPort) 77 + } 78 + ) 79 + try await app.runService() 80 + } 81 + 82 + func buildRouter() -> Router<BasicRequestContext> { 83 + let router = Router() 84 + 85 + router.get("/") { _, _ -> Response in 86 + let html = loadViewerHTML() 87 + return Response( 88 + status: .ok, 89 + headers: [.contentType: "text/html; charset=utf-8"], 90 + body: .init(byteBuffer: .init(string: html)) 91 + ) 92 + } 93 + 94 + router.get("/api/filters") { _, _ -> FilterOptionsResponse in 95 + let opts = await dataStore.filterOptions() 96 + return FilterOptionsResponse( 97 + years: opts.years, 98 + albums: opts.albums, 99 + totalAssets: opts.totalAssets, 100 + totalPhotos: opts.totalPhotos, 101 + totalVideos: opts.totalVideos 102 + ) 103 + } 104 + 105 + router.get("/api/assets") { request, _ -> AssetListResponse in 106 + let params = request.uri.queryParameters 107 + let page = params.get("page", as: Int.self) ?? 1 108 + let pageSize = params.get("pageSize", as: Int.self) ?? 50 109 + let year = params.get("year", as: Int.self) 110 + let album = params.get("album", as: String.self) 111 + let favorites = params.get("favorites", as: Bool.self) 112 + let mediaType = params.get("type", as: String.self) 113 + 114 + let result = await dataStore.query( 115 + year: year, album: album, favorites: favorites, 116 + mediaType: mediaType, page: page, pageSize: pageSize 117 + ) 118 + 119 + let assetsWithURLs = result.assets.map { asset in 120 + assetWithURL(asset, expires: 14400) 121 + } 122 + 123 + return AssetListResponse( 124 + assets: assetsWithURLs, 125 + totalCount: result.totalCount, 126 + page: result.page, 127 + pageSize: result.pageSize 128 + ) 129 + } 130 + 131 + router.get("/api/assets/:uuid") { _, context -> Response in 132 + let uuid = try context.parameters.require("uuid") 133 + guard let asset = await dataStore.asset(uuid: uuid) else { 134 + return Response(status: .notFound) 135 + } 136 + 137 + let detail = AssetDetailResponse( 138 + uuid: asset.uuid, 139 + filename: asset.filename, 140 + dateCreated: asset.dateCreated, 141 + year: asset.year, 142 + albums: asset.albums, 143 + isFavorite: asset.isFavorite, 144 + isVideo: asset.isVideo, 145 + width: asset.width, 146 + height: asset.height, 147 + imageURL: s3.presignedURL(key: asset.s3Key, expires: 14400) 148 + .absoluteString 149 + ) 150 + 151 + let data = try JSONEncoder().encode(detail) 152 + return Response( 153 + status: .ok, 154 + headers: [.contentType: "application/json"], 155 + body: .init(byteBuffer: .init(data: data)) 156 + ) 157 + } 158 + 159 + if let thumbProvider = thumbnailProvider { 160 + router.get("/api/thumb/:uuid") { _, context -> Response in 161 + let uuid = try context.parameters.require("uuid") 162 + guard S3Paths.isValidUUID(uuid) else { 163 + return Response(status: .badRequest) 164 + } 165 + do { 166 + let data = try await thumbProvider.thumbnail(uuid: uuid) 167 + return Response( 168 + status: .ok, 169 + headers: [ 170 + .contentType: "image/jpeg", 171 + .cacheControl: "public, max-age=31536000, immutable", 172 + ], 173 + body: .init(byteBuffer: .init(data: data)) 174 + ) 175 + } catch { 176 + return Response(status: .notFound) 177 + } 178 + } 179 + } 180 + 181 + return router 182 + } 183 + 184 + private func assetWithURL(_ asset: AssetView, expires: Int) -> AssetWithURL { 185 + AssetWithURL( 186 + uuid: asset.uuid, 187 + filename: asset.filename, 188 + dateCreated: asset.dateCreated, 189 + year: asset.year, 190 + albums: asset.albums, 191 + isFavorite: asset.isFavorite, 192 + isVideo: asset.isVideo, 193 + width: asset.width, 194 + height: asset.height, 195 + imageURL: s3.presignedURL(key: asset.s3Key, expires: expires) 196 + .absoluteString 197 + ) 198 + } 199 + } 200 + 201 + /// Load the embedded viewer HTML from the resource bundle. 202 + func loadViewerHTML() -> String { 203 + guard let url = Bundle.module.url(forResource: "viewer", withExtension: "html"), 204 + let html = try? String(contentsOf: url, encoding: .utf8) 205 + else { 206 + return "<html><body><h1>Error: viewer.html not found in bundle</h1></body></html>" 207 + } 208 + return html 209 + }
+63
Sources/AtticCore/ImageThumbnailer.swift
··· 1 + import CoreGraphics 2 + import Foundation 3 + import ImageIO 4 + import UniformTypeIdentifiers 5 + 6 + /// Generates JPEG thumbnails from image data using ImageIO. 7 + /// 8 + /// Supports any format macOS ImageIO handles: HEIC, JPEG, PNG, TIFF, RAW, etc. 9 + /// Automatically respects EXIF orientation. 10 + public enum ImageThumbnailer { 11 + /// Resize image data to a JPEG thumbnail. 12 + /// - Parameters: 13 + /// - data: Original image data (any ImageIO-supported format) 14 + /// - maxDimension: Maximum width or height in pixels (default 400) 15 + /// - quality: JPEG compression quality 0.0-1.0 (default 0.8) 16 + /// - Returns: JPEG data 17 + public static func thumbnail( 18 + from data: Data, 19 + maxDimension: Int = 400, 20 + quality: Double = 0.8 21 + ) throws -> Data { 22 + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { 23 + throw ThumbnailError.decodeFailed("CGImageSourceCreateWithData returned nil") 24 + } 25 + 26 + let options: [CFString: Any] = [ 27 + kCGImageSourceCreateThumbnailFromImageAlways: true, 28 + kCGImageSourceThumbnailMaxPixelSize: maxDimension, 29 + kCGImageSourceCreateThumbnailWithTransform: true, // Apply EXIF orientation 30 + ] 31 + 32 + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { 33 + throw ThumbnailError.decodeFailed("CGImageSourceCreateThumbnailAtIndex returned nil") 34 + } 35 + 36 + return try encodeJPEG(image: thumbnail, quality: quality) 37 + } 38 + 39 + /// Encode a CGImage as JPEG data. 40 + static func encodeJPEG(image: CGImage, quality: Double) throws -> Data { 41 + let data = NSMutableData() 42 + guard let dest = CGImageDestinationCreateWithData( 43 + data as CFMutableData, 44 + UTType.jpeg.identifier as CFString, 45 + 1, 46 + nil 47 + ) else { 48 + throw ThumbnailError.decodeFailed("CGImageDestinationCreateWithData failed") 49 + } 50 + 51 + let properties: [CFString: Any] = [ 52 + kCGImageDestinationLossyCompressionQuality: quality, 53 + ] 54 + 55 + CGImageDestinationAddImage(dest, image, properties as CFDictionary) 56 + 57 + guard CGImageDestinationFinalize(dest) else { 58 + throw ThumbnailError.decodeFailed("CGImageDestinationFinalize failed") 59 + } 60 + 61 + return data as Data 62 + } 63 + }
+4
Sources/AtticCore/MockS3Provider.swift
··· 50 50 ) 51 51 } 52 52 53 + public nonisolated func presignedURL(key: String, expires: Int) -> URL { 54 + URL(string: "http://mock-s3/\(key)?expires=\(expires)")! 55 + } 56 + 53 57 public func listObjects(prefix: String) async throws -> [S3ListObject] { 54 58 objects.keys 55 59 .filter { $0.hasPrefix(prefix) }
+6
Sources/AtticCore/S3Paths.swift
··· 62 62 return "metadata/assets/\(uuid).json" 63 63 } 64 64 65 + /// Generate S3 key for an asset's thumbnail JPEG. 66 + public static func thumbnailKey(uuid: String) throws -> String { 67 + try assertSafeUUID(uuid) 68 + return "thumbnails/\(uuid).jpg" 69 + } 70 + 65 71 /// Extract file extension from a UTI or filename. 66 72 public static func extensionFromUTIOrFilename( 67 73 uti: String?,
+3
Sources/AtticCore/S3Providing.swift
··· 38 38 39 39 /// List objects with a given prefix. 40 40 func listObjects(prefix: String) async throws -> [S3ListObject] 41 + 42 + /// Generate a pre-signed URL for temporary direct access to an object. 43 + func presignedURL(key: String, expires: Int) -> URL 41 44 } 42 45 43 46 /// Convenience overloads.
+29
Sources/AtticCore/ThumbnailCache.swift
··· 1 + import Foundation 2 + 3 + /// On-disk cache for thumbnail JPEG files at ~/.attic/thumbnails/. 4 + public struct ThumbnailCache: Sendable { 5 + public let directory: URL 6 + 7 + public init(directory: URL? = nil) { 8 + self.directory = directory 9 + ?? FileConfigProvider.defaultDirectory.appendingPathComponent("thumbnails") 10 + } 11 + 12 + /// Read a cached thumbnail, or nil if not cached. 13 + public func get(uuid: String) -> Data? { 14 + guard S3Paths.isValidUUID(uuid) else { return nil } 15 + let path = directory.appendingPathComponent("\(uuid).jpg") 16 + return try? Data(contentsOf: path) 17 + } 18 + 19 + /// Write a thumbnail to the cache directory. Creates the directory if needed. 20 + public func put(uuid: String, data: Data) throws { 21 + guard S3Paths.isValidUUID(uuid) else { return } 22 + let fm = FileManager.default 23 + if !fm.fileExists(atPath: directory.path) { 24 + try fm.createDirectory(at: directory, withIntermediateDirectories: true) 25 + } 26 + let path = directory.appendingPathComponent("\(uuid).jpg") 27 + try data.write(to: path) 28 + } 29 + }
+25
Sources/AtticCore/ThumbnailProviding.swift
··· 1 + import Foundation 2 + 3 + /// Protocol for serving thumbnail image data by asset UUID. 4 + public protocol ThumbnailProviding: Sendable { 5 + /// Returns JPEG thumbnail data for the given asset UUID. 6 + func thumbnail(uuid: String) async throws -> Data 7 + } 8 + 9 + /// Errors from the thumbnail system. 10 + public enum ThumbnailError: Error, CustomStringConvertible { 11 + case notFound(String) 12 + case decodeFailed(String) 13 + case s3Failure(String, Error) 14 + 15 + public var description: String { 16 + switch self { 17 + case let .notFound(uuid): 18 + "Thumbnail: asset not found: \(uuid)" 19 + case let .decodeFailed(uuid): 20 + "Thumbnail: failed to decode image for: \(uuid)" 21 + case let .s3Failure(uuid, error): 22 + "Thumbnail: S3 error for \(uuid): \(error)" 23 + } 24 + } 25 + }
+119
Sources/AtticCore/ThumbnailService.swift
··· 1 + import Foundation 2 + 3 + /// Orchestrates thumbnail generation with three-tier lookup, in-flight 4 + /// deduplication, and bounded concurrency. 5 + /// 6 + /// Lookup order: 7 + /// 1. Local disk cache (~/.attic/thumbnails/) 8 + /// 2. S3 thumbnail (thumbnails/{uuid}.jpg) 9 + /// 3. Generate from original (download → resize → save to cache + S3) 10 + public actor ThumbnailService: ThumbnailProviding { 11 + private let cache: ThumbnailCache 12 + private let s3: S3Providing 13 + private let dataStore: ViewerDataStore 14 + private let maxConcurrent: Int 15 + private var inFlight: [String: Task<Data, any Error>] = [:] 16 + private var activeGenerations = 0 17 + private var waiters: [CheckedContinuation<Void, Never>] = [] 18 + 19 + public init( 20 + cache: ThumbnailCache = ThumbnailCache(), 21 + s3: S3Providing, 22 + dataStore: ViewerDataStore, 23 + maxConcurrent: Int = 6 24 + ) { 25 + self.cache = cache 26 + self.s3 = s3 27 + self.dataStore = dataStore 28 + self.maxConcurrent = maxConcurrent 29 + } 30 + 31 + public func thumbnail(uuid: String) async throws -> Data { 32 + // 1. Deduplicate: if already generating this UUID, wait for it 33 + if let existing = inFlight[uuid] { 34 + return try await existing.value 35 + } 36 + 37 + // 2. Local disk cache 38 + if let cached = cache.get(uuid: uuid) { 39 + return cached 40 + } 41 + 42 + // 3. Start a generation task (S3 thumbnail → generate from original) 43 + let task = Task<Data, any Error> { [cache, s3] in 44 + // 3a. Check S3 for existing thumbnail 45 + let thumbKey = try S3Paths.thumbnailKey(uuid: uuid) 46 + if let meta = try? await s3.headObject(key: thumbKey), meta != nil { 47 + let data = try await s3.getObject(key: thumbKey) 48 + try? cache.put(uuid: uuid, data: data) 49 + return data 50 + } 51 + 52 + // 3b. Generate from original 53 + let asset = await self.dataStore.asset(uuid: uuid) 54 + guard let asset else { 55 + throw ThumbnailError.notFound(uuid) 56 + } 57 + 58 + // Wait for a concurrency slot 59 + await self.acquireSlot() 60 + defer { Task { await self.releaseSlot() } } 61 + 62 + let originalData: Data 63 + do { 64 + originalData = try await s3.getObject(key: asset.s3Key) 65 + } catch { 66 + throw ThumbnailError.s3Failure(uuid, error) 67 + } 68 + 69 + let jpegData: Data 70 + if asset.isVideo { 71 + jpegData = try VideoThumbnailer.thumbnail(from: originalData) 72 + } else { 73 + jpegData = try ImageThumbnailer.thumbnail(from: originalData) 74 + } 75 + 76 + // Save to local cache 77 + try? cache.put(uuid: uuid, data: jpegData) 78 + 79 + // Best-effort upload to S3 (don't fail if upload errors) 80 + try? await s3.putObject( 81 + key: thumbKey, body: jpegData, contentType: "image/jpeg" 82 + ) 83 + 84 + return jpegData 85 + } 86 + 87 + inFlight[uuid] = task 88 + 89 + do { 90 + let result = try await task.value 91 + inFlight.removeValue(forKey: uuid) 92 + return result 93 + } catch { 94 + inFlight.removeValue(forKey: uuid) 95 + throw error 96 + } 97 + } 98 + 99 + // MARK: - Concurrency limiting 100 + 101 + private func acquireSlot() async { 102 + if activeGenerations < maxConcurrent { 103 + activeGenerations += 1 104 + return 105 + } 106 + await withCheckedContinuation { continuation in 107 + waiters.append(continuation) 108 + } 109 + activeGenerations += 1 110 + } 111 + 112 + private func releaseSlot() { 113 + activeGenerations -= 1 114 + if !waiters.isEmpty { 115 + let next = waiters.removeFirst() 116 + next.resume() 117 + } 118 + } 119 + }
+8
Sources/AtticCore/URLSessionS3Client.swift
··· 138 138 return results 139 139 } 140 140 141 + public func presignedURL(key: String, expires: Int = 14400) -> URL { 142 + // makeRequest can only throw for invalid virtual-hosted URLs, which 143 + // would have failed at init time. Force-try is safe here. 144 + // swiftlint:disable:next force_try 145 + let request = try! makeRequest(key: key, method: "GET") 146 + return signer.signURL(url: request.url!, method: .GET, expires: expires) 147 + } 148 + 141 149 // MARK: - Helpers 142 150 143 151 private func makeRequest(key: String, method: String) throws -> URLRequest {
+61
Sources/AtticCore/VideoThumbnailer.swift
··· 1 + import AVFoundation 2 + import CoreGraphics 3 + import Foundation 4 + 5 + /// Extracts a poster frame from video files and produces a JPEG thumbnail. 6 + /// 7 + /// Seeks to ~1 second to avoid black fade-ins. Falls back to 0 for very 8 + /// short videos. Writes video data to a temp file since AVAssetImageGenerator 9 + /// requires a file URL. 10 + public enum VideoThumbnailer { 11 + /// Extract a poster frame from a video file and return as JPEG thumbnail. 12 + /// - Parameters: 13 + /// - fileURL: URL to the video file on disk 14 + /// - maxDimension: Maximum width or height in pixels (default 400) 15 + /// - quality: JPEG compression quality 0.0-1.0 (default 0.8) 16 + /// - Returns: JPEG data 17 + public static func thumbnail( 18 + from fileURL: URL, 19 + maxDimension: Int = 400, 20 + quality: Double = 0.8 21 + ) throws -> Data { 22 + let asset = AVURLAsset(url: fileURL) 23 + let generator = AVAssetImageGenerator(asset: asset) 24 + generator.appliesPreferredTrackTransform = true 25 + generator.maximumSize = CGSize(width: maxDimension, height: maxDimension) 26 + 27 + // Seek to 1 second to avoid black fade-in frames 28 + let seekTime = CMTime(seconds: 1.0, preferredTimescale: 600) 29 + var actualTime = CMTime.zero 30 + 31 + let cgImage: CGImage 32 + do { 33 + cgImage = try generator.copyCGImage(at: seekTime, actualTime: &actualTime) 34 + } catch { 35 + // If 1s fails (video shorter than 1s), try frame 0 36 + do { 37 + cgImage = try generator.copyCGImage(at: .zero, actualTime: &actualTime) 38 + } catch { 39 + throw ThumbnailError.decodeFailed("AVAssetImageGenerator failed: \(error)") 40 + } 41 + } 42 + 43 + return try ImageThumbnailer.encodeJPEG(image: cgImage, quality: quality) 44 + } 45 + 46 + /// Generate a thumbnail from video data by writing to a temp file first. 47 + /// Cleans up the temp file after generation. 48 + public static func thumbnail( 49 + from data: Data, 50 + maxDimension: Int = 400, 51 + quality: Double = 0.8 52 + ) throws -> Data { 53 + let tempURL = FileManager.default.temporaryDirectory 54 + .appendingPathComponent("attic-video-\(UUID().uuidString).mov") 55 + 56 + defer { try? FileManager.default.removeItem(at: tempURL) } 57 + 58 + try data.write(to: tempURL) 59 + return try thumbnail(from: tempURL, maxDimension: maxDimension, quality: quality) 60 + } 61 + }
+265
Sources/AtticCore/ViewerDataStore.swift
··· 1 + import Foundation 2 + 3 + /// Lightweight view of an asset for the viewer UI. 4 + public struct AssetView: Codable, Sendable { 5 + public var uuid: String 6 + public var filename: String 7 + public var dateCreated: String? 8 + public var year: Int? 9 + public var albums: [String] 10 + public var isFavorite: Bool 11 + public var isVideo: Bool 12 + public var width: Int 13 + public var height: Int 14 + public var s3Key: String 15 + 16 + public init( 17 + uuid: String, filename: String, dateCreated: String?, 18 + year: Int?, albums: [String], isFavorite: Bool, 19 + isVideo: Bool, width: Int, height: Int, s3Key: String 20 + ) { 21 + self.uuid = uuid 22 + self.filename = filename 23 + self.dateCreated = dateCreated 24 + self.year = year 25 + self.albums = albums 26 + self.isFavorite = isFavorite 27 + self.isVideo = isVideo 28 + self.width = width 29 + self.height = height 30 + self.s3Key = s3Key 31 + } 32 + } 33 + 34 + /// Available filter values for the viewer UI. 35 + public struct FilterOptions: Codable, Sendable { 36 + public var years: [YearCount] 37 + public var albums: [AlbumCount] 38 + public var totalAssets: Int 39 + public var totalPhotos: Int 40 + public var totalVideos: Int 41 + } 42 + 43 + /// Year with asset count. 44 + public struct YearCount: Codable, Sendable { 45 + public var year: Int 46 + public var count: Int 47 + } 48 + 49 + /// Album title with asset count. 50 + public struct AlbumCount: Codable, Sendable { 51 + public var album: String 52 + public var count: Int 53 + } 54 + 55 + /// Paginated result from a filtered query. 56 + public struct AssetPage: Sendable { 57 + public var assets: [AssetView] 58 + public var totalCount: Int 59 + public var page: Int 60 + public var pageSize: Int 61 + } 62 + 63 + /// Loads all backed-up asset metadata from S3 into memory for fast filtering. 64 + public actor ViewerDataStore { 65 + private var assets: [AssetView] = [] 66 + private var cachedFilterOptions: FilterOptions? 67 + 68 + public init() {} 69 + 70 + /// Load metadata for all manifest entries from S3. 71 + /// Calls `onProgress` with (loaded, total) counts. 72 + public func load( 73 + manifest: Manifest, 74 + s3: S3Providing, 75 + onProgress: @escaping @Sendable (Int, Int) -> Void = { _, _ in } 76 + ) async { 77 + let entries = Array(manifest.entries.values) 78 + let total = entries.count 79 + if total == 0 { return } 80 + 81 + let loaded = LoadCounter() 82 + 83 + let maxConcurrency = 20 84 + 85 + await withTaskGroup(of: AssetView?.self) { group in 86 + for (index, entry) in entries.enumerated() { 87 + // Limit concurrency by waiting for a result before adding more 88 + if index >= maxConcurrency { 89 + if let view = await group.next() ?? nil { 90 + assets.append(view) 91 + } 92 + } 93 + 94 + group.addTask { 95 + do { 96 + let key = try S3Paths.metadataKey(uuid: entry.uuid) 97 + let data = try await s3.getObject(key: key) 98 + let meta = try JSONDecoder().decode(AssetMetadata.self, from: data) 99 + let count = await loaded.increment() 100 + onProgress(count, total) 101 + return Self.assetView(from: meta) 102 + } catch { 103 + let count = await loaded.increment() 104 + onProgress(count, total) 105 + return nil 106 + } 107 + } 108 + } 109 + 110 + // Drain remaining tasks 111 + for await view in group { 112 + if let view { assets.append(view) } 113 + } 114 + } 115 + 116 + // Sort by date descending (newest first), nil dates last 117 + assets.sort { a, b in 118 + switch (a.dateCreated, b.dateCreated) { 119 + case let (dateA?, dateB?): return dateA > dateB 120 + case (nil, _): return false 121 + case (_, nil): return true 122 + } 123 + } 124 + 125 + cachedFilterOptions = buildFilterOptions() 126 + } 127 + 128 + /// Load from pre-built asset views (for testing). 129 + public func load(assets: [AssetView]) { 130 + self.assets = assets 131 + cachedFilterOptions = buildFilterOptions() 132 + } 133 + 134 + /// Query assets with optional filters, paginated. 135 + public func query( 136 + year: Int? = nil, 137 + album: String? = nil, 138 + favorites: Bool? = nil, 139 + mediaType: String? = nil, 140 + page: Int = 1, 141 + pageSize: Int = 50 142 + ) -> AssetPage { 143 + var filtered = assets 144 + 145 + if let year { 146 + filtered = filtered.filter { $0.year == year } 147 + } 148 + if let album { 149 + filtered = filtered.filter { $0.albums.contains(album) } 150 + } 151 + if let favorites, favorites { 152 + filtered = filtered.filter { $0.isFavorite } 153 + } 154 + if let mediaType { 155 + switch mediaType { 156 + case "photo": filtered = filtered.filter { !$0.isVideo } 157 + case "video": filtered = filtered.filter { $0.isVideo } 158 + default: break 159 + } 160 + } 161 + 162 + let totalCount = filtered.count 163 + let startIndex = (page - 1) * pageSize 164 + let endIndex = min(startIndex + pageSize, totalCount) 165 + let pageAssets = startIndex < totalCount 166 + ? Array(filtered[startIndex..<endIndex]) 167 + : [] 168 + 169 + return AssetPage( 170 + assets: pageAssets, 171 + totalCount: totalCount, 172 + page: page, 173 + pageSize: pageSize 174 + ) 175 + } 176 + 177 + /// Available filter options derived from loaded data. 178 + public func filterOptions() -> FilterOptions { 179 + cachedFilterOptions ?? FilterOptions( 180 + years: [], albums: [], 181 + totalAssets: 0, totalPhotos: 0, totalVideos: 0 182 + ) 183 + } 184 + 185 + /// Find a single asset by UUID. 186 + public func asset(uuid: String) -> AssetView? { 187 + assets.first { $0.uuid == uuid } 188 + } 189 + 190 + // MARK: - Private 191 + 192 + private func buildFilterOptions() -> FilterOptions { 193 + var yearCounts: [Int: Int] = [:] 194 + var albumCounts: [String: Int] = [:] 195 + var photoCount = 0 196 + var videoCount = 0 197 + 198 + for asset in assets { 199 + if let year = asset.year { 200 + yearCounts[year, default: 0] += 1 201 + } 202 + for album in asset.albums { 203 + albumCounts[album, default: 0] += 1 204 + } 205 + if asset.isVideo { videoCount += 1 } else { photoCount += 1 } 206 + } 207 + 208 + return FilterOptions( 209 + years: yearCounts.map { YearCount(year: $0.key, count: $0.value) } 210 + .sorted { $0.year > $1.year }, 211 + albums: albumCounts.map { AlbumCount(album: $0.key, count: $0.value) } 212 + .sorted { $0.count > $1.count }, 213 + totalAssets: assets.count, 214 + totalPhotos: photoCount, 215 + totalVideos: videoCount 216 + ) 217 + } 218 + 219 + private static let videoUTIs: Set<String> = [ 220 + "public.mpeg-4", "com.apple.quicktime-movie", 221 + "com.apple.m4v-video", "public.avi", 222 + ] 223 + 224 + private static let yearExtractor: DateFormatter = { 225 + let df = DateFormatter() 226 + df.dateFormat = "yyyy" 227 + df.timeZone = TimeZone(identifier: "UTC") 228 + return df 229 + }() 230 + 231 + private static func assetView(from meta: AssetMetadata) -> AssetView { 232 + let year: Int? 233 + if let dateStr = meta.dateCreated, 234 + let date = ISO8601DateFormatter().date(from: dateStr) 235 + { 236 + year = Calendar.current.component(.year, from: date) 237 + } else { 238 + year = nil 239 + } 240 + 241 + let isVideo = meta.type.map { videoUTIs.contains($0) } ?? false 242 + 243 + return AssetView( 244 + uuid: meta.uuid, 245 + filename: meta.originalFilename, 246 + dateCreated: meta.dateCreated, 247 + year: year, 248 + albums: meta.albums.map(\.title), 249 + isFavorite: meta.favorite, 250 + isVideo: isVideo, 251 + width: meta.width, 252 + height: meta.height, 253 + s3Key: meta.s3Key 254 + ) 255 + } 256 + } 257 + 258 + /// Thread-safe counter for tracking concurrent progress. 259 + private actor LoadCounter { 260 + private var count = 0 261 + func increment() -> Int { 262 + count += 1 263 + return count 264 + } 265 + }
+8
Tests/AtticCoreTests/NetworkPauseTests.swift
··· 48 48 func listObjects(prefix: String) async throws -> [S3ListObject] { 49 49 try await inner.listObjects(prefix: prefix) 50 50 } 51 + 52 + nonisolated func presignedURL(key: String, expires: Int) -> URL { 53 + URL(string: "http://mock-s3/\(key)?expires=\(expires)")! 54 + } 51 55 } 52 56 53 57 /// Throws a real URLError so typed isNetworkDown() detection works end-to-end. ··· 93 97 94 98 func listObjects(prefix: String) async throws -> [S3ListObject] { 95 99 try await inner.listObjects(prefix: prefix) 100 + } 101 + 102 + nonisolated func presignedURL(key: String, expires: Int) -> URL { 103 + URL(string: "http://mock-s3/\(key)?expires=\(expires)")! 96 104 } 97 105 } 98 106
+25
Tests/AtticCoreTests/PreSignedURLTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 3 + import Testing 4 + 5 + @Suite 6 + struct PreSignedURLTests { 7 + @Test func mockReturnsExpectedURL() async throws { 8 + let s3 = MockS3Provider() 9 + let url = await s3.presignedURL(key: "originals/2024/07/test-uuid.heic", expires: 14400) 10 + #expect(url.absoluteString == "http://mock-s3/originals/2024/07/test-uuid.heic?expires=14400") 11 + } 12 + 13 + @Test func mockURLIncludesExpiryValue() async throws { 14 + let s3 = MockS3Provider() 15 + let url = await s3.presignedURL(key: "originals/2024/01/abc.jpg", expires: 3600) 16 + #expect(url.absoluteString.contains("expires=3600")) 17 + } 18 + 19 + @Test func mockURLPreservesS3Key() async throws { 20 + let s3 = MockS3Provider() 21 + let key = "metadata/assets/8A3B1C2D-4E5F-6789-ABCD-EF0123456789.json" 22 + let url = await s3.presignedURL(key: key, expires: 14400) 23 + #expect(url.absoluteString.contains(key)) 24 + } 25 + }
+4
Tests/AtticCoreTests/S3ManifestStoreTests.swift
··· 82 82 func listObjects(prefix: String) async throws -> [S3ListObject] { 83 83 [] 84 84 } 85 + 86 + nonisolated func presignedURL(key: String, expires: Int) -> URL { 87 + URL(string: "http://mock-s3/\(key)?expires=\(expires)")! 88 + } 85 89 } 86 90 87 91 struct S3ManifestStoreTests {
+303
Tests/AtticCoreTests/ThumbnailTests.swift
··· 1 + @testable import AtticCore 2 + import CoreGraphics 3 + import Foundation 4 + import Testing 5 + 6 + // MARK: - ThumbnailCache Tests 7 + 8 + @Suite 9 + struct ThumbnailCacheTests { 10 + private func makeTempDir() throws -> URL { 11 + let dir = FileManager.default.temporaryDirectory 12 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 13 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 14 + return dir 15 + } 16 + 17 + @Test func getReturnsNilForMissingUUID() throws { 18 + let dir = try makeTempDir() 19 + defer { try? FileManager.default.removeItem(at: dir) } 20 + let cache = ThumbnailCache(directory: dir) 21 + #expect(cache.get(uuid: "nonexistent") == nil) 22 + } 23 + 24 + @Test func putAndGetRoundTrips() throws { 25 + let dir = try makeTempDir() 26 + defer { try? FileManager.default.removeItem(at: dir) } 27 + let cache = ThumbnailCache(directory: dir) 28 + let data = Data("fake-jpeg".utf8) 29 + 30 + try cache.put(uuid: "test-uuid-123", data: data) 31 + let retrieved = cache.get(uuid: "test-uuid-123") 32 + #expect(retrieved == data) 33 + } 34 + 35 + @Test func putCreatesDirectoryIfNeeded() throws { 36 + let dir = FileManager.default.temporaryDirectory 37 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 38 + defer { try? FileManager.default.removeItem(at: dir) } 39 + 40 + #expect(!FileManager.default.fileExists(atPath: dir.path)) 41 + 42 + let cache = ThumbnailCache(directory: dir) 43 + try cache.put(uuid: "test-uuid", data: Data("data".utf8)) 44 + 45 + #expect(FileManager.default.fileExists(atPath: dir.path)) 46 + } 47 + 48 + @Test func getRejectsUnsafeUUID() throws { 49 + let dir = try makeTempDir() 50 + defer { try? FileManager.default.removeItem(at: dir) } 51 + let cache = ThumbnailCache(directory: dir) 52 + 53 + // Path traversal attempt 54 + #expect(cache.get(uuid: "../../../etc/passwd") == nil) 55 + #expect(cache.get(uuid: "uuid/with/slashes") == nil) 56 + } 57 + 58 + @Test func putIgnoresUnsafeUUID() throws { 59 + let dir = try makeTempDir() 60 + defer { try? FileManager.default.removeItem(at: dir) } 61 + let cache = ThumbnailCache(directory: dir) 62 + 63 + // Should silently do nothing 64 + try cache.put(uuid: "../../../bad", data: Data("data".utf8)) 65 + #expect(cache.get(uuid: "../../../bad") == nil) 66 + } 67 + } 68 + 69 + // MARK: - S3Paths.thumbnailKey Tests 70 + 71 + @Suite 72 + struct ThumbnailKeyTests { 73 + @Test func thumbnailKeyGeneratesCorrectPath() throws { 74 + let key = try S3Paths.thumbnailKey(uuid: "abc-123") 75 + #expect(key == "thumbnails/abc-123.jpg") 76 + } 77 + 78 + @Test func thumbnailKeyRejectsUnsafeUUID() { 79 + #expect(throws: S3PathError.self) { 80 + try S3Paths.thumbnailKey(uuid: "../../../etc") 81 + } 82 + #expect(throws: S3PathError.self) { 83 + try S3Paths.thumbnailKey(uuid: "uuid/with/slashes") 84 + } 85 + } 86 + } 87 + 88 + // MARK: - ImageThumbnailer Tests 89 + 90 + @Suite 91 + struct ImageThumbnailerTests { 92 + @Test func thumbnailFromValidJPEGData() throws { 93 + // Create a minimal valid JPEG via CGImage 94 + let jpegData = try createTestJPEG(width: 800, height: 600) 95 + let thumb = try ImageThumbnailer.thumbnail(from: jpegData, maxDimension: 200) 96 + 97 + #expect(!thumb.isEmpty) 98 + // Verify it's JPEG (starts with FF D8) 99 + #expect(thumb[0] == 0xFF) 100 + #expect(thumb[1] == 0xD8) 101 + } 102 + 103 + @Test func thumbnailFromInvalidDataThrows() { 104 + let badData = Data("not an image".utf8) 105 + #expect(throws: ThumbnailError.self) { 106 + try ImageThumbnailer.thumbnail(from: badData) 107 + } 108 + } 109 + 110 + @Test func encodeJPEGProducesValidOutput() throws { 111 + let cgImage = try createTestCGImage(width: 100, height: 100) 112 + let data = try ImageThumbnailer.encodeJPEG(image: cgImage, quality: 0.8) 113 + 114 + #expect(!data.isEmpty) 115 + #expect(data[0] == 0xFF) 116 + #expect(data[1] == 0xD8) 117 + } 118 + 119 + // Helper to create a test JPEG 120 + private func createTestJPEG(width: Int, height: Int) throws -> Data { 121 + let cgImage = try createTestCGImage(width: width, height: height) 122 + return try ImageThumbnailer.encodeJPEG(image: cgImage, quality: 0.9) 123 + } 124 + 125 + private func createTestCGImage(width: Int, height: Int) throws -> CGImage { 126 + let colorSpace = CGColorSpaceCreateDeviceRGB() 127 + guard let ctx = CGContext( 128 + data: nil, width: width, height: height, 129 + bitsPerComponent: 8, bytesPerRow: width * 4, 130 + space: colorSpace, 131 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 132 + ) else { 133 + throw ThumbnailError.decodeFailed("Could not create test CGContext") 134 + } 135 + ctx.setFillColor(red: 0.5, green: 0.3, blue: 0.8, alpha: 1.0) 136 + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) 137 + guard let image = ctx.makeImage() else { 138 + throw ThumbnailError.decodeFailed("Could not create test CGImage") 139 + } 140 + return image 141 + } 142 + } 143 + 144 + // MARK: - ThumbnailService Tests 145 + 146 + @Suite 147 + struct ThumbnailServiceTests { 148 + private func makeAssetView( 149 + uuid: String, 150 + isVideo: Bool = false 151 + ) -> AssetView { 152 + AssetView( 153 + uuid: uuid, 154 + filename: "\(uuid).heic", 155 + dateCreated: "2024-07-14T12:00:00Z", 156 + year: 2024, 157 + albums: [], 158 + isFavorite: false, 159 + isVideo: isVideo, 160 + width: 4032, 161 + height: 3024, 162 + s3Key: "originals/2024/07/\(uuid).heic" 163 + ) 164 + } 165 + 166 + private func createTestJPEG() throws -> Data { 167 + let colorSpace = CGColorSpaceCreateDeviceRGB() 168 + guard let ctx = CGContext( 169 + data: nil, width: 100, height: 100, 170 + bitsPerComponent: 8, bytesPerRow: 400, 171 + space: colorSpace, 172 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 173 + ), 174 + let image = ctx.makeImage() 175 + else { 176 + throw ThumbnailError.decodeFailed("Could not create test image") 177 + } 178 + return try ImageThumbnailer.encodeJPEG(image: image, quality: 0.8) 179 + } 180 + 181 + @Test func servesFromLocalCacheFirst() async throws { 182 + let dir = FileManager.default.temporaryDirectory 183 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 184 + defer { try? FileManager.default.removeItem(at: dir) } 185 + 186 + let cache = ThumbnailCache(directory: dir) 187 + let cachedData = Data("cached-thumbnail".utf8) 188 + try cache.put(uuid: "test-uuid", data: cachedData) 189 + 190 + let s3 = MockS3Provider() 191 + let dataStore = ViewerDataStore() 192 + 193 + let service = ThumbnailService( 194 + cache: cache, s3: s3, dataStore: dataStore 195 + ) 196 + 197 + let result = try await service.thumbnail(uuid: "test-uuid") 198 + #expect(result == cachedData) 199 + // Should not hit S3 at all 200 + let getCount = await s3.getCount 201 + #expect(getCount == 0) 202 + } 203 + 204 + @Test func servesFromS3ThumbnailWhenNotCachedLocally() async throws { 205 + let dir = FileManager.default.temporaryDirectory 206 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 207 + defer { try? FileManager.default.removeItem(at: dir) } 208 + 209 + let thumbData = Data("s3-thumbnail".utf8) 210 + let s3 = MockS3Provider(objects: [ 211 + "thumbnails/test-uuid.jpg": thumbData, 212 + ]) 213 + let dataStore = ViewerDataStore() 214 + let cache = ThumbnailCache(directory: dir) 215 + 216 + let service = ThumbnailService( 217 + cache: cache, s3: s3, dataStore: dataStore 218 + ) 219 + 220 + let result = try await service.thumbnail(uuid: "test-uuid") 221 + #expect(result == thumbData) 222 + 223 + // Should also be written to local cache 224 + let cached = cache.get(uuid: "test-uuid") 225 + #expect(cached == thumbData) 226 + } 227 + 228 + @Test func generatesFromOriginalWhenNoThumbnailExists() async throws { 229 + let dir = FileManager.default.temporaryDirectory 230 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 231 + defer { try? FileManager.default.removeItem(at: dir) } 232 + 233 + let originalJPEG = try createTestJPEG() 234 + let s3 = MockS3Provider(objects: [ 235 + "originals/2024/07/photo-uuid.heic": originalJPEG, 236 + ]) 237 + let dataStore = ViewerDataStore() 238 + let asset = makeAssetView(uuid: "photo-uuid") 239 + await dataStore.load(assets: [asset]) 240 + 241 + let cache = ThumbnailCache(directory: dir) 242 + let service = ThumbnailService( 243 + cache: cache, s3: s3, dataStore: dataStore 244 + ) 245 + 246 + let result = try await service.thumbnail(uuid: "photo-uuid") 247 + #expect(!result.isEmpty) 248 + // Should be valid JPEG 249 + #expect(result[0] == 0xFF) 250 + #expect(result[1] == 0xD8) 251 + 252 + // Should be cached locally now 253 + let cached = cache.get(uuid: "photo-uuid") 254 + #expect(cached == result) 255 + 256 + // Should have been uploaded to S3 as thumbnail 257 + let s3Thumb = await s3.objects["thumbnails/photo-uuid.jpg"] 258 + #expect(s3Thumb != nil) 259 + } 260 + 261 + @Test func throwsNotFoundForUnknownUUID() async throws { 262 + let s3 = MockS3Provider() 263 + let dataStore = ViewerDataStore() 264 + let service = ThumbnailService(s3: s3, dataStore: dataStore) 265 + 266 + do { 267 + _ = try await service.thumbnail(uuid: "nonexistent") 268 + #expect(Bool(false), "Should have thrown") 269 + } catch let error as ThumbnailError { 270 + if case .notFound(let uuid) = error { 271 + #expect(uuid == "nonexistent") 272 + } else { 273 + #expect(Bool(false), "Wrong error case: \(error)") 274 + } 275 + } 276 + } 277 + 278 + @Test func deduplicatesInFlightRequests() async throws { 279 + let dir = FileManager.default.temporaryDirectory 280 + .appendingPathComponent("attic-test-\(UUID().uuidString)") 281 + defer { try? FileManager.default.removeItem(at: dir) } 282 + 283 + let originalJPEG = try createTestJPEG() 284 + let s3 = MockS3Provider(objects: [ 285 + "originals/2024/07/dedup-uuid.heic": originalJPEG, 286 + ]) 287 + let dataStore = ViewerDataStore() 288 + let asset = makeAssetView(uuid: "dedup-uuid") 289 + await dataStore.load(assets: [asset]) 290 + 291 + let cache = ThumbnailCache(directory: dir) 292 + let service = ThumbnailService( 293 + cache: cache, s3: s3, dataStore: dataStore 294 + ) 295 + 296 + // Launch multiple concurrent requests for the same UUID 297 + async let r1 = service.thumbnail(uuid: "dedup-uuid") 298 + async let r2 = service.thumbnail(uuid: "dedup-uuid") 299 + 300 + let (result1, result2) = try await (r1, r2) 301 + #expect(result1 == result2) 302 + } 303 + }
+4
Tests/AtticCoreTests/VerifyPipelineTests.swift
··· 85 85 func listObjects(prefix: String) async throws -> [S3ListObject] { 86 86 [] 87 87 } 88 + 89 + nonisolated func presignedURL(key: String, expires: Int) -> URL { 90 + URL(string: "http://mock-s3/\(key)?expires=\(expires)")! 91 + } 88 92 } 89 93 90 94 private enum FailingS3Error: Error {
+219
Tests/AtticCoreTests/ViewerDataStoreTests.swift
··· 1 + @testable import AtticCore 2 + import Foundation 3 + import Testing 4 + 5 + @Suite 6 + struct ViewerDataStoreTests { 7 + // MARK: - Test helpers 8 + 9 + private func makeAssetView( 10 + uuid: String, 11 + year: Int? = 2024, 12 + albums: [String] = [], 13 + isFavorite: Bool = false, 14 + isVideo: Bool = false 15 + ) -> AssetView { 16 + AssetView( 17 + uuid: uuid, 18 + filename: "\(uuid).heic", 19 + dateCreated: year.map { "\($0)-07-14T12:00:00Z" }, 20 + year: year, 21 + albums: albums, 22 + isFavorite: isFavorite, 23 + isVideo: isVideo, 24 + width: 4032, 25 + height: 3024, 26 + s3Key: "originals/\(year ?? 0)/07/\(uuid).heic" 27 + ) 28 + } 29 + 30 + // MARK: - Query tests 31 + 32 + @Test func queryReturnsAllAssetsUnfiltered() async { 33 + let store = ViewerDataStore() 34 + let assets = (1...5).map { makeAssetView(uuid: "uuid-\($0)") } 35 + await store.load(assets: assets) 36 + 37 + let result = await store.query() 38 + #expect(result.totalCount == 5) 39 + #expect(result.assets.count == 5) 40 + #expect(result.page == 1) 41 + } 42 + 43 + @Test func queryFiltersByYear() async { 44 + let store = ViewerDataStore() 45 + let assets = [ 46 + makeAssetView(uuid: "a", year: 2023), 47 + makeAssetView(uuid: "b", year: 2024), 48 + makeAssetView(uuid: "c", year: 2024), 49 + ] 50 + await store.load(assets: assets) 51 + 52 + let result = await store.query(year: 2024) 53 + #expect(result.totalCount == 2) 54 + #expect(result.assets.allSatisfy { $0.year == 2024 }) 55 + } 56 + 57 + @Test func queryFiltersByAlbum() async { 58 + let store = ViewerDataStore() 59 + let assets = [ 60 + makeAssetView(uuid: "a", albums: ["Vacation"]), 61 + makeAssetView(uuid: "b", albums: ["Work"]), 62 + makeAssetView(uuid: "c", albums: ["Vacation", "Favorites"]), 63 + ] 64 + await store.load(assets: assets) 65 + 66 + let result = await store.query(album: "Vacation") 67 + #expect(result.totalCount == 2) 68 + } 69 + 70 + @Test func queryFiltersByFavorites() async { 71 + let store = ViewerDataStore() 72 + let assets = [ 73 + makeAssetView(uuid: "a", isFavorite: true), 74 + makeAssetView(uuid: "b", isFavorite: false), 75 + makeAssetView(uuid: "c", isFavorite: true), 76 + ] 77 + await store.load(assets: assets) 78 + 79 + let result = await store.query(favorites: true) 80 + #expect(result.totalCount == 2) 81 + #expect(result.assets.allSatisfy { $0.isFavorite }) 82 + } 83 + 84 + @Test func queryFiltersByMediaType() async { 85 + let store = ViewerDataStore() 86 + let assets = [ 87 + makeAssetView(uuid: "a", isVideo: false), 88 + makeAssetView(uuid: "b", isVideo: true), 89 + makeAssetView(uuid: "c", isVideo: false), 90 + ] 91 + await store.load(assets: assets) 92 + 93 + let photos = await store.query(mediaType: "photo") 94 + #expect(photos.totalCount == 2) 95 + 96 + let videos = await store.query(mediaType: "video") 97 + #expect(videos.totalCount == 1) 98 + } 99 + 100 + @Test func queryPaginates() async { 101 + let store = ViewerDataStore() 102 + let assets = (1...10).map { makeAssetView(uuid: "uuid-\($0)") } 103 + await store.load(assets: assets) 104 + 105 + let page1 = await store.query(page: 1, pageSize: 3) 106 + #expect(page1.assets.count == 3) 107 + #expect(page1.totalCount == 10) 108 + 109 + let page4 = await store.query(page: 4, pageSize: 3) 110 + #expect(page4.assets.count == 1) 111 + } 112 + 113 + @Test func queryBeyondLastPageReturnsEmpty() async { 114 + let store = ViewerDataStore() 115 + let assets = [makeAssetView(uuid: "a")] 116 + await store.load(assets: assets) 117 + 118 + let result = await store.query(page: 5, pageSize: 50) 119 + #expect(result.assets.isEmpty) 120 + #expect(result.totalCount == 1) 121 + } 122 + 123 + @Test func queryCombinesMultipleFilters() async { 124 + let store = ViewerDataStore() 125 + let assets = [ 126 + makeAssetView(uuid: "a", year: 2024, albums: ["Vacation"], isFavorite: true), 127 + makeAssetView(uuid: "b", year: 2024, albums: ["Vacation"], isFavorite: false), 128 + makeAssetView(uuid: "c", year: 2023, albums: ["Vacation"], isFavorite: true), 129 + ] 130 + await store.load(assets: assets) 131 + 132 + let result = await store.query(year: 2024, album: "Vacation", favorites: true) 133 + #expect(result.totalCount == 1) 134 + #expect(result.assets.first?.uuid == "a") 135 + } 136 + 137 + // MARK: - Filter options 138 + 139 + @Test func filterOptionsReflectsLoadedData() async { 140 + let store = ViewerDataStore() 141 + let assets = [ 142 + makeAssetView(uuid: "a", year: 2024, albums: ["Vacation"], isVideo: false), 143 + makeAssetView(uuid: "b", year: 2024, albums: ["Work"], isVideo: true), 144 + makeAssetView(uuid: "c", year: 2023, albums: ["Vacation"], isVideo: false), 145 + ] 146 + await store.load(assets: assets) 147 + 148 + let opts = await store.filterOptions() 149 + #expect(opts.totalAssets == 3) 150 + #expect(opts.totalPhotos == 2) 151 + #expect(opts.totalVideos == 1) 152 + #expect(opts.years.count == 2) 153 + #expect(opts.albums.count == 2) 154 + } 155 + 156 + // MARK: - Edge cases 157 + 158 + @Test func emptyManifestLoadsNothing() async { 159 + let store = ViewerDataStore() 160 + let s3 = MockS3Provider() 161 + let manifest = Manifest() 162 + 163 + await store.load(manifest: manifest, s3: s3) 164 + 165 + let result = await store.query() 166 + #expect(result.totalCount == 0) 167 + #expect(result.assets.isEmpty) 168 + } 169 + 170 + @Test func corruptMetadataIsSkipped() async { 171 + let store = ViewerDataStore() 172 + let s3 = MockS3Provider(objects: [ 173 + "metadata/assets/good-uuid.json": try! JSONEncoder().encode( 174 + AssetMetadata( 175 + uuid: "good-uuid", originalFilename: "IMG.HEIC", 176 + dateCreated: "2024-01-15T12:00:00Z", 177 + width: 4032, height: 3024, 178 + latitude: nil, longitude: nil, fileSize: nil, 179 + type: "public.heic", favorite: false, 180 + title: nil, description: nil, 181 + albums: [], keywords: [], people: [], 182 + hasEdit: false, editedAt: nil, editor: nil, 183 + s3Key: "originals/2024/01/good-uuid.heic", 184 + checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z" 185 + ) 186 + ), 187 + "metadata/assets/bad-uuid.json": Data("not json".utf8), 188 + ]) 189 + 190 + var manifest = Manifest() 191 + manifest.entries["good-uuid"] = ManifestEntry( 192 + uuid: "good-uuid", s3Key: "originals/2024/01/good-uuid.heic", 193 + checksum: "sha256:abc", backedUpAt: "2024-01-15T12:00:00Z" 194 + ) 195 + manifest.entries["bad-uuid"] = ManifestEntry( 196 + uuid: "bad-uuid", s3Key: "originals/2024/01/bad-uuid.heic", 197 + checksum: "sha256:def", backedUpAt: "2024-01-15T12:00:00Z" 198 + ) 199 + 200 + await store.load(manifest: manifest, s3: s3) 201 + 202 + let result = await store.query() 203 + #expect(result.totalCount == 1) 204 + #expect(result.assets.first?.uuid == "good-uuid") 205 + } 206 + 207 + @Test func assetLookupByUUID() async { 208 + let store = ViewerDataStore() 209 + let assets = [makeAssetView(uuid: "target")] 210 + await store.load(assets: assets) 211 + 212 + let found = await store.asset(uuid: "target") 213 + #expect(found != nil) 214 + #expect(found?.uuid == "target") 215 + 216 + let missing = await store.asset(uuid: "nonexistent") 217 + #expect(missing == nil) 218 + } 219 + }