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 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
··· 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
··· 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 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
··· 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 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
··· 119 119 120 120 #Preview { 121 121 ContentView() 122 - .modelContainer(for: Account.self, inMemory: true) 122 + .modelContainer(for: Account.self, inMemory: false) 123 123 }
+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
··· 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 <!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
··· 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
··· 1 + #if os(iOS) 1 2 import ActivityKit 2 3 import Foundation 3 4 ··· 20 21 21 22 var accountDid: String 22 23 var accountHandle: String 23 - } 24 + } 25 + #endif
+3 -1
WidgetExtension/DownloadLiveActivityView.swift
··· 1 + #if os(iOS) 1 2 import ActivityKit 2 3 import SwiftUI 3 4 import WidgetKit ··· 239 240 .foregroundColor(.green) 240 241 } 241 242 } 242 - } 243 + } 244 + #endif
+2 -1
WidgetExtension/WidgetExtensionBundle.swift
··· 4 4 // 5 5 // Created by Corey Alexander on 8/28/25. 6 6 // 7 - 7 + #if os(iOS) 8 8 import WidgetKit 9 9 import SwiftUI 10 10 ··· 14 14 DownloadActivityWidget() 15 15 } 16 16 } 17 + #endif