+2
AtProtoBackup.xcodeproj/project.pbxproj
+2
AtProtoBackup.xcodeproj/project.pbxproj
···
419
419
ENABLE_PREVIEWS = YES;
420
420
GENERATE_INFOPLIST_FILE = YES;
421
421
INFOPLIST_FILE = AtProtoBackup/Info.plist;
422
+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
422
423
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
423
424
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
424
425
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
···
461
462
ENABLE_PREVIEWS = YES;
462
463
GENERATE_INFOPLIST_FILE = YES;
463
464
INFOPLIST_FILE = AtProtoBackup/Info.plist;
465
+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
464
466
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
465
467
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
466
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
···
7
7
<key>AtProtoBackup.xcscheme_^#shared#^_</key>
8
8
<dict>
9
9
<key>orderHint</key>
10
-
<integer>0</integer>
10
+
<integer>1</integer>
11
11
</dict>
12
12
<key>WidgetExtensionExtension.xcscheme_^#shared#^_</key>
13
13
<dict>
14
14
<key>orderHint</key>
15
-
<integer>1</integer>
15
+
<integer>0</integer>
16
16
</dict>
17
17
</dict>
18
18
</dict>
+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
7
8
8
import SwiftUI
9
9
import SwiftData
10
+
#if os(iOS)
10
11
import ActivityKit
12
+
#endif
11
13
12
14
@main
13
15
struct AtProtoBackupApp: App {
16
+
#if os(iOS)
17
+
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
18
+
#endif
19
+
14
20
init() {
21
+
#if os(iOS)
15
22
// Initialize Live Activity permissions check
16
23
Task {
17
24
await LiveActivityManager.shared.checkActivityPermissions()
18
25
}
26
+
#endif
19
27
}
20
28
21
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
17
case failed(error: Error)
18
18
}
19
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
+
20
131
public actor BlobDownloader {
21
-
private var maxConcurrentDownloads: Int = 1
132
+
private var maxConcurrentDownloads: Int = 5
22
133
private var activeTasks = 0
23
134
private var continuation: CheckedContinuation<Void, Never>?
24
135
// private var atProtocolManger: AtProtocolManager
···
27
138
private var activeDownloadTasks: Set<Task<URL?, Error>> = []
28
139
private var currentDownloadTask: Task<[URL], Error>?
29
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
+
}
30
174
// Progress tracking
31
175
// private var progressContinuation: AsyncStream<DownloadProgress>.Continuation?
32
176
···
53
197
}
54
198
}
55
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
+
56
291
// Modified downloadBlobs method with proper cleanup
57
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(
58
313
repo: String,
59
314
pdsURL: String,
60
315
cids: [String],
···
288
543
resourceURL: URL,
289
544
fileManager: FileManager,
290
545
saveLocation: URL,
291
-
fileName: String
546
+
fileName: String,
547
+
accountDid: String? = nil
292
548
) async throws -> URL {
293
549
try Task.checkCancellation()
294
550
···
297
553
request.setValue("*/*", forHTTPHeaderField: "Accept")
298
554
request.timeoutInterval = 30
299
555
300
-
// Download to file instead of memory
301
-
let (tempURL, response) = try await URLSession.shared.download(for: request)
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
302
565
303
566
if let httpResponse = response as? HTTPURLResponse {
304
567
switch httpResponse.statusCode {
···
306
569
let mimeType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
307
570
let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? ""
308
571
let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)")
309
-
try fileManager.moveItem(
310
-
at: tempURL, to: newLocation)
572
+
try fileManager.moveItem(at: tempURL, to: newLocation)
311
573
return newLocation
312
574
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)
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)
318
579
319
580
default:
320
581
try? FileManager.default.removeItem(at: tempURL)
···
322
583
statusCode: httpResponse.statusCode)
323
584
}
324
585
}
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
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
334
593
}
335
594
336
595
public func getBlob(
···
340
599
saveLocation: URL,
341
600
pdsURL: String? = nil
342
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
+
343
627
let baseUrl = pdsURL ?? "https://bsky.network"
344
628
345
629
// Construct URL with query parameters
···
358
642
}
359
643
360
644
return try await streamBlobToDisk(
361
-
resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid)
645
+
resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid, accountDid: accountDID)
362
646
}
363
647
364
648
public func getCar(
···
371
655
) async throws -> URL {
372
656
373
657
let baseUrl = pdsURL ?? "https://bsky.network"
374
-
// Only ever one file being downloaded
375
-
self.maxConcurrentDownloads = 1
376
-
// Construct URL with query parameters
377
658
guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getRepo")
378
659
else {
379
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)
1
2
import ActivityKit
3
+
#endif
2
4
import Foundation
3
5
6
+
#if os(iOS)
4
7
struct DownloadActivityAttributes: ActivityAttributes {
5
8
public struct ContentState: Codable, Hashable {
6
9
var progress: Double
···
20
23
21
24
var accountDid: String
22
25
var accountHandle: String
23
-
}
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
8
import SwiftUI
9
9
import Combine
10
10
import ATProtoKit
11
+
#if os(iOS)
11
12
import ActivityKit
13
+
#endif
12
14
13
15
struct DownloadInfo: Identifiable {
14
16
let id = UUID()
···
22
24
class DownloadManager: ObservableObject {
23
25
@Published private var downloads: [String: DownloadInfo] = [:]
24
26
private let blobDownloader = BlobDownloader()
27
+
#if os(iOS)
25
28
private var liveActivities: [String: Activity<DownloadActivityAttributes>] = [:]
29
+
#endif
26
30
27
31
func getDownload(for account: Account) -> DownloadInfo? {
28
32
downloads[account.did]
···
37
41
downloads[account.did]?.progress = downloads[account.did]?.progress ?? 0
38
42
}
39
43
44
+
#if os(iOS)
40
45
// Start Live Activity
41
46
startLiveActivity(for: account)
47
+
#endif
42
48
43
49
Task {
44
50
await MainActor.run {
45
51
downloads[accountDid]?.isDownloading = true
46
52
}
47
53
54
+
#if os(iOS)
48
55
// Update Live Activity to fetching state
49
56
updateLiveActivity(for: accountDid, status: .fetchingData, progress: 0, downloadedBlobs: 0, totalBlobs: nil, isPaused: false)
57
+
#endif
50
58
51
59
do {
52
60
let tempDirectory = FileManager.default.temporaryDirectory
···
93
101
downloads[accountDid]?.progress = 0 // Reset progress for blob downloads
94
102
}
95
103
104
+
#if os(iOS)
96
105
// Update Live Activity to downloading state
97
106
updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false)
98
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
+
99
115
// guard let saveUrl = saveLocation.fileURL else {
100
116
// throw GenericIntentError.message(
101
117
// "Was not able to get a valid url for the save location")
···
124
140
if let totalBlobs = self?.downloads[accountDid]?.totalBlobs {
125
141
let progress = Double(downloaded) / Double(totalBlobs)
126
142
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)
143
+
// Live Activity updates are now handled by BackgroundDownloadTracker
129
144
}
130
145
}
131
146
}
···
135
150
downloads[accountDid]?.isDownloading = false
136
151
}
137
152
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
-
}
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
146
158
147
159
} catch {
148
160
print("Download error: \(error)")
···
205
217
206
218
// MARK: - Live Activity Management
207
219
220
+
#if os(iOS)
208
221
private func startLiveActivity(for account: Account) {
209
222
print("[LiveActivity] Checking if activities are enabled...")
210
223
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
···
279
292
liveActivities.removeValue(forKey: accountDid)
280
293
}
281
294
}
295
+
#endif
282
296
}
+2
-2
AtProtoBackup/Info.plist
+2
-2
AtProtoBackup/Info.plist
···
2
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
<plist version="1.0">
4
4
<dict>
5
-
<key>NSSupportsLiveActivities</key>
6
-
<true/>
7
5
<key>UIBackgroundModes</key>
8
6
<array>
9
7
<string>remote-notification</string>
8
+
<string>fetch</string>
9
+
<string>processing</string>
10
10
</array>
11
11
</dict>
12
12
</plist>
+6
AtProtoBackup/LiveActivityManager.swift
+6
AtProtoBackup/LiveActivityManager.swift
···
1
+
#if os(iOS)
1
2
import ActivityKit
3
+
#endif
2
4
import Foundation
3
5
4
6
class LiveActivityManager {
5
7
static let shared = LiveActivityManager()
6
8
7
9
private init() {
10
+
#if os(iOS)
8
11
// Check Activity permissions on init
9
12
Task {
10
13
await checkActivityPermissions()
11
14
}
15
+
#endif
12
16
}
13
17
18
+
#if os(iOS)
14
19
func checkActivityPermissions() async {
15
20
let authorizationInfo = ActivityAuthorizationInfo()
16
21
···
29
34
// but we can check if they're enabled
30
35
await checkActivityPermissions()
31
36
}
37
+
#endif
32
38
}
+3
-1
WidgetExtension/DownloadActivityAttributes.swift
+3
-1
WidgetExtension/DownloadActivityAttributes.swift
+3
-1
WidgetExtension/DownloadLiveActivityView.swift
+3
-1
WidgetExtension/DownloadLiveActivityView.swift