this repo has no description

Fix Mac App by wrapping live activity stuff in ios only. Also work on making downloads continue in the backgriund but its not really working well yet

+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
··· 7 <key>AtProtoBackup.xcscheme_^#shared#^_</key> 8 <dict> 9 <key>orderHint</key> 10 - <integer>0</integer> 11 </dict> 12 <key>WidgetExtensionExtension.xcscheme_^#shared#^_</key> 13 <dict> 14 <key>orderHint</key> 15 - <integer>1</integer> 16 </dict> 17 </dict> 18 </dict>
··· 7 <key>AtProtoBackup.xcscheme_^#shared#^_</key> 8 <dict> 9 <key>orderHint</key> 10 + <integer>1</integer> 11 </dict> 12 <key>WidgetExtensionExtension.xcscheme_^#shared#^_</key> 13 <dict> 14 <key>orderHint</key> 15 + <integer>0</integer> 16 </dict> 17 </dict> 18 </dict>
+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
··· 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
···
··· 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
··· 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
··· 119 120 #Preview { 121 ContentView() 122 - .modelContainer(for: Account.self, inMemory: true) 123 }
··· 119 120 #Preview { 121 ContentView() 122 + .modelContainer(for: Account.self, inMemory: false) 123 }
+26 -1
AtProtoBackup/DownloadActivityAttributes.swift
··· 1 import ActivityKit 2 import Foundation 3 4 struct DownloadActivityAttributes: ActivityAttributes { 5 public struct ContentState: Codable, Hashable { 6 var progress: Double ··· 20 21 var accountDid: String 22 var accountHandle: String 23 - }
··· 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
··· 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 <!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
··· 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
··· 1 import ActivityKit 2 import Foundation 3 ··· 20 21 var accountDid: String 22 var accountHandle: String 23 - }
··· 1 + #if os(iOS) 2 import ActivityKit 3 import Foundation 4 ··· 21 22 var accountDid: String 23 var accountHandle: String 24 + } 25 + #endif
+3 -1
WidgetExtension/DownloadLiveActivityView.swift
··· 1 import ActivityKit 2 import SwiftUI 3 import WidgetKit ··· 239 .foregroundColor(.green) 240 } 241 } 242 - }
··· 1 + #if os(iOS) 2 import ActivityKit 3 import SwiftUI 4 import WidgetKit ··· 240 .foregroundColor(.green) 241 } 242 } 243 + } 244 + #endif
+2 -1
WidgetExtension/WidgetExtensionBundle.swift
··· 4 // 5 // Created by Corey Alexander on 8/28/25. 6 // 7 - 8 import WidgetKit 9 import SwiftUI 10 ··· 14 DownloadActivityWidget() 15 } 16 }
··· 4 // 5 // Created by Corey Alexander on 8/28/25. 6 // 7 + #if os(iOS) 8 import WidgetKit 9 import SwiftUI 10 ··· 14 DownloadActivityWidget() 15 } 16 } 17 + #endif