+2
AtProtoBackup.xcodeproj/project.pbxproj
+2
AtProtoBackup.xcodeproj/project.pbxproj
···
419
ENABLE_PREVIEWS = YES;
420
GENERATE_INFOPLIST_FILE = YES;
421
INFOPLIST_FILE = AtProtoBackup/Info.plist;
422
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
423
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
424
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
···
461
ENABLE_PREVIEWS = YES;
462
GENERATE_INFOPLIST_FILE = YES;
463
INFOPLIST_FILE = AtProtoBackup/Info.plist;
464
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
465
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
466
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
···
419
ENABLE_PREVIEWS = YES;
420
GENERATE_INFOPLIST_FILE = YES;
421
INFOPLIST_FILE = AtProtoBackup/Info.plist;
422
+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
423
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
424
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
425
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
···
462
ENABLE_PREVIEWS = YES;
463
GENERATE_INFOPLIST_FILE = YES;
464
INFOPLIST_FILE = AtProtoBackup/Info.plist;
465
+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
466
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
467
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
468
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+2
-2
AtProtoBackup.xcodeproj/xcuserdata/coreyja.xcuserdatad/xcschemes/xcschememanagement.plist
+2
-2
AtProtoBackup.xcodeproj/xcuserdata/coreyja.xcuserdatad/xcschemes/xcschememanagement.plist
+44
AtProtoBackup/AppDelegate.swift
+44
AtProtoBackup/AppDelegate.swift
···
···
1
+
//
2
+
// AppDelegate.swift
3
+
// AtProtoBackup
4
+
//
5
+
// Created by Corey Alexander on 8/29/25.
6
+
//
7
+
#if os(iOS)
8
+
import UIKit
9
+
10
+
11
+
class AppDelegate: NSObject, UIApplicationDelegate {
12
+
func application(_ application: UIApplication,
13
+
handleEventsForBackgroundURLSession identifier: String,
14
+
completionHandler: @escaping () -> Void) {
15
+
print("[AppDelegate] Handling background URLSession events for identifier: \(identifier)")
16
+
17
+
// Note: We can't update Live Activities here because we don't have
18
+
// access to the download progress from the BlobDownloader.
19
+
// Live Activities will show stale data until the app returns to foreground.
20
+
21
+
// Let BlobDownloader handle the completion
22
+
BlobDownloader.handleEventsForBackgroundURLSession(
23
+
identifier: identifier,
24
+
completionHandler: completionHandler
25
+
)
26
+
}
27
+
28
+
func applicationDidEnterBackground(_ application: UIApplication) {
29
+
// Request background processing time
30
+
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
31
+
backgroundTask = application.beginBackgroundTask {
32
+
application.endBackgroundTask(backgroundTask)
33
+
}
34
+
35
+
// You get ~30 seconds to update Live Activities
36
+
Task {
37
+
// Note: We could update Live Activities here but we don't have
38
+
// access to current download progress. The DownloadManager would
39
+
// need to expose this data for background updates to work.
40
+
application.endBackgroundTask(backgroundTask)
41
+
}
42
+
}
43
+
}
44
+
#endif
+8
AtProtoBackup/AtProtoBackupApp.swift
+8
AtProtoBackup/AtProtoBackupApp.swift
···
7
8
import SwiftUI
9
import SwiftData
10
import ActivityKit
11
12
@main
13
struct AtProtoBackupApp: App {
14
init() {
15
// Initialize Live Activity permissions check
16
Task {
17
await LiveActivityManager.shared.checkActivityPermissions()
18
}
19
}
20
21
var sharedModelContainer: ModelContainer = {
···
7
8
import SwiftUI
9
import SwiftData
10
+
#if os(iOS)
11
import ActivityKit
12
+
#endif
13
14
@main
15
struct AtProtoBackupApp: App {
16
+
#if os(iOS)
17
+
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
18
+
#endif
19
+
20
init() {
21
+
#if os(iOS)
22
// Initialize Live Activity permissions check
23
Task {
24
await LiveActivityManager.shared.checkActivityPermissions()
25
}
26
+
#endif
27
}
28
29
var sharedModelContainer: ModelContainer = {
+115
AtProtoBackup/BackgroundDownloadTracker.swift
+115
AtProtoBackup/BackgroundDownloadTracker.swift
···
···
1
+
//
2
+
// BackgroundDownloadTracker.swift
3
+
// AtProtoBackup
4
+
//
5
+
// Created by Corey Alexander on 8/29/25.
6
+
//
7
+
8
+
import Foundation
9
+
#if os(iOS)
10
+
import ActivityKit
11
+
#endif
12
+
13
+
@MainActor
14
+
class BackgroundDownloadTracker {
15
+
static let shared = BackgroundDownloadTracker()
16
+
17
+
private var downloadProgress: [String: DownloadProgress] = [:]
18
+
#if os(iOS)
19
+
private var liveActivities: [String: Activity<DownloadActivityAttributes>] = [:]
20
+
private var lastUpdateCounts: [String: Int] = [:]
21
+
private let updateBatchSize = 10
22
+
#endif
23
+
24
+
private struct DownloadProgress {
25
+
var downloadedBlobs: Int
26
+
var totalBlobs: Int
27
+
var lastUpdated: Date
28
+
}
29
+
30
+
private init() {}
31
+
32
+
func registerDownload(accountDid: String, totalBlobs: Int) {
33
+
downloadProgress[accountDid] = DownloadProgress(
34
+
downloadedBlobs: 0,
35
+
totalBlobs: totalBlobs,
36
+
lastUpdated: Date()
37
+
)
38
+
#if os(iOS)
39
+
lastUpdateCounts[accountDid] = 0
40
+
#endif
41
+
}
42
+
43
+
#if os(iOS)
44
+
func registerLiveActivity(_ activity: Activity<DownloadActivityAttributes>, for accountDid: String) {
45
+
liveActivities[accountDid] = activity
46
+
}
47
+
#endif
48
+
49
+
func incrementProgress(for accountDid: String) async {
50
+
guard var progress = downloadProgress[accountDid] else { return }
51
+
52
+
progress.downloadedBlobs += 1
53
+
progress.lastUpdated = Date()
54
+
downloadProgress[accountDid] = progress
55
+
56
+
#if os(iOS)
57
+
// Check if we should update the Live Activity (every 10 downloads)
58
+
let currentCount = lastUpdateCounts[accountDid] ?? 0
59
+
let newCount = currentCount + 1
60
+
lastUpdateCounts[accountDid] = newCount
61
+
62
+
if newCount >= updateBatchSize || progress.downloadedBlobs == progress.totalBlobs {
63
+
// Reset counter and update Live Activity
64
+
lastUpdateCounts[accountDid] = 0
65
+
await updateLiveActivityIfNeeded(accountDid: accountDid, progress: progress)
66
+
}
67
+
#endif
68
+
}
69
+
70
+
#if os(iOS)
71
+
private func updateLiveActivityIfNeeded(accountDid: String, progress: DownloadProgress) async {
72
+
guard let activity = liveActivities[accountDid] else { return }
73
+
74
+
let progressPercent = Double(progress.downloadedBlobs) / Double(progress.totalBlobs)
75
+
let status: DownloadActivityAttributes.ContentState.DownloadStatus =
76
+
progress.downloadedBlobs == progress.totalBlobs ? .completed : .downloading
77
+
78
+
let updatedState = DownloadActivityAttributes.ContentState(
79
+
progress: progressPercent,
80
+
downloadedBlobs: progress.downloadedBlobs,
81
+
totalBlobs: progress.totalBlobs,
82
+
accountHandle: activity.attributes.accountHandle,
83
+
isPaused: false,
84
+
status: status
85
+
)
86
+
87
+
await activity.update(using: updatedState)
88
+
print("[BackgroundTracker] Updated activity for \(accountDid) - Blobs: \(progress.downloadedBlobs)/\(progress.totalBlobs)")
89
+
90
+
// If completed, end the activity after a delay
91
+
if status == .completed {
92
+
Task {
93
+
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
94
+
await activity.end(dismissalPolicy: .immediate)
95
+
liveActivities.removeValue(forKey: accountDid)
96
+
downloadProgress.removeValue(forKey: accountDid)
97
+
lastUpdateCounts.removeValue(forKey: accountDid)
98
+
}
99
+
}
100
+
}
101
+
#endif
102
+
103
+
func getProgress(for accountDid: String) -> (downloaded: Int, total: Int)? {
104
+
guard let progress = downloadProgress[accountDid] else { return nil }
105
+
return (progress.downloadedBlobs, progress.totalBlobs)
106
+
}
107
+
108
+
func cleanup(for accountDid: String) {
109
+
downloadProgress.removeValue(forKey: accountDid)
110
+
#if os(iOS)
111
+
liveActivities.removeValue(forKey: accountDid)
112
+
lastUpdateCounts.removeValue(forKey: accountDid)
113
+
#endif
114
+
}
115
+
}
+304
-23
AtProtoBackup/BlobDownloader.swift
+304
-23
AtProtoBackup/BlobDownloader.swift
···
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
···
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
···
53
}
54
}
55
56
// Modified downloadBlobs method with proper cleanup
57
func downloadBlobs(
58
repo: String,
59
pdsURL: String,
60
cids: [String],
···
288
resourceURL: URL,
289
fileManager: FileManager,
290
saveLocation: URL,
291
-
fileName: String
292
) async throws -> URL {
293
try Task.checkCancellation()
294
···
297
request.setValue("*/*", forHTTPHeaderField: "Accept")
298
request.timeoutInterval = 30
299
300
-
// Download to file instead of memory
301
-
let (tempURL, response) = try await URLSession.shared.download(for: request)
302
303
if let httpResponse = response as? HTTPURLResponse {
304
switch httpResponse.statusCode {
···
306
let mimeType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
307
let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? ""
308
let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)")
309
-
try fileManager.moveItem(
310
-
at: tempURL, to: newLocation)
311
return newLocation
312
case 400:
313
-
let (data, _) = try await URLSession.shared.data(for: request)
314
-
315
-
let errorResponse = try JSONDecoder().decode(
316
-
ATHTTPResponseError.self, from: data)
317
-
throw BlobDownloadError.apiError(error: errorResponse)
318
319
default:
320
try? FileManager.default.removeItem(at: tempURL)
···
322
statusCode: httpResponse.statusCode)
323
}
324
}
325
-
throw BlobDownloadError.unknownError
326
-
// guard let httpResponse = response as? HTTPURLResponse,
327
-
// (200...299).contains(httpResponse.statusCode)
328
-
// else {
329
-
// try? FileManager.default.removeItem(at: tempURL)
330
-
// throw BlobDownloadError.httpError(
331
-
// statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0)
332
-
// }
333
334
}
335
336
public func getBlob(
···
340
saveLocation: URL,
341
pdsURL: String? = nil
342
) async throws -> URL {
343
let baseUrl = pdsURL ?? "https://bsky.network"
344
345
// Construct URL with query parameters
···
358
}
359
360
return try await streamBlobToDisk(
361
-
resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid)
362
}
363
364
public func getCar(
···
371
) async throws -> URL {
372
373
let baseUrl = pdsURL ?? "https://bsky.network"
374
-
// Only ever one file being downloaded
375
-
self.maxConcurrentDownloads = 1
376
-
// Construct URL with query parameters
377
guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getRepo")
378
else {
379
throw BlobDownloadError.invalidURL
···
17
case failed(error: Error)
18
}
19
20
+
// Structure to hold download result
21
+
struct DownloadResult {
22
+
let tempURL: URL
23
+
let response: URLResponse?
24
+
}
25
+
26
+
// Async semaphore for concurrency control
27
+
actor AsyncSemaphore {
28
+
private var value: Int
29
+
private var waiters: [CheckedContinuation<Void, Never>] = []
30
+
31
+
init(value: Int) {
32
+
self.value = value
33
+
}
34
+
35
+
func wait() async {
36
+
if value > 0 {
37
+
value -= 1
38
+
return
39
+
}
40
+
41
+
await withCheckedContinuation { continuation in
42
+
waiters.append(continuation)
43
+
}
44
+
}
45
+
46
+
func signal() {
47
+
if let waiter = waiters.first {
48
+
waiters.removeFirst()
49
+
waiter.resume()
50
+
} else {
51
+
value += 1
52
+
}
53
+
}
54
+
}
55
+
56
+
// Delegate handler for background downloads
57
+
class BlobDownloadDelegate: NSObject, URLSessionDownloadDelegate {
58
+
private var downloadCompletions: [URLSessionDownloadTask: CheckedContinuation<DownloadResult, Error>] = [:]
59
+
private var taskResponses: [URLSessionDownloadTask: URLResponse] = [:]
60
+
private var taskAccountDids: [URLSessionDownloadTask: String] = [:]
61
+
private let completionQueue = DispatchQueue(label: "com.app.atproto.download-completions")
62
+
63
+
func addCompletion(_ continuation: CheckedContinuation<DownloadResult, Error>, for task: URLSessionDownloadTask, accountDid: String? = nil) {
64
+
completionQueue.sync {
65
+
downloadCompletions[task] = continuation
66
+
if let accountDid = accountDid {
67
+
taskAccountDids[task] = accountDid
68
+
}
69
+
}
70
+
}
71
+
72
+
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
73
+
completionQueue.sync {
74
+
if let continuation = downloadCompletions[downloadTask] {
75
+
// Move file to temporary location to prevent deletion
76
+
let tempDir = FileManager.default.temporaryDirectory
77
+
let tempURL = tempDir.appendingPathComponent(UUID().uuidString)
78
+
do {
79
+
try FileManager.default.moveItem(at: location, to: tempURL)
80
+
let response = taskResponses[downloadTask] ?? downloadTask.response
81
+
let result = DownloadResult(tempURL: tempURL, response: response)
82
+
continuation.resume(returning: result)
83
+
84
+
// Update background tracker if this is a blob download
85
+
if let accountDid = taskAccountDids[downloadTask] {
86
+
Task {
87
+
await BackgroundDownloadTracker.shared.incrementProgress(for: accountDid)
88
+
}
89
+
}
90
+
} catch {
91
+
continuation.resume(throwing: error)
92
+
}
93
+
downloadCompletions.removeValue(forKey: downloadTask)
94
+
taskResponses.removeValue(forKey: downloadTask)
95
+
taskAccountDids.removeValue(forKey: downloadTask)
96
+
}
97
+
}
98
+
}
99
+
100
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
101
+
// Store response for download tasks
102
+
if let downloadTask = dataTask as? URLSessionDownloadTask {
103
+
completionQueue.sync {
104
+
taskResponses[downloadTask] = response
105
+
}
106
+
}
107
+
completionHandler(.allow)
108
+
}
109
+
110
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
111
+
completionQueue.sync {
112
+
if let downloadTask = task as? URLSessionDownloadTask,
113
+
let continuation = downloadCompletions[downloadTask] {
114
+
if let error = error {
115
+
continuation.resume(throwing: error)
116
+
downloadCompletions.removeValue(forKey: downloadTask)
117
+
taskResponses.removeValue(forKey: downloadTask)
118
+
taskAccountDids.removeValue(forKey: downloadTask)
119
+
}
120
+
// If no error, success is handled in didFinishDownloadingTo
121
+
}
122
+
}
123
+
}
124
+
125
+
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
126
+
// Handle background session completion
127
+
// You might want to post a notification here if needed
128
+
}
129
+
}
130
+
131
public actor BlobDownloader {
132
+
private var maxConcurrentDownloads: Int = 5
133
private var activeTasks = 0
134
private var continuation: CheckedContinuation<Void, Never>?
135
// private var atProtocolManger: AtProtocolManager
···
138
private var activeDownloadTasks: Set<Task<URL?, Error>> = []
139
private var currentDownloadTask: Task<[URL], Error>?
140
141
+
// Delegate instance
142
+
private let sessionDelegate = BlobDownloadDelegate()
143
+
144
+
// Stable session identifier for background downloads
145
+
private static let backgroundSessionIdentifier = "com.app.atproto.backup.downloader"
146
+
147
+
private lazy var backgroundSession: URLSession = {
148
+
let config = URLSessionConfiguration.background(
149
+
withIdentifier: Self.backgroundSessionIdentifier
150
+
)
151
+
config.isDiscretionary = false
152
+
config.sessionSendsLaunchEvents = true
153
+
config.allowsCellularAccess = true // For critical data
154
+
return URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
155
+
}()
156
+
157
+
// Initialize and restore any pending background downloads
158
+
public init() {
159
+
// Force creation of the session to reconnect with any existing downloads
160
+
_ = backgroundSession
161
+
}
162
+
163
+
// Static method to handle background session events (call from AppDelegate)
164
+
public static func handleEventsForBackgroundURLSession(
165
+
identifier: String,
166
+
completionHandler: @escaping () -> Void
167
+
) {
168
+
if identifier == backgroundSessionIdentifier {
169
+
// Store the completion handler to call when all tasks complete
170
+
// This would typically be handled in the delegate's urlSessionDidFinishEvents method
171
+
completionHandler()
172
+
}
173
+
}
174
// Progress tracking
175
// private var progressContinuation: AsyncStream<DownloadProgress>.Continuation?
176
···
197
}
198
}
199
200
+
// Background-optimized download method that enqueues all tasks immediately
201
+
func downloadBlobsBackground(
202
+
repo: String,
203
+
pdsURL: String,
204
+
cids: [String],
205
+
saveLocationBookmark: Data? = nil,
206
+
maxConcurrent: Int = 5,
207
+
progressHandler: ((Int, Int) -> Void)? = nil
208
+
) async throws -> [URL] {
209
+
// Resolve save location
210
+
var saveLocation: URL
211
+
if let override = saveLocationBookmark {
212
+
var isStale = false
213
+
guard
214
+
let saveUrl = try? URL(
215
+
resolvingBookmarkData: override,
216
+
options: .withoutUI,
217
+
relativeTo: nil,
218
+
bookmarkDataIsStale: &isStale
219
+
)
220
+
else {
221
+
throw GenericIntentError.message("Failed to resolve bookmark data")
222
+
}
223
+
saveLocation = saveUrl
224
+
} else {
225
+
let tempDirectory = FileManager.default.temporaryDirectory
226
+
saveLocation = tempDirectory.appendingPathComponent(repo)
227
+
228
+
do {
229
+
try FileManager.default.createDirectory(
230
+
at: saveLocation, withIntermediateDirectories: true, attributes: nil)
231
+
} catch CocoaError.fileWriteFileExists {
232
+
print("Folder already exists at: \(saveLocation.path)")
233
+
} catch {
234
+
throw error
235
+
}
236
+
}
237
+
238
+
print("Starting background download of \(cids.count) blobs with max \(maxConcurrent) concurrent")
239
+
240
+
// Create all download tasks immediately so they continue in background
241
+
var results: [String: URL] = [:]
242
+
let resultsLock = NSLock()
243
+
244
+
// Semaphore to limit concurrent downloads
245
+
let semaphore = AsyncSemaphore(value: maxConcurrent)
246
+
247
+
// Use a TaskGroup to wait for all downloads
248
+
try await withThrowingTaskGroup(of: (String, URL)?.self) { group in
249
+
for cid in cids {
250
+
group.addTask { [weak self] in
251
+
guard let self = self else { return nil }
252
+
253
+
// Wait for semaphore before starting download
254
+
await semaphore.wait()
255
+
256
+
do {
257
+
let url = try await self.getBlob(
258
+
from: repo,
259
+
cid: cid,
260
+
fileManager: FileManager.default,
261
+
saveLocation: saveLocation,
262
+
pdsURL: pdsURL
263
+
)
264
+
await semaphore.signal()
265
+
return (cid, url)
266
+
} catch {
267
+
print("Failed to download blob \(cid): \(error)")
268
+
await semaphore.signal()
269
+
return nil
270
+
}
271
+
}
272
+
}
273
+
274
+
var downloadedCount = 0
275
+
for try await result in group {
276
+
if let (cid, url) = result {
277
+
resultsLock.lock()
278
+
results[cid] = url
279
+
downloadedCount += 1
280
+
resultsLock.unlock()
281
+
282
+
progressHandler?(downloadedCount, cids.count)
283
+
}
284
+
}
285
+
}
286
+
287
+
// Return URLs in original CID order
288
+
return cids.compactMap { results[$0] }
289
+
}
290
+
291
// Modified downloadBlobs method with proper cleanup
292
func downloadBlobs(
293
+
repo: String,
294
+
pdsURL: String,
295
+
cids: [String],
296
+
saveLocationBookmark: Data? = nil,
297
+
maxConcurrentDownloads: Int = 1,
298
+
progressHandler: ((Int, Int) -> Void)? = nil
299
+
) async throws -> [URL] {
300
+
// Use background-optimized method for better suspension handling
301
+
return try await downloadBlobsBackground(
302
+
repo: repo,
303
+
pdsURL: pdsURL,
304
+
cids: cids,
305
+
saveLocationBookmark: saveLocationBookmark,
306
+
maxConcurrent: maxConcurrentDownloads,
307
+
progressHandler: progressHandler
308
+
)
309
+
}
310
+
311
+
// Legacy chunked download method (kept for reference)
312
+
func downloadBlobsChunked(
313
repo: String,
314
pdsURL: String,
315
cids: [String],
···
543
resourceURL: URL,
544
fileManager: FileManager,
545
saveLocation: URL,
546
+
fileName: String,
547
+
accountDid: String? = nil
548
) async throws -> URL {
549
try Task.checkCancellation()
550
···
553
request.setValue("*/*", forHTTPHeaderField: "Accept")
554
request.timeoutInterval = 30
555
556
+
// Create download task and use continuation
557
+
let result = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<DownloadResult, Error>) in
558
+
let downloadTask = self.backgroundSession.downloadTask(with: request)
559
+
self.sessionDelegate.addCompletion(continuation, for: downloadTask, accountDid: accountDid)
560
+
downloadTask.resume()
561
+
}
562
+
563
+
let tempURL = result.tempURL
564
+
let response = result.response
565
566
if let httpResponse = response as? HTTPURLResponse {
567
switch httpResponse.statusCode {
···
569
let mimeType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
570
let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? ""
571
let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)")
572
+
try fileManager.moveItem(at: tempURL, to: newLocation)
573
return newLocation
574
case 400:
575
+
// For 400 errors, we'd need to handle differently since background downloads
576
+
// don't give us the response body. Just throw the error.
577
+
try? FileManager.default.removeItem(at: tempURL)
578
+
throw BlobDownloadError.httpError(statusCode: 400)
579
580
default:
581
try? FileManager.default.removeItem(at: tempURL)
···
583
statusCode: httpResponse.statusCode)
584
}
585
}
586
587
+
// If no response info, assume success and process based on content
588
+
let mimeType = "application/octet-stream" // Default
589
+
let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? ""
590
+
let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)")
591
+
try fileManager.moveItem(at: tempURL, to: newLocation)
592
+
return newLocation
593
}
594
595
public func getBlob(
···
599
saveLocation: URL,
600
pdsURL: String? = nil
601
) async throws -> URL {
602
+
// Check if blob already exists with any extension
603
+
let fileEnumerator = fileManager.enumerator(
604
+
at: saveLocation,
605
+
includingPropertiesForKeys: [.isRegularFileKey],
606
+
options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]
607
+
)
608
+
609
+
if let fileEnumerator = fileEnumerator {
610
+
for case let fileURL as URL in fileEnumerator {
611
+
let fileName = fileURL.deletingPathExtension().lastPathComponent
612
+
if fileName == cid {
613
+
print("Blob \(cid) already exists at \(fileURL.path), skipping download")
614
+
615
+
// Update progress tracker for existing file
616
+
// if let accountDid = accountDID {
617
+
Task {
618
+
await BackgroundDownloadTracker.shared.incrementProgress(for: accountDID)
619
+
}
620
+
// }
621
+
622
+
return fileURL
623
+
}
624
+
}
625
+
}
626
+
627
let baseUrl = pdsURL ?? "https://bsky.network"
628
629
// Construct URL with query parameters
···
642
}
643
644
return try await streamBlobToDisk(
645
+
resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid, accountDid: accountDID)
646
}
647
648
public func getCar(
···
655
) async throws -> URL {
656
657
let baseUrl = pdsURL ?? "https://bsky.network"
658
guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getRepo")
659
else {
660
throw BlobDownloadError.invalidURL
+1
-1
AtProtoBackup/ContentView.swift
+1
-1
AtProtoBackup/ContentView.swift
+26
-1
AtProtoBackup/DownloadActivityAttributes.swift
+26
-1
AtProtoBackup/DownloadActivityAttributes.swift
···
1
+
#if os(iOS)
2
import ActivityKit
3
+
#endif
4
import Foundation
5
6
+
#if os(iOS)
7
struct DownloadActivityAttributes: ActivityAttributes {
8
public struct ContentState: Codable, Hashable {
9
var progress: Double
···
23
24
var accountDid: String
25
var accountHandle: String
26
+
}
27
+
#else
28
+
struct DownloadActivityAttributes {
29
+
public struct ContentState: Codable, Hashable {
30
+
var progress: Double
31
+
var downloadedBlobs: Int
32
+
var totalBlobs: Int?
33
+
var accountHandle: String
34
+
var isPaused: Bool
35
+
var status: DownloadStatus
36
+
37
+
enum DownloadStatus: String, Codable {
38
+
case fetchingData = "Fetching repository data..."
39
+
case downloading = "Downloading"
40
+
case paused = "Paused"
41
+
case completed = "Completed"
42
+
}
43
+
}
44
+
45
+
var accountDid: String
46
+
var accountHandle: String
47
+
}
48
+
#endif
+24
-10
AtProtoBackup/DownloadManager.swift
+24
-10
AtProtoBackup/DownloadManager.swift
···
8
import SwiftUI
9
import Combine
10
import ATProtoKit
11
import ActivityKit
12
13
struct DownloadInfo: Identifiable {
14
let id = UUID()
···
22
class DownloadManager: ObservableObject {
23
@Published private var downloads: [String: DownloadInfo] = [:]
24
private let blobDownloader = BlobDownloader()
25
private var liveActivities: [String: Activity<DownloadActivityAttributes>] = [:]
26
27
func getDownload(for account: Account) -> DownloadInfo? {
28
downloads[account.did]
···
37
downloads[account.did]?.progress = downloads[account.did]?.progress ?? 0
38
}
39
40
// Start Live Activity
41
startLiveActivity(for: account)
42
43
Task {
44
await MainActor.run {
45
downloads[accountDid]?.isDownloading = true
46
}
47
48
// Update Live Activity to fetching state
49
updateLiveActivity(for: accountDid, status: .fetchingData, progress: 0, downloadedBlobs: 0, totalBlobs: nil, isPaused: false)
50
51
do {
52
let tempDirectory = FileManager.default.temporaryDirectory
···
93
downloads[accountDid]?.progress = 0 // Reset progress for blob downloads
94
}
95
96
// Update Live Activity to downloading state
97
updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false)
98
99
// guard let saveUrl = saveLocation.fileURL else {
100
// throw GenericIntentError.message(
101
// "Was not able to get a valid url for the save location")
···
124
if let totalBlobs = self?.downloads[accountDid]?.totalBlobs {
125
let progress = Double(downloaded) / Double(totalBlobs)
126
self?.downloads[accountDid]?.progress = progress
127
-
// Update Live Activity progress
128
-
self?.updateLiveActivity(for: accountDid, status: .downloading, progress: progress, downloadedBlobs: downloaded, totalBlobs: totalBlobs, isPaused: false)
129
}
130
}
131
}
···
135
downloads[accountDid]?.isDownloading = false
136
}
137
138
-
// Update Live Activity to completed
139
-
updateLiveActivity(for: accountDid, status: .completed, progress: 1.0, downloadedBlobs: totalCount, totalBlobs: totalCount, isPaused: false)
140
-
141
-
// End Live Activity after a delay
142
-
Task {
143
-
try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
144
-
endLiveActivity(for: accountDid)
145
-
}
146
147
} catch {
148
print("Download error: \(error)")
···
205
206
// MARK: - Live Activity Management
207
208
private func startLiveActivity(for account: Account) {
209
print("[LiveActivity] Checking if activities are enabled...")
210
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
···
279
liveActivities.removeValue(forKey: accountDid)
280
}
281
}
282
}
···
8
import SwiftUI
9
import Combine
10
import ATProtoKit
11
+
#if os(iOS)
12
import ActivityKit
13
+
#endif
14
15
struct DownloadInfo: Identifiable {
16
let id = UUID()
···
24
class DownloadManager: ObservableObject {
25
@Published private var downloads: [String: DownloadInfo] = [:]
26
private let blobDownloader = BlobDownloader()
27
+
#if os(iOS)
28
private var liveActivities: [String: Activity<DownloadActivityAttributes>] = [:]
29
+
#endif
30
31
func getDownload(for account: Account) -> DownloadInfo? {
32
downloads[account.did]
···
41
downloads[account.did]?.progress = downloads[account.did]?.progress ?? 0
42
}
43
44
+
#if os(iOS)
45
// Start Live Activity
46
startLiveActivity(for: account)
47
+
#endif
48
49
Task {
50
await MainActor.run {
51
downloads[accountDid]?.isDownloading = true
52
}
53
54
+
#if os(iOS)
55
// Update Live Activity to fetching state
56
updateLiveActivity(for: accountDid, status: .fetchingData, progress: 0, downloadedBlobs: 0, totalBlobs: nil, isPaused: false)
57
+
#endif
58
59
do {
60
let tempDirectory = FileManager.default.temporaryDirectory
···
101
downloads[accountDid]?.progress = 0 // Reset progress for blob downloads
102
}
103
104
+
#if os(iOS)
105
// Update Live Activity to downloading state
106
updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false)
107
108
+
// Register with background tracker
109
+
await BackgroundDownloadTracker.shared.registerDownload(accountDid: accountDid, totalBlobs: totalCount)
110
+
if let activity = liveActivities[accountDid] {
111
+
await BackgroundDownloadTracker.shared.registerLiveActivity(activity, for: accountDid)
112
+
}
113
+
#endif
114
+
115
// guard let saveUrl = saveLocation.fileURL else {
116
// throw GenericIntentError.message(
117
// "Was not able to get a valid url for the save location")
···
140
if let totalBlobs = self?.downloads[accountDid]?.totalBlobs {
141
let progress = Double(downloaded) / Double(totalBlobs)
142
self?.downloads[accountDid]?.progress = progress
143
+
// Live Activity updates are now handled by BackgroundDownloadTracker
144
}
145
}
146
}
···
150
downloads[accountDid]?.isDownloading = false
151
}
152
153
+
#if os(iOS)
154
+
// Final update is handled by BackgroundDownloadTracker when last blob completes
155
+
// Clean up the tracker
156
+
await BackgroundDownloadTracker.shared.cleanup(for: accountDid)
157
+
#endif
158
159
} catch {
160
print("Download error: \(error)")
···
217
218
// MARK: - Live Activity Management
219
220
+
#if os(iOS)
221
private func startLiveActivity(for account: Account) {
222
print("[LiveActivity] Checking if activities are enabled...")
223
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
···
292
liveActivities.removeValue(forKey: accountDid)
293
}
294
}
295
+
#endif
296
}
+2
-2
AtProtoBackup/Info.plist
+2
-2
AtProtoBackup/Info.plist
···
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
<plist version="1.0">
4
<dict>
5
-
<key>NSSupportsLiveActivities</key>
6
-
<true/>
7
<key>UIBackgroundModes</key>
8
<array>
9
<string>remote-notification</string>
10
</array>
11
</dict>
12
</plist>
···
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
<plist version="1.0">
4
<dict>
5
<key>UIBackgroundModes</key>
6
<array>
7
<string>remote-notification</string>
8
+
<string>fetch</string>
9
+
<string>processing</string>
10
</array>
11
</dict>
12
</plist>
+6
AtProtoBackup/LiveActivityManager.swift
+6
AtProtoBackup/LiveActivityManager.swift
···
1
import ActivityKit
2
import Foundation
3
4
class LiveActivityManager {
5
static let shared = LiveActivityManager()
6
7
private init() {
8
// Check Activity permissions on init
9
Task {
10
await checkActivityPermissions()
11
}
12
}
13
14
func checkActivityPermissions() async {
15
let authorizationInfo = ActivityAuthorizationInfo()
16
···
29
// but we can check if they're enabled
30
await checkActivityPermissions()
31
}
32
}
···
1
+
#if os(iOS)
2
import ActivityKit
3
+
#endif
4
import Foundation
5
6
class LiveActivityManager {
7
static let shared = LiveActivityManager()
8
9
private init() {
10
+
#if os(iOS)
11
// Check Activity permissions on init
12
Task {
13
await checkActivityPermissions()
14
}
15
+
#endif
16
}
17
18
+
#if os(iOS)
19
func checkActivityPermissions() async {
20
let authorizationInfo = ActivityAuthorizationInfo()
21
···
34
// but we can check if they're enabled
35
await checkActivityPermissions()
36
}
37
+
#endif
38
}
+3
-1
WidgetExtension/DownloadActivityAttributes.swift
+3
-1
WidgetExtension/DownloadActivityAttributes.swift
+3
-1
WidgetExtension/DownloadLiveActivityView.swift
+3
-1
WidgetExtension/DownloadLiveActivityView.swift
+2
-1
WidgetExtension/WidgetExtensionBundle.swift
+2
-1
WidgetExtension/WidgetExtensionBundle.swift