this repo has no description
at main 10 kB view raw
1// 2// DownloadManager.swift 3// AtProtoBackup 4// 5// Created by Corey Alexander on 8/25/25. 6// 7 8import SwiftUI 9import Combine 10import ATProtoKit 11#if os(iOS) 12import ActivityKit 13#endif 14 15struct DownloadInfo: Identifiable { 16 let id = UUID() 17 let accountID: String 18 var progress: Double = 0 19 var totalBlobs: Int? 20 var isDownloading: Bool = false 21 var blobDownloader: Bool = false 22} 23 24class 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] 33 } 34 35 func startDownload(for account: Account) { 36 let accountDid = account.did 37 if downloads[account.did] == nil { 38 downloads[account.did] = DownloadInfo(accountID: account.did) 39 } else { 40 // Reset progress for resume 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 saveLocation = try BackupDiscovery.accountBackupDirectory(for: account.did) 61 62 print("Saving to \(saveLocation.path)") 63 64 let startDate = Date() 65 let formatter = ISO8601DateFormatter() 66 let utcString = formatter.string(from: startDate) 67 let fileName = "\(account.handle)-\(utcString).car" 68 69 do { 70 try FileManager.default.createDirectory( 71 at: saveLocation, withIntermediateDirectories: true, attributes: nil) 72 } catch CocoaError.fileWriteFileExists { 73 print("Folder already exists at: \(saveLocation.path)") 74 } catch { 75 throw error 76 } 77 78 let result = try await blobDownloader.getCar(from: account.did, since: nil, fileManager: FileManager.default, saveLocation: saveLocation, fileName: fileName, pdsURL: account.pds) 79 80 print("All Done! \(result)") 81 82 // let config = ATProtocolConfiguration(pdsURL: account.pds) 83 84 // let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: account.pds) 85 86 var allBlobCids: [String] = [] 87 var cursor: String? = nil 88 89 repeat { 90 let blobsResponse = try await listBlobs(from: account.did, pds: account.pds, sinceRevision: nil, cursor: cursor) 91 allBlobCids.append(contentsOf: blobsResponse.accountCIDs) 92 cursor = blobsResponse.cursor 93 print("Listed \(blobsResponse.accountCIDs.count) blob cids, total: \(allBlobCids.count)") 94 } while cursor != nil 95 96 print("List all \(allBlobCids.count) blob cids") 97 let totalCount: Int = allBlobCids.count 98 await MainActor.run { 99 downloads[accountDid]?.totalBlobs = totalCount 100 downloads[accountDid]?.progress = 0 // Reset progress for blob downloads 101 } 102 103 #if os(iOS) 104 // Update Live Activity to downloading state 105 updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false) 106 107 // Register with background tracker 108 await BackgroundDownloadTracker.shared.registerDownload(accountDid: accountDid, totalBlobs: totalCount) 109 if let activity = liveActivities[accountDid] { 110 await BackgroundDownloadTracker.shared.registerLiveActivity(activity, for: accountDid) 111 } 112 #endif 113 114// guard let saveUrl = saveLocation.fileURL else { 115// throw GenericIntentError.message( 116// "Was not able to get a valid url for the save location") 117// } 118 let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() 119 defer { 120 if didStartAccessing { 121 saveLocation.stopAccessingSecurityScopedResource() 122 } 123 } 124 var bookmarkData: Data? 125 if didStartAccessing { 126 do { 127 bookmarkData = try saveLocation.bookmarkData( 128 options: .minimalBookmark, 129 includingResourceValuesForKeys: nil, 130 relativeTo: nil 131 ) 132 } catch { 133 print("Failed to create bookmark: \(error)") 134 } 135 } 136 137 let (_, newBlobsDownloaded) = try await blobDownloader.downloadBlobs(repo: account.did, pdsURL: account.pds, cids: allBlobCids, saveLocationBookmark: bookmarkData) { [weak self] downloaded, total in 138 Task { @MainActor in 139 if let totalBlobs = self?.downloads[accountDid]?.totalBlobs { 140 let progress = Double(downloaded) / Double(totalBlobs) 141 self?.downloads[accountDid]?.progress = progress 142 // Live Activity updates are now handled by BackgroundDownloadTracker 143 } 144 } 145 } 146 147 await MainActor.run { 148 downloads[accountDid]?.progress = 1.0 149 downloads[accountDid]?.isDownloading = false 150 } 151 152 // Save backup metadata 153 let metadata = BackupMetadata( 154 completedAt: Date(), 155 startedAt: startDate, 156 did: account.did, 157 handle: account.handle, 158 pds: account.pds, 159 carFileName: fileName, 160 totalBlobs: totalCount, 161 newBlobsDownloaded: newBlobsDownloaded, 162 deviceInfo: BackupMetadata.DeviceInfo.current() 163 ) 164 165 do { 166 try metadata.save(to: saveLocation) 167 print("Backup metadata saved to \(saveLocation.path)/backup-metadata.json") 168 } catch { 169 print("Failed to save backup metadata: \(error)") 170 } 171 172 #if os(iOS) 173 // Final update is handled by BackgroundDownloadTracker when last blob completes 174 // Clean up the tracker 175 await BackgroundDownloadTracker.shared.cleanup(for: accountDid) 176 #endif 177 178 } catch { 179 print("Download error: \(error)") 180 await MainActor.run { 181 downloads[accountDid]?.isDownloading = false 182 } 183 } 184 } 185 186 func listBlobs( 187 from repositoryDID: String, 188 pds: String, 189 sinceRevision: String?, 190 limit: Int? = 500, 191 cursor: String? = nil 192 ) async throws -> ComAtprotoLexicon.Sync.ListBlobsOutput { 193 guard let requestURL = URL(string: pds + "/xrpc/com.atproto.sync.listBlobs") else { 194 throw ATProtocolError.invalidURL 195 } 196 197 var queryItems = [(String, String)]() 198 199 queryItems.append(("did", repositoryDID)) 200 201 if let sinceRevision { 202 queryItems.append(("since", sinceRevision)) 203 } 204 205 if let limit { 206 let finalLimit = max(1, min(limit, 1_000)) 207 queryItems.append(("limit", "\(finalLimit)")) 208 } 209 210 if let cursor { 211 queryItems.append(("cursor", cursor)) 212 } 213 214 var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: true)! 215 components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) } 216 217 guard let queryURL = components.url else { 218 throw ATProtocolError.invalidURL 219 } 220 221 var request = URLRequest(url: queryURL) 222 request.httpMethod = "GET" 223 request.setValue("application/vnd.ipld.car", forHTTPHeaderField: "Accept") 224 225 let (data, response) = try await URLSession.shared.data(for: request) 226 227 guard let httpResponse = response as? HTTPURLResponse, 228 (200...299).contains(httpResponse.statusCode) else { 229 throw ATProtocolError.invalidResponse 230 } 231 232 let decoder = JSONDecoder() 233 return try decoder.decode(ComAtprotoLexicon.Sync.ListBlobsOutput.self, from: data) 234 } 235 } 236 237 // MARK: - Live Activity Management 238 239 #if os(iOS) 240 private func startLiveActivity(for account: Account) { 241 print("[LiveActivity] Checking if activities are enabled...") 242 guard ActivityAuthorizationInfo().areActivitiesEnabled else { 243 print("[LiveActivity] Activities are not enabled") 244 return 245 } 246 247 print("[LiveActivity] Starting Live Activity for account: \(account.handle)") 248 249 let attributes = DownloadActivityAttributes( 250 accountDid: account.did, 251 accountHandle: account.handle 252 ) 253 254 let initialState = DownloadActivityAttributes.ContentState( 255 progress: 0, 256 downloadedBlobs: 0, 257 totalBlobs: nil, 258 accountHandle: account.handle, 259 isPaused: false, 260 status: .fetchingData 261 ) 262 263 let content = ActivityContent(state: initialState, staleDate: nil) 264 265 do { 266 let activity = try Activity.request( 267 attributes: attributes, 268 content: content, 269 pushType: nil 270 ) 271 liveActivities[account.did] = activity 272 print("[LiveActivity] Successfully started Live Activity with ID: \(activity.id)") 273 } catch { 274 print("[LiveActivity] Failed to start Live Activity: \(error)") 275 } 276 } 277 278 private func updateLiveActivity( 279 for accountDid: String, 280 status: DownloadActivityAttributes.ContentState.DownloadStatus, 281 progress: Double, 282 downloadedBlobs: Int, 283 totalBlobs: Int?, 284 isPaused: Bool 285 ) { 286 guard let activity = liveActivities[accountDid] else { 287 print("[LiveActivity] No activity found for account \(accountDid)") 288 return 289 } 290 291 Task { 292 let updatedState = DownloadActivityAttributes.ContentState( 293 progress: progress, 294 downloadedBlobs: downloadedBlobs, 295 totalBlobs: totalBlobs, 296 accountHandle: activity.attributes.accountHandle, 297 isPaused: isPaused, 298 status: status 299 ) 300 301 await activity.update(using: updatedState) 302 print("[LiveActivity] Updated activity for \(accountDid) - Status: \(status), Progress: \(Int(progress * 100))%") 303 } 304 } 305 306 private func endLiveActivity(for accountDid: String) { 307 guard let activity = liveActivities[accountDid] else { return } 308 309 Task { 310 await activity.end(dismissalPolicy: .immediate) 311 liveActivities.removeValue(forKey: accountDid) 312 } 313 } 314 #endif 315}