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.

chore: swiftformat fixes for CI

+48 -21
+2 -2
Sources/AtticCLI/LadderKitExportProvider.swift
··· 36 36 try await exporter.checkPermissions() 37 37 } catch AppleScriptError.automationPermissionDenied { 38 38 throw ExportProviderError.permissionDenied( 39 - AppleScriptError.automationPermissionDenied.localizedDescription 39 + AppleScriptError.automationPermissionDenied.localizedDescription, 40 40 ) 41 41 } catch let err as AppleScriptError { 42 - if case .timeout(_, let seconds) = err { 42 + if case let .timeout(_, seconds) = err { 43 43 throw ExportProviderError.timeout(seconds: Int(seconds)) 44 44 } 45 45 throw err
+6 -2
Sources/AtticCLI/StatusRenderer.swift
··· 47 47 if let retry = data.retry { 48 48 print("") 49 49 print("\(bold)Retries\(reset)") 50 - print(" Queued \(padLeft(format(retry.count), width: 8)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))") 50 + print( 51 + " Queued \(padLeft(format(retry.count), width: 8)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))", 52 + ) 51 53 if let oldest = retry.oldestFirstFailedAt { 52 54 let day = String(oldest.prefix(10)) 53 55 print(" Since \(day)") ··· 105 107 if let retry = data.retry { 106 108 print("") 107 109 print("Retries") 108 - print(" Queued: \(format(retry.count)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))") 110 + print( 111 + " Queued: \(format(retry.count)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))", 112 + ) 109 113 if let oldest = retry.oldestFirstFailedAt { 110 114 print(" Since: \(String(oldest.prefix(10)))") 111 115 }
+1 -1
Sources/AtticCLI/ViewerServer.swift
··· 49 49 self.s3 = s3 50 50 self.thumbnailProvider = thumbnailProvider 51 51 self.port = port 52 - self.csp = Self.buildCSP(endpointHost: endpointHost) 52 + csp = Self.buildCSP(endpointHost: endpointHost) 53 53 } 54 54 55 55 private static func buildCSP(endpointHost: String) -> String {
+5 -3
Sources/AtticCore/AIMDController.swift
··· 41 41 42 42 public init(config: Config = Config()) { 43 43 self.config = config 44 - self.limit = max(config.minLimit, min(config.maxLimit, config.initialLimit)) 44 + limit = max(config.minLimit, min(config.maxLimit, config.initialLimit)) 45 45 } 46 46 47 - public func currentLimit() -> Int { limit } 47 + public func currentLimit() -> Int { 48 + limit 49 + } 48 50 49 51 public func record(_ outcome: ExportOutcome) { 50 52 switch outcome { ··· 64 66 } 65 67 guard window.count >= Self.windowSize else { return } 66 68 67 - let failures = window.lazy.filter { $0 }.count 69 + let failures = window.lazy.count(where: { $0 }) 68 70 let rate = Double(failures) / Double(window.count) 69 71 70 72 if rate > Self.backoffThreshold {
+34 -13
Tests/AtticCoreTests/AdaptiveConcurrencyTests.swift
··· 1 + @testable import AtticCore 1 2 import Foundation 2 3 import LadderKit 3 4 import Testing 4 - 5 - @testable import AtticCore 6 5 7 6 @Suite("AIMDController") 8 7 struct AIMDControllerTests { ··· 25 24 let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 26 25 // 10 failures then 10 successes: once the window fills (20 items), 27 26 // the rate is 0.5 → backoff. 28 - for _ in 0 ..< 10 { await c.record(.transientFailure) } 29 - for _ in 0 ..< 10 { await c.record(.success) } 27 + for _ in 0 ..< 10 { 28 + await c.record(.transientFailure) 29 + } 30 + for _ in 0 ..< 10 { 31 + await c.record(.success) 32 + } 30 33 #expect(await c.currentLimit() == 4) 31 34 } 32 35 33 36 @Test("Permanent failures are ignored") 34 37 func permanentIgnored() async { 35 38 let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 36 - for _ in 0 ..< 40 { await c.record(.permanentFailure) } 39 + for _ in 0 ..< 40 { 40 + await c.record(.permanentFailure) 41 + } 37 42 #expect(await c.currentLimit() == 8) 38 43 } 39 44 ··· 41 46 func recover() async { 42 47 let c = AIMDController(config: .init(initialLimit: 4, minLimit: 1, maxLimit: 12)) 43 48 // 20 successes fills the window with rate 0 → +1. 44 - for _ in 0 ..< 20 { await c.record(.success) } 49 + for _ in 0 ..< 20 { 50 + await c.record(.success) 51 + } 45 52 #expect(await c.currentLimit() == 5) 46 53 } 47 54 ··· 53 60 let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 54 61 // First fill the window with 14 successes + 6 failures (rate 0.30 — 55 62 // not > threshold, no change). 56 - for _ in 0 ..< 14 { await c.record(.success) } 57 - for _ in 0 ..< 6 { await c.record(.transientFailure) } 63 + for _ in 0 ..< 14 { 64 + await c.record(.success) 65 + } 66 + for _ in 0 ..< 6 { 67 + await c.record(.transientFailure) 68 + } 58 69 #expect(await c.currentLimit() == 8) 59 70 // Now slide in 6 more failures; oldest successes drop, rate climbs 60 71 // above 0.30 and triggers backoff. 61 - for _ in 0 ..< 6 { await c.record(.transientFailure) } 72 + for _ in 0 ..< 6 { 73 + await c.record(.transientFailure) 74 + } 62 75 #expect(await c.currentLimit() == 4) 63 76 } 64 77 ··· 66 79 func windowClearsOnChange() async { 67 80 let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 68 81 // Force backoff. 69 - for _ in 0 ..< 10 { await c.record(.transientFailure) } 70 - for _ in 0 ..< 10 { await c.record(.success) } 82 + for _ in 0 ..< 10 { 83 + await c.record(.transientFailure) 84 + } 85 + for _ in 0 ..< 10 { 86 + await c.record(.success) 87 + } 71 88 #expect(await c.currentLimit() == 4) 72 89 // Immediately feeding another failure must not re-backoff — the window 73 90 // is empty post-change. It takes another full window to re-trigger. ··· 78 95 @Test("Respects minLimit floor") 79 96 func minLimitFloor() async { 80 97 let c = AIMDController(config: .init(initialLimit: 2, minLimit: 2, maxLimit: 12)) 81 - for _ in 0 ..< 40 { await c.record(.transientFailure) } 98 + for _ in 0 ..< 40 { 99 + await c.record(.transientFailure) 100 + } 82 101 #expect(await c.currentLimit() == 2) 83 102 } 84 103 85 104 @Test("Respects maxLimit ceiling") 86 105 func maxLimitCeiling() async { 87 106 let c = AIMDController(config: .init(initialLimit: 3, minLimit: 1, maxLimit: 3)) 88 - for _ in 0 ..< 40 { await c.record(.success) } 107 + for _ in 0 ..< 40 { 108 + await c.record(.success) 109 + } 89 110 #expect(await c.currentLimit() == 3) 90 111 } 91 112 }