+45
AtProtoBackup.xcodeproj/project.pbxproj
+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
}
+4
-2
AtProtoBackup/ATProtocolService.swift
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}