this repo has no description

Backup is working now

+45
AtProtoBackup.xcodeproj/project.pbxproj
··· 6 6 objectVersion = 77; 7 7 objects = { 8 8 9 + /* Begin PBXBuildFile section */ 10 + 16A25DB92E5FE9060070BFFD /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 16A25DB82E5FE9060070BFFD /* ZIPFoundation */; }; 11 + 16A25DBE2E5FED820070BFFD /* ATProtoKit in Frameworks */ = {isa = PBXBuildFile; productRef = 16A25DBD2E5FED820070BFFD /* ATProtoKit */; }; 12 + /* End PBXBuildFile section */ 13 + 9 14 /* Begin PBXContainerItemProxy section */ 10 15 16A25D8A2E5CA47B0070BFFD /* PBXContainerItemProxy */ = { 11 16 isa = PBXContainerItemProxy; ··· 65 70 isa = PBXFrameworksBuildPhase; 66 71 buildActionMask = 2147483647; 67 72 files = ( 73 + 16A25DB92E5FE9060070BFFD /* ZIPFoundation in Frameworks */, 74 + 16A25DBE2E5FED820070BFFD /* ATProtoKit in Frameworks */, 68 75 ); 69 76 runOnlyForDeploymentPostprocessing = 0; 70 77 }; ··· 125 132 ); 126 133 name = AtProtoBackup; 127 134 packageProductDependencies = ( 135 + 16A25DB82E5FE9060070BFFD /* ZIPFoundation */, 136 + 16A25DBD2E5FED820070BFFD /* ATProtoKit */, 128 137 ); 129 138 productName = AtProtoBackup; 130 139 productReference = 16A25D782E5CA4790070BFFD /* AtProtoBackup.app */; ··· 208 217 ); 209 218 mainGroup = 16A25D6F2E5CA4790070BFFD; 210 219 minimizedProjectReferenceProxies = 1; 220 + packageReferences = ( 221 + 16A25DB72E5FE9060070BFFD /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 222 + 16A25DBC2E5FED820070BFFD /* XCRemoteSwiftPackageReference "ATProtoKit" */, 223 + ); 211 224 preferredProjectObjectVersion = 77; 212 225 productRefGroup = 16A25D792E5CA4790070BFFD /* Products */; 213 226 projectDirPath = ""; ··· 612 625 defaultConfigurationName = Release; 613 626 }; 614 627 /* End XCConfigurationList section */ 628 + 629 + /* Begin XCRemoteSwiftPackageReference section */ 630 + 16A25DB72E5FE9060070BFFD /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { 631 + isa = XCRemoteSwiftPackageReference; 632 + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; 633 + requirement = { 634 + kind = upToNextMajorVersion; 635 + minimumVersion = 0.9.19; 636 + }; 637 + }; 638 + 16A25DBC2E5FED820070BFFD /* XCRemoteSwiftPackageReference "ATProtoKit" */ = { 639 + isa = XCRemoteSwiftPackageReference; 640 + repositoryURL = "https://github.com/MasterJ93/ATProtoKit"; 641 + requirement = { 642 + kind = upToNextMinorVersion; 643 + minimumVersion = 0.31.2; 644 + }; 645 + }; 646 + /* End XCRemoteSwiftPackageReference section */ 647 + 648 + /* Begin XCSwiftPackageProductDependency section */ 649 + 16A25DB82E5FE9060070BFFD /* ZIPFoundation */ = { 650 + isa = XCSwiftPackageProductDependency; 651 + package = 16A25DB72E5FE9060070BFFD /* XCRemoteSwiftPackageReference "ZIPFoundation" */; 652 + productName = ZIPFoundation; 653 + }; 654 + 16A25DBD2E5FED820070BFFD /* ATProtoKit */ = { 655 + isa = XCSwiftPackageProductDependency; 656 + package = 16A25DBC2E5FED820070BFFD /* XCRemoteSwiftPackageReference "ATProtoKit" */; 657 + productName = ATProtoKit; 658 + }; 659 + /* End XCSwiftPackageProductDependency section */ 615 660 }; 616 661 rootObject = 16A25D702E5CA4790070BFFD /* Project object */; 617 662 }
+42
AtProtoBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
··· 1 + { 2 + "originHash" : "c948a2b2436351963ceafb097737262a5b30543267795a77eac2492601d375be", 3 + "pins" : [ 4 + { 5 + "identity" : "atprotokit", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/MasterJ93/ATProtoKit", 8 + "state" : { 9 + "revision" : "5048826586a661168e0a53f6758f562a28b6b0b3", 10 + "version" : "0.31.2" 11 + } 12 + }, 13 + { 14 + "identity" : "swift-log", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/apple/swift-log.git", 17 + "state" : { 18 + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", 19 + "version" : "1.6.4" 20 + } 21 + }, 22 + { 23 + "identity" : "swift-syntax", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/swiftlang/swift-syntax.git", 26 + "state" : { 27 + "revision" : "0687f71944021d616d34d922343dcef086855920", 28 + "version" : "600.0.1" 29 + } 30 + }, 31 + { 32 + "identity" : "zipfoundation", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/weichsel/ZIPFoundation.git", 35 + "state" : { 36 + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", 37 + "version" : "0.9.19" 38 + } 39 + } 40 + ], 41 + "version" : 3 42 + }
+4 -2
AtProtoBackup/ATProtocolService.swift
··· 9 9 10 10 struct DIDResponse: Codable { 11 11 let did: String 12 + let handle: String 13 + let pds: String 12 14 } 13 15 14 16 class ATProtocolService { ··· 16 18 17 19 private init() {} 18 20 19 - func lookupDID(for handle: String) async throws -> (did: String, jsonData: Data) { 21 + func lookupDID(for handle: String) async throws -> (parsed: DIDResponse, jsonData: Data) { 20 22 print("[ATProtocolService] lookupDID called with handle: '\(handle)'") 21 23 22 24 guard let url = URL(string: "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=\(handle)") else { ··· 47 49 do { 48 50 let didResponse = try JSONDecoder().decode(DIDResponse.self, from: data) 49 51 print("[ATProtocolService] Successfully decoded - DID: \(didResponse.did)") 50 - return (did: didResponse.did, jsonData: data) 52 + return (parsed: didResponse, jsonData: data) 51 53 } catch { 52 54 print("[ATProtocolService] Decoding error: \(error)") 53 55 throw ATProtocolError.decodingError
+12 -10
AtProtoBackup/Account.swift
··· 11 11 12 12 @Model 13 13 final class Account { 14 - var did: String 15 - var handle: String 16 - var jsonResponse: Data? 17 - 18 - init(did: String, handle: String, jsonResponse: Data? = nil) { 19 - print("[Account] Creating new Account - DID: \(did), Handle: \(handle)") 20 - self.did = did 21 - self.handle = handle 22 - self.jsonResponse = jsonResponse 23 - } 14 + var did: String 15 + var handle: String 16 + var pds: String 17 + var jsonResponse: Data? 18 + 19 + init(did: String, handle: String, pds: String, jsonResponse: Data? = nil) { 20 + print("[Account] Creating new Account - DID: \(did), Handle: \(handle)") 21 + self.did = did 22 + self.handle = handle 23 + self.pds = pds 24 + self.jsonResponse = jsonResponse 25 + } 24 26 }
+63
AtProtoBackup/AccountDetailView.swift
··· 1 + // 2 + // AccountDetailView.swift 3 + // AtProtoBackup 4 + // 5 + // Created by Corey Alexander on 8/25/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct AccountDetailView: View { 11 + let account: Account 12 + @StateObject private var downloadManager = DownloadManager() 13 + 14 + var body: some View { 15 + VStack(alignment: .leading, spacing: 16) { 16 + Text("Account Details") 17 + .font(.title2) 18 + .fontWeight(.bold) 19 + 20 + AccountInfoSection(account: account) 21 + 22 + DownloadSection(account: account, downloadManager: downloadManager) 23 + 24 + JSONResponseSection(jsonData: account.jsonResponse) 25 + 26 + Spacer() 27 + } 28 + .padding() 29 + .navigationTitle(account.handle) 30 + } 31 + } 32 + 33 + struct AccountInfoSection: View { 34 + let account: Account 35 + 36 + var body: some View { 37 + VStack(alignment: .leading, spacing: 8) { 38 + HStack { 39 + Text("Handle:") 40 + .fontWeight(.semibold) 41 + Text(account.handle) 42 + } 43 + 44 + HStack { 45 + Text("DID:") 46 + .fontWeight(.semibold) 47 + Text(account.did) 48 + .lineLimit(1) 49 + .truncationMode(.middle) 50 + } 51 + } 52 + } 53 + } 54 + 55 + #Preview { 56 + AccountDetailView( 57 + account: Account( 58 + did: "did:plc:example", 59 + handle: "user.bsky.social", 60 + pds: "https://bsky.network" 61 + ) 62 + ) 63 + }
+32
AtProtoBackup/AccountListItemView.swift
··· 1 + // 2 + // AccountListItemView.swift 3 + // AtProtoBackup 4 + // 5 + // Created by Corey Alexander on 8/25/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct AccountListItemView: View { 11 + let account: Account 12 + 13 + var body: some View { 14 + VStack(alignment: .leading) { 15 + Text(account.handle) 16 + .font(.headline) 17 + Text(account.did) 18 + .font(.caption) 19 + .foregroundColor(.secondary) 20 + } 21 + } 22 + } 23 + 24 + #Preview { 25 + AccountListItemView( 26 + account: Account( 27 + did: "did:plc:example", 28 + handle: "user.bsky.social", 29 + pds: "https://bsky.network" 30 + ) 31 + ) 32 + }
+525
AtProtoBackup/BlobDownloader.swift
··· 1 + // 2 + // BlobDownloader.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/14/25. 6 + // 7 + 8 + import Foundation 9 + import UniformTypeIdentifiers 10 + import ZIPFoundation 11 + 12 + // Progress event types 13 + public enum DownloadProgress { 14 + case started(totalCIDs: Int) 15 + case progressUpdate(currentCount: Int, totalCount: Int) 16 + case completed(totalDownloaded: Int, zipURL: URL) 17 + case failed(error: Error) 18 + } 19 + 20 + public actor BlobDownloader { 21 + private var maxConcurrentDownloads: Int = 1 22 + private var activeTasks = 0 23 + private var continuation: CheckedContinuation<Void, Never>? 24 + // private var atProtocolManger: AtProtocolManager 25 + 26 + // Add these for task management 27 + private var activeDownloadTasks: Set<Task<URL?, Error>> = [] 28 + private var currentDownloadTask: Task<[URL], Error>? 29 + 30 + // Progress tracking 31 + // private var progressContinuation: AsyncStream<DownloadProgress>.Continuation? 32 + 33 + // Actor for thread-safe archive writes 34 + 35 + // init(maxConcurrentDownloads: Int = 2) { 36 + // self.maxConcurrentDownloads = maxConcurrentDownloads 37 + // } 38 + 39 + private func waitForAvailableSlot() async { 40 + while activeTasks >= maxConcurrentDownloads { 41 + await withCheckedContinuation { cont in 42 + continuation = cont 43 + } 44 + } 45 + activeTasks += 1 46 + } 47 + 48 + private func releaseSlot() { 49 + activeTasks -= 1 50 + if let cont = continuation { 51 + continuation = nil 52 + cont.resume() 53 + } 54 + } 55 + 56 + // Modified downloadBlobs method with proper cleanup 57 + func downloadBlobs( 58 + repo: String, 59 + pdsURL: String, 60 + cids: [String], 61 + saveLocationBookmark: Data? = nil, 62 + maxConcurrentDownloads: Int = 1 63 + ) async throws -> [URL] { 64 + self.maxConcurrentDownloads = maxConcurrentDownloads 65 + // Cancel any existing download task 66 + cancelAllActiveTasks() 67 + 68 + // Create new download task and store reference 69 + 70 + // guard let self = self else { throw BlobDownloadError.noData } 71 + 72 + // Check for cancellation at the start 73 + try Task.checkCancellation() 74 + 75 + do { 76 + var totalProcessed = 0 77 + var totalCIDs = 0 78 + var successfulDownloads = 0 79 + 80 + var saveLocation: URL 81 + if let override = saveLocationBookmark { 82 + var isStale = false 83 + guard 84 + let saveUrl = try? URL( 85 + resolvingBookmarkData: override, 86 + options: .withoutUI, 87 + relativeTo: nil, 88 + bookmarkDataIsStale: &isStale 89 + ) 90 + else { 91 + throw GenericIntentError.message("Failed to resolve bookmark data") 92 + } 93 + saveLocation = saveUrl 94 + } else { 95 + let tempDirectory = FileManager.default.temporaryDirectory 96 + saveLocation = tempDirectory.appendingPathComponent(repo) 97 + 98 + do { 99 + try FileManager.default.createDirectory( 100 + at: saveLocation, withIntermediateDirectories: true, attributes: nil) 101 + } catch CocoaError.fileWriteFileExists { 102 + print("Folder already exists at: \(saveLocation.path)") 103 + } catch { 104 + throw error 105 + } 106 + } 107 + 108 + // Check for cancellation before processing 109 + // try Task.checkCancellation() 110 + if Task.isCancelled { 111 + throw CancellationError() 112 + } 113 + 114 + totalCIDs += cids.count 115 + var urls = [URL]() 116 + let chunkSize = maxConcurrentDownloads 117 + 118 + for chunk in cids.chunked(into: chunkSize) { 119 + // Check for cancellation before each chunk 120 + try Task.checkCancellation() 121 + 122 + let newUrls = try await downloadBlobsConcurrently( 123 + repo: repo, 124 + pdsURL: pdsURL, 125 + cids: chunk, 126 + fileManager: FileManager.default, 127 + saveToDirectory: saveLocation 128 + ) 129 + urls.append(contentsOf: newUrls) 130 + totalProcessed += chunk.count 131 + successfulDownloads += chunk.count 132 + 133 + if Task.isCancelled { 134 + print("Download cancelled early") 135 + } 136 + print("Downloaded \(totalProcessed) of \(totalCIDs) blobs") 137 + } 138 + 139 + return urls 140 + 141 + } catch { 142 + // Clean up on any error (including cancellation) 143 + self.cancelAllActiveTasks() 144 + throw error 145 + } 146 + 147 + // Store the task reference 148 + // currentDownloadTask = downloadTask 149 + // 150 + // defer { 151 + // // Clean up task reference when done 152 + // Task { [weak self] in 153 + // await self?.clearCurrentTask() 154 + // } 155 + // } 156 + 157 + // return try await downloadTask.value 158 + } 159 + 160 + public func CancelAll() { 161 + self.cancelAllActiveTasks() 162 + } 163 + 164 + // Method to cancel all active tasks 165 + private func cancelAllActiveTasks() { 166 + print("Cancelling all active download tasks...") 167 + 168 + // Cancel all individual download tasks 169 + for task in activeDownloadTasks { 170 + task.cancel() 171 + } 172 + activeDownloadTasks.removeAll() 173 + 174 + // Cancel current download task 175 + currentDownloadTask?.cancel() 176 + currentDownloadTask = nil 177 + 178 + // Reset active task counter 179 + activeTasks = 0 180 + 181 + // Resume any waiting continuations 182 + if let cont = continuation { 183 + continuation = nil 184 + cont.resume() 185 + } 186 + } 187 + 188 + private func clearCurrentTask() { 189 + currentDownloadTask = nil 190 + } 191 + 192 + // Add task tracking to downloadBlobsConcurrently 193 + private func downloadBlobsConcurrently( 194 + repo: String, 195 + pdsURL: String, 196 + cids: [String], 197 + fileManager: FileManager, 198 + saveToDirectory: URL 199 + ) async throws -> [URL] { 200 + var successCount = 0 201 + var urls: [URL] = [] 202 + 203 + try await withThrowingTaskGroup(of: URL?.self) { group in 204 + for cid in cids { 205 + // Check for cancellation before adding each task 206 + try Task.checkCancellation() 207 + 208 + group.addTask { [weak self] in 209 + // Create a cancellable task and track it 210 + let downloadTask = Task<URL?, Error> { 211 + do { 212 + // Check for cancellation at start of each download 213 + try Task.checkCancellation() 214 + 215 + let checkForUrl = findFile( 216 + withBaseName: cid, inDirectory: saveToDirectory) 217 + if let checkForUrl { 218 + return checkForUrl 219 + } 220 + 221 + let url = try await self?.downloadBlobWithRetry( 222 + repo: repo, 223 + pdsURL: pdsURL, 224 + cid: cid, 225 + maxRetries: 3, 226 + fileManger: fileManager, 227 + saveToDirectory: saveToDirectory 228 + ) 229 + return url 230 + } catch let downloadError as BlobDownloadError { 231 + throw downloadError 232 + 233 + } catch { 234 + if error is CancellationError { 235 + print("Download cancelled for blob \(cid)") 236 + throw error 237 + } else { 238 + print("Failed to download blob \(cid): \(error)") 239 + } 240 + return nil 241 + } 242 + } 243 + 244 + // Track the task 245 + await self?.addActiveTask(downloadTask) 246 + 247 + let result = try await downloadTask.value 248 + 249 + // Remove from tracking when done 250 + await self?.removeActiveTask(downloadTask) 251 + 252 + return result 253 + } 254 + } 255 + 256 + // Wait for all tasks to complete, but check for cancellation 257 + for try await success in group { 258 + try Task.checkCancellation() 259 + if let url = success { 260 + urls.append(url) 261 + successCount += 1 262 + } 263 + } 264 + } 265 + 266 + return urls 267 + } 268 + 269 + // Helper methods for task tracking 270 + private func addActiveTask(_ task: Task<URL?, Error>) { 271 + activeDownloadTasks.insert(task) 272 + } 273 + 274 + private func removeActiveTask(_ task: Task<URL?, Error>) { 275 + activeDownloadTasks.remove(task) 276 + } 277 + 278 + // Public method to cancel downloads from outside 279 + // public func cancelDownloads() async { 280 + // cancelAllActiveTasks() 281 + // } 282 + 283 + // Alternative: Stream large blobs to temporary files 284 + 285 + public func streamBlobToDisk( 286 + resourceURL: URL, 287 + fileManager: FileManager, 288 + saveLocation: URL, 289 + fileName: String 290 + ) async throws -> URL { 291 + try Task.checkCancellation() 292 + 293 + var request = URLRequest(url: resourceURL) 294 + request.httpMethod = "GET" 295 + request.setValue("*/*", forHTTPHeaderField: "Accept") 296 + request.timeoutInterval = 30 297 + 298 + // Download to file instead of memory 299 + let (tempURL, response) = try await URLSession.shared.download(for: request) 300 + 301 + if let httpResponse = response as? HTTPURLResponse { 302 + switch httpResponse.statusCode { 303 + case (200...299): 304 + let mimeType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "" 305 + let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? "" 306 + let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)") 307 + try fileManager.moveItem( 308 + at: tempURL, to: newLocation) 309 + return newLocation 310 + case 400: 311 + let (data, _) = try await URLSession.shared.data(for: request) 312 + 313 + let errorResponse = try JSONDecoder().decode( 314 + ATHTTPResponseError.self, from: data) 315 + throw BlobDownloadError.apiError(error: errorResponse) 316 + 317 + default: 318 + try? FileManager.default.removeItem(at: tempURL) 319 + throw BlobDownloadError.httpError( 320 + statusCode: httpResponse.statusCode) 321 + } 322 + } 323 + throw BlobDownloadError.unknownError 324 + // guard let httpResponse = response as? HTTPURLResponse, 325 + // (200...299).contains(httpResponse.statusCode) 326 + // else { 327 + // try? FileManager.default.removeItem(at: tempURL) 328 + // throw BlobDownloadError.httpError( 329 + // statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0) 330 + // } 331 + 332 + } 333 + 334 + public func getBlob( 335 + from accountDID: String, 336 + cid: String, 337 + fileManager: FileManager, 338 + saveLocation: URL, 339 + pdsURL: String? = nil 340 + ) async throws -> URL { 341 + let baseUrl = pdsURL ?? "https://bsky.network" 342 + 343 + // Construct URL with query parameters 344 + guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getBlob") 345 + else { 346 + throw BlobDownloadError.invalidURL 347 + } 348 + 349 + urlComponents.queryItems = [ 350 + URLQueryItem(name: "did", value: accountDID), 351 + URLQueryItem(name: "cid", value: cid), 352 + ] 353 + 354 + guard let url = urlComponents.url else { 355 + throw BlobDownloadError.invalidURL 356 + } 357 + 358 + return try await streamBlobToDisk( 359 + resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid) 360 + } 361 + 362 + public func getCar( 363 + from accountDID: String, 364 + since: String?, 365 + fileManager: FileManager, 366 + saveLocation: URL, 367 + fileName: String = "repo.car", 368 + pdsURL: String? = nil 369 + ) async throws -> URL { 370 + 371 + let baseUrl = pdsURL ?? "https://bsky.network" 372 + // Only ever one file being downloaded 373 + self.maxConcurrentDownloads = 1 374 + // Construct URL with query parameters 375 + guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getRepo") 376 + else { 377 + throw BlobDownloadError.invalidURL 378 + } 379 + 380 + urlComponents.queryItems = [ 381 + URLQueryItem(name: "did", value: accountDID) 382 + 383 + ] 384 + 385 + if let sinceQuery = since { 386 + urlComponents.queryItems?.append(URLQueryItem(name: "since", value: sinceQuery)) 387 + } 388 + 389 + guard let url = urlComponents.url else { 390 + throw BlobDownloadError.invalidURL 391 + } 392 + 393 + return try await streamBlobToDisk( 394 + resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, 395 + fileName: fileName) 396 + } 397 + 398 + /// Downloads a single blob with retry logic 399 + public func downloadBlobWithRetry( 400 + repo: String, 401 + pdsURL: String, 402 + cid: String, 403 + maxRetries: Int, 404 + fileManger: FileManager, 405 + saveToDirectory: URL 406 + ) async throws -> URL { 407 + var lastError: Error? 408 + 409 + for attempt in 0..<maxRetries { 410 + // Check for cancellation before each retry 411 + try Task.checkCancellation() 412 + 413 + do { 414 + if attempt > 0 { 415 + // Exponential backoff 416 + let delay = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000) 417 + try await Task.sleep(nanoseconds: delay) 418 + } 419 + return try await getBlob( 420 + from: repo, cid: cid, fileManager: fileManger, saveLocation: saveToDirectory, 421 + pdsURL: pdsURL) 422 + } catch let downloadError as BlobDownloadError { 423 + switch downloadError { 424 + case .apiError(_): 425 + throw downloadError 426 + default: 427 + lastError = downloadError 428 + if attempt < maxRetries - 1 { 429 + continue 430 + } 431 + break 432 + } 433 + } catch { 434 + // If it's a cancellation error, don't retry 435 + if error is CancellationError { 436 + throw error 437 + } 438 + 439 + lastError = error 440 + if attempt < maxRetries - 1 { 441 + continue 442 + } 443 + 444 + } 445 + } 446 + 447 + throw lastError 448 + ?? GenericIntentError.message("Failed to download blob after \(maxRetries) attempts") 449 + } 450 + public struct BlobDownloadOutput { 451 + public var data: Data 452 + public var cid: String 453 + public var mimeType: String 454 + } 455 + 456 + // MARK: - Optimized Concurrent Blob Downloader 457 + 458 + public class ConcurrentBlobDownloader { 459 + private let urlSession: URLSession 460 + 461 + public init() { 462 + let config = URLSessionConfiguration.default 463 + config.httpMaximumConnectionsPerHost = 10 464 + config.timeoutIntervalForRequest = 30 465 + config.timeoutIntervalForResource = 300 466 + self.urlSession = URLSession(configuration: config) 467 + } 468 + 469 + } 470 + 471 + } 472 + 473 + public func fileExtension(fromMimeType mimeType: String) -> String? { 474 + guard let utType = UTType(mimeType: mimeType) else { return nil } 475 + return utType.preferredFilenameExtension 476 + } 477 + 478 + // MARK: - Helper Extensions 479 + 480 + extension Array { 481 + /// Splits array into chunks of specified size 482 + func chunked(into size: Int) -> [[Element]] { 483 + return stride(from: 0, to: count, by: size).map { 484 + Array(self[$0..<Swift.min($0 + size, count)]) 485 + } 486 + } 487 + } 488 + 489 + /// An error type related to issues surrounding HTTP responses. 490 + public struct ATHTTPResponseError: Decodable { 491 + 492 + /// The name of the error. 493 + public let error: String 494 + 495 + /// The message for the error. 496 + public let message: String 497 + } 498 + 499 + func findFile(withBaseName baseName: String, inDirectory directory: URL) -> URL? { 500 + let fileManager = FileManager.default 501 + 502 + do { 503 + let contents = try fileManager.contentsOfDirectory( 504 + at: directory, includingPropertiesForKeys: nil) 505 + 506 + // Find the first file that matches the base name 507 + return contents.first { fileURL in 508 + let fileNameWithoutExtension = fileURL.deletingPathExtension().lastPathComponent 509 + return fileNameWithoutExtension == baseName 510 + } 511 + } catch { 512 + print("Error reading directory: \(error)") 513 + return nil 514 + } 515 + } 516 + 517 + public enum BlobDownloadError: Error { 518 + case invalidURL 519 + case networkError(Error) 520 + case noData 521 + case httpError(statusCode: Int) 522 + case unknownError 523 + case apiError(error: ATHTTPResponseError) 524 + 525 + }
+9 -68
AtProtoBackup/ContentView.swift
··· 21 21 List { 22 22 ForEach(items) { item in 23 23 NavigationLink { 24 - VStack(alignment: .leading, spacing: 16) { 25 - Text("Account Details") 26 - .font(.title2) 27 - .fontWeight(.bold) 28 - 29 - VStack(alignment: .leading, spacing: 8) { 30 - HStack { 31 - Text("Handle:") 32 - .fontWeight(.semibold) 33 - Text(item.handle) 34 - } 35 - 36 - HStack { 37 - Text("DID:") 38 - .fontWeight(.semibold) 39 - Text(item.did) 40 - .lineLimit(1) 41 - .truncationMode(.middle) 42 - } 43 - } 44 - 45 - if let jsonData = item.jsonResponse { 46 - VStack(alignment: .leading, spacing: 8) { 47 - Text("JSON Response:") 48 - .font(.headline) 49 - 50 - ScrollView { 51 - if let jsonObject = try? JSONSerialization.jsonObject(with: jsonData), 52 - let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), 53 - let prettyString = String(data: prettyData, encoding: .utf8) { 54 - Text(prettyString) 55 - .font(.system(.body, design: .monospaced)) 56 - .textSelection(.enabled) 57 - .padding() 58 - .background(Color.gray.opacity(0.1)) 59 - .cornerRadius(8) 60 - } else { 61 - Text("Unable to decode JSON data") 62 - .foregroundColor(.secondary) 63 - } 64 - } 65 - } 66 - } else { 67 - Text("No JSON response available") 68 - .foregroundColor(.secondary) 69 - } 70 - 71 - Spacer() 72 - } 73 - .padding() 74 - .navigationTitle(item.handle) 24 + AccountDetailView(account: item) 75 25 } label: { 76 - VStack(alignment: .leading) { 77 - Text(item.handle) 78 - .font(.headline) 79 - Text(item.did) 80 - .font(.caption) 81 - .foregroundColor(.secondary) 82 - } 26 + AccountListItemView(account: item) 83 27 } 84 28 } 85 29 .onDelete(perform: deleteItems) ··· 99 43 } 100 44 } 101 45 } 102 - 103 - 104 - 105 46 } detail: { 106 47 Text("Select an item") 107 - }.alert("Add AT Protocol Account", isPresented: $showingAlert, actions: { 48 + } 49 + .alert("Add AT Protocol Account", isPresented: $showingAlert) { 108 50 TextField("Enter handle (e.g., user.bsky.social)", text: $inputText) 109 51 Button("Add") { 110 52 print("[ContentView] Add button in alert pressed") ··· 116 58 inputText = "" 117 59 errorMessage = nil 118 60 } 119 - }, message: { 61 + } message: { 120 62 if let errorMessage = errorMessage { 121 63 Text(errorMessage) 122 64 } else if isLoading { ··· 124 66 } else { 125 67 Text("Enter your AT Protocol handle") 126 68 } 127 - }) 128 - 69 + } 129 70 } 130 71 131 72 ··· 143 84 144 85 do { 145 86 print("[ContentView] Starting API lookup...") 146 - let (did, jsonData) = try await ATProtocolService.shared.lookupDID(for: inputText) 147 - print("[ContentView] API lookup successful - DID: \(did)") 87 + let (parsed, jsonData) = try await ATProtocolService.shared.lookupDID(for: inputText) 88 + print("[ContentView] API lookup successful - DID: \(parsed.did)") 148 89 149 90 await MainActor.run { 150 91 print("[ContentView] Creating and inserting Account object") 151 92 withAnimation { 152 - let account = Account(did: did, handle: inputText, jsonResponse: jsonData) 93 + let account = Account(did: parsed.did, handle: inputText, pds: parsed.pds, jsonResponse: jsonData) 153 94 modelContext.insert(account) 154 95 print("[ContentView] Account inserted into modelContext") 155 96 }
+168
AtProtoBackup/DownloadManager.swift
··· 1 + // 2 + // DownloadManager.swift 3 + // AtProtoBackup 4 + // 5 + // Created by Corey Alexander on 8/25/25. 6 + // 7 + 8 + import SwiftUI 9 + import Combine 10 + import ATProtoKit 11 + 12 + struct DownloadInfo: Identifiable { 13 + let id = UUID() 14 + let accountID: String 15 + var progress: Double = 0 16 + var totalBlobs: Int? 17 + var isDownloading: Bool = false 18 + var blobDownloader: Bool = false 19 + } 20 + 21 + class DownloadManager: ObservableObject { 22 + @Published private var downloads: [String: DownloadInfo] = [:] 23 + private let blobDownloader = BlobDownloader() 24 + 25 + func getDownload(for account: Account) -> DownloadInfo? { 26 + downloads[account.did] 27 + } 28 + 29 + func startDownload(for account: Account) { 30 + let accountDid = account.did 31 + if downloads[account.did] == nil { 32 + downloads[account.did] = DownloadInfo(accountID: account.did) 33 + } 34 + 35 + 36 + Task { 37 + await MainActor.run { 38 + downloads[accountDid]?.isDownloading = true 39 + } 40 + 41 + do { 42 + let tempDirectory = FileManager.default.temporaryDirectory 43 + let saveLocation = tempDirectory.appendingPathComponent(account.did) 44 + 45 + print("Saving to \(saveLocation.path)") 46 + 47 + let date = Date() 48 + let formatter = ISO8601DateFormatter() 49 + let utcString = formatter.string(from: date) 50 + let fileName = "\(account.handle)-\(utcString).car" 51 + 52 + do { 53 + try FileManager.default.createDirectory( 54 + at: saveLocation, withIntermediateDirectories: true, attributes: nil) 55 + } catch CocoaError.fileWriteFileExists { 56 + print("Folder already exists at: \(saveLocation.path)") 57 + } catch { 58 + throw error 59 + } 60 + 61 + let result = try await blobDownloader.getCar(from: account.did, since: nil, fileManager: FileManager.default, saveLocation: saveLocation, fileName: fileName, pdsURL: account.pds) 62 + 63 + print("All Done! \(result)") 64 + 65 + // let config = ATProtocolConfiguration(pdsURL: account.pds) 66 + 67 + // let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: account.pds) 68 + 69 + var allBlobCids: [String] = [] 70 + var cursor: String? = nil 71 + 72 + repeat { 73 + let blobsResponse = try await listBlobs(from: account.did, pds: account.pds, sinceRevision: nil, cursor: cursor) 74 + allBlobCids.append(contentsOf: blobsResponse.accountCIDs) 75 + cursor = blobsResponse.cursor 76 + print("Listed \(blobsResponse.accountCIDs.count) blob cids, total: \(allBlobCids.count)") 77 + } while cursor != nil 78 + 79 + print("List all \(allBlobCids.count) blob cids") 80 + let totalCount: Int = allBlobCids.count 81 + await MainActor.run { 82 + downloads[accountDid]?.totalBlobs = totalCount 83 + } 84 + 85 + // guard let saveUrl = saveLocation.fileURL else { 86 + // throw GenericIntentError.message( 87 + // "Was not able to get a valid url for the save location") 88 + // } 89 + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() 90 + defer { 91 + if didStartAccessing { 92 + saveLocation.stopAccessingSecurityScopedResource() 93 + } 94 + } 95 + var bookmarkData: Data? 96 + if didStartAccessing { 97 + do { 98 + bookmarkData = try saveLocation.bookmarkData( 99 + options: .minimalBookmark, 100 + includingResourceValuesForKeys: nil, 101 + relativeTo: nil 102 + ) 103 + } catch { 104 + print("Failed to create bookmark: \(error)") 105 + } 106 + } 107 + 108 + let _ = try await blobDownloader.downloadBlobs(repo: account.did, pdsURL: account.pds, cids: allBlobCids, saveLocationBookmark: bookmarkData) 109 + 110 + } catch { 111 + print("Download error: \(error)") 112 + await MainActor.run { 113 + downloads[accountDid]?.isDownloading = false 114 + } 115 + } 116 + } 117 + 118 + func listBlobs( 119 + from repositoryDID: String, 120 + pds: String, 121 + sinceRevision: String?, 122 + limit: Int? = 500, 123 + cursor: String? = nil 124 + ) async throws -> ComAtprotoLexicon.Sync.ListBlobsOutput { 125 + guard let requestURL = URL(string: pds + "/xrpc/com.atproto.sync.listBlobs") else { 126 + throw ATProtocolError.invalidURL 127 + } 128 + 129 + var queryItems = [(String, String)]() 130 + 131 + queryItems.append(("did", repositoryDID)) 132 + 133 + if let sinceRevision { 134 + queryItems.append(("since", sinceRevision)) 135 + } 136 + 137 + if let limit { 138 + let finalLimit = max(1, min(limit, 1_000)) 139 + queryItems.append(("limit", "\(finalLimit)")) 140 + } 141 + 142 + if let cursor { 143 + queryItems.append(("cursor", cursor)) 144 + } 145 + 146 + var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: true)! 147 + components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) } 148 + 149 + guard let queryURL = components.url else { 150 + throw ATProtocolError.invalidURL 151 + } 152 + 153 + var request = URLRequest(url: queryURL) 154 + request.httpMethod = "GET" 155 + request.setValue("application/vnd.ipld.car", forHTTPHeaderField: "Accept") 156 + 157 + let (data, response) = try await URLSession.shared.data(for: request) 158 + 159 + guard let httpResponse = response as? HTTPURLResponse, 160 + (200...299).contains(httpResponse.statusCode) else { 161 + throw ATProtocolError.invalidResponse 162 + } 163 + 164 + let decoder = JSONDecoder() 165 + return try decoder.decode(ComAtprotoLexicon.Sync.ListBlobsOutput.self, from: data) 166 + } 167 + } 168 + }
+73
AtProtoBackup/DownloadSection.swift
··· 1 + // 2 + // DownloadSection.swift 3 + // AtProtoBackup 4 + // 5 + // Created by Corey Alexander on 8/25/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct DownloadSection: View { 11 + let account: Account 12 + @ObservedObject var downloadManager: DownloadManager 13 + 14 + var body: some View { 15 + VStack(spacing: 12) { 16 + Button(action: { 17 + downloadManager.startDownload(for: account) 18 + }) { 19 + HStack { 20 + Image(systemName: "arrow.down.circle.fill") 21 + Text("Download Backup") 22 + } 23 + .padding(.horizontal, 16) 24 + .padding(.vertical, 8) 25 + .background(Color.blue) 26 + .foregroundColor(.white) 27 + .cornerRadius(8) 28 + } 29 + .buttonStyle(PlainButtonStyle()) 30 + 31 + if let download = downloadManager.getDownload(for: account) { 32 + DownloadStatusView(download: download, account: account, downloadManager: downloadManager) 33 + } 34 + } 35 + } 36 + } 37 + 38 + struct DownloadStatusView: View { 39 + let download: DownloadInfo 40 + let account: Account 41 + @ObservedObject var downloadManager: DownloadManager 42 + 43 + var body: some View { 44 + VStack(spacing: 8) { 45 + if download.isDownloading { 46 + ProgressView(value: download.progress) { 47 + Text("Downloading... \(Int(download.progress * 100))%") 48 + .font(.caption) 49 + } 50 + .progressViewStyle(LinearProgressViewStyle()) 51 + } else if download.progress > 0 && download.progress < 1.0 { 52 + HStack { 53 + Text("Download paused at \(Int(download.progress * 100))%") 54 + .font(.caption) 55 + .foregroundColor(.secondary) 56 + 57 + Button("Resume") { 58 + downloadManager.startDownload(for: account) 59 + } 60 + .buttonStyle(BorderedButtonStyle()) 61 + } 62 + } else if download.progress >= 1.0 { 63 + HStack { 64 + Image(systemName: "checkmark.circle.fill") 65 + .foregroundColor(.green) 66 + Text("Download complete") 67 + .font(.caption) 68 + .foregroundColor(.secondary) 69 + } 70 + } 71 + } 72 + } 73 + }
+21
AtProtoBackup/ErrorHandling.swift
··· 1 + // 2 + // ErrorHandling.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + import Foundation 8 + 9 + enum GenericIntentError: Error, CustomLocalizedStringResourceConvertible, LocalizedError { 10 + case general 11 + case message(_ message: String) 12 + case notFound(_ lostItem: String) 13 + 14 + var localizedStringResource: LocalizedStringResource { 15 + switch self { 16 + case let .message(message): return "\(message)" 17 + case .general: return "There was an error making the post." 18 + case let .notFound(lostItem): return "\(lostItem) could not be found" 19 + } 20 + } 21 + }
+40
AtProtoBackup/JSONResponseSection.swift
··· 1 + // 2 + // JSONResponseSection.swift 3 + // AtProtoBackup 4 + // 5 + // Created by Corey Alexander on 8/25/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct JSONResponseSection: View { 11 + let jsonData: Data? 12 + 13 + var body: some View { 14 + if let jsonData = jsonData { 15 + VStack(alignment: .leading, spacing: 8) { 16 + Text("JSON Response:") 17 + .font(.headline) 18 + 19 + ScrollView { 20 + if let jsonObject = try? JSONSerialization.jsonObject(with: jsonData), 21 + let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), 22 + let prettyString = String(data: prettyData, encoding: .utf8) { 23 + Text(prettyString) 24 + .font(.system(.body, design: .monospaced)) 25 + .textSelection(.enabled) 26 + .padding() 27 + .background(Color.gray.opacity(0.1)) 28 + .cornerRadius(8) 29 + } else { 30 + Text("Unable to decode JSON data") 31 + .foregroundColor(.secondary) 32 + } 33 + } 34 + } 35 + } else { 36 + Text("No JSON response available") 37 + .foregroundColor(.secondary) 38 + } 39 + } 40 + }