this repo has no description
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}