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.

Add animated preparation spinner for backup command

Show a Braille-pattern spinner with status messages during the
preparation phase (config load, manifest download, library scan)
so the CLI no longer appears hung before uploads begin.

Bump version to 1.0.0-alpha.2.

+97 -6
+9
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 1.0.0-alpha.2 4 + 5 + Animated preparation spinner for the backup command. 6 + 7 + - **Preparation spinner** — shows an animated spinner with status messages 8 + ("Loading manifest from S3...", "Scanning Photos library...") during the 9 + preparation phase before uploads begin, so the CLI no longer appears hung 10 + - **Unused variable fix** — removed unused `config` binding in backup command 11 + 3 12 ## 0.2.6 4 13 5 14 Hardened error detection and timeout handling.
+14 -5
Sources/AtticCLI/BackupCommand.swift
··· 22 22 var dryRun: Bool = false 23 23 24 24 func run() async throws { 25 - let (config, s3, manifestStore) = try Dependencies.makeBackupDeps() 25 + let isTTY = isatty(STDOUT_FILENO) != 0 26 + let spinner = isTTY ? PreparationSpinner() : nil 27 + spinner?.start() 28 + 29 + spinner?.updateStatus("Loading configuration...") 30 + let (_, s3, manifestStore) = try Dependencies.makeBackupDeps() 31 + 32 + spinner?.updateStatus("Loading manifest from S3...") 26 33 var manifest = try await Dependencies.loadManifest(store: manifestStore) 34 + 35 + spinner?.updateStatus("Scanning Photos library...") 27 36 let assets = Dependencies.loadAssets() 28 37 29 38 let assetKind: AssetKind? = switch type?.lowercased() { ··· 42 51 // Pre-flight permission check 43 52 try await exporter.checkPermissions() 44 53 45 - let isTTY = isatty(STDOUT_FILENO) != 0 46 - let progress: any BackupProgressDelegate = isTTY 47 - ? TerminalRenderer() 48 - : LogProgressDelegate() 54 + spinner?.updateStatus("Comparing assets...") 55 + 56 + let renderer = isTTY ? TerminalRenderer(spinner: spinner) : nil 57 + let progress: any BackupProgressDelegate = renderer ?? LogProgressDelegate() 49 58 50 59 let options = BackupOptions( 51 60 batchSize: batchSize,
+67
Sources/AtticCLI/PreparationSpinner.swift
··· 1 + import Foundation 2 + 3 + /// Animated spinner shown during the preparation phase before backup uploads begin. 4 + /// 5 + /// Displays a single line with a Braille-pattern spinner and a status message. 6 + final class PreparationSpinner: @unchecked Sendable { 7 + private static let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 8 + 9 + private let lock = NSLock() 10 + private var statusMessage = "Preparing..." 11 + private var animationTask: Task<Void, Never>? 12 + private var hasPrinted = false 13 + 14 + func updateStatus(_ message: String) { 15 + lock.withLock { 16 + statusMessage = message 17 + } 18 + } 19 + 20 + func start() { 21 + lock.withLock { 22 + hasPrinted = false 23 + } 24 + animationTask = Task { 25 + var frameIndex = 0 26 + 27 + while !Task.isCancelled { 28 + let message: String = self.lock.withLock { 29 + self.statusMessage 30 + } 31 + 32 + let frame = Self.frames[frameIndex % Self.frames.count] 33 + frameIndex += 1 34 + 35 + let shouldMoveCursor: Bool = self.lock.withLock { 36 + if self.hasPrinted { 37 + return true 38 + } 39 + self.hasPrinted = true 40 + return false 41 + } 42 + 43 + if shouldMoveCursor { 44 + print("\u{1b}[1A", terminator: "") 45 + } 46 + print("\u{1b}[2K \(frame) \(message)") 47 + fflush(stdout) 48 + 49 + try? await Task.sleep(nanoseconds: 100_000_000) 50 + } 51 + } 52 + } 53 + 54 + func stop() { 55 + let printed: Bool = lock.withLock { 56 + animationTask?.cancel() 57 + animationTask = nil 58 + return hasPrinted 59 + } 60 + if printed { 61 + // Brief sleep to let the cancelled task finish its current iteration 62 + Thread.sleep(forTimeInterval: 0.15) 63 + print("\u{1b}[1A\u{1b}[2K", terminator: "") 64 + fflush(stdout) 65 + } 66 + } 67 + }
+6
Sources/AtticCLI/TerminalRenderer.swift
··· 11 11 private var state = RenderState() 12 12 private var startTime: Date? 13 13 private var lastRenderTime: Date? 14 + private let spinner: PreparationSpinner? 15 + 16 + init(spinner: PreparationSpinner? = nil) { 17 + self.spinner = spinner 18 + } 14 19 15 20 private struct RenderState { 16 21 var total: Int = 0 ··· 30 35 // MARK: - BackupProgressDelegate 31 36 32 37 func backupStarted(pending: Int, photos: Int, videos: Int) { 38 + spinner?.stop() 33 39 lock.withLock { 34 40 state.total = pending 35 41 state.photos = photos
+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-alpha.1" 5 + public static let version = "1.0.0-alpha.2" 6 6 }