@capawesome/capacitor-live-update 6.7.2 → 7.0.0-dev.1765885034

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +3 -3
  2. package/Package.swift +32 -0
  3. package/README.md +419 -83
  4. package/android/build.gradle +12 -12
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +758 -273
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +29 -27
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateHttpClient.java +40 -7
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +212 -68
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +52 -3
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +27 -0
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +32 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/{results/GetBundleResult.java → events/NextBundleSetEvent.java} +9 -7
  13. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +16 -1
  14. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetConfigOptions.java +19 -0
  15. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SyncOptions.java +4 -0
  16. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +29 -1
  17. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +27 -0
  18. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  19. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +27 -0
  20. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/IsSyncingResult.java +26 -0
  21. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  22. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  23. package/dist/docs.json +630 -103
  24. package/dist/esm/definitions.d.ts +365 -39
  25. package/dist/esm/definitions.js.map +1 -1
  26. package/dist/esm/index.js +1 -1
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +9 -2
  29. package/dist/esm/web.js +21 -0
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +22 -3
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +22 -3
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/Plugin/Classes/Events/DownloadBundleProgressEvent.swift +25 -0
  36. package/ios/Plugin/Classes/{Results/GetBundleResult.swift → Events/NextBundleSetEvent.swift} +3 -3
  37. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -1
  38. package/ios/Plugin/Classes/Options/SetConfigOptions.swift +14 -0
  39. package/ios/Plugin/Classes/Options/SyncOptions.swift +4 -0
  40. package/ios/Plugin/Classes/Results/{FetchLatestBundle.swift → FetchLatestBundleResult.swift} +16 -1
  41. package/ios/Plugin/Classes/Results/GetBlockedBundlesResult.swift +16 -0
  42. package/ios/Plugin/Classes/Results/GetConfigResult.swift +19 -0
  43. package/ios/Plugin/Classes/Results/GetDownloadedBundlesResult.swift +16 -0
  44. package/ios/Plugin/Classes/Results/IsSyncingResult.swift +16 -0
  45. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  46. package/ios/Plugin/LiveUpdate.swift +370 -151
  47. package/ios/Plugin/LiveUpdateConfig.swift +4 -4
  48. package/ios/Plugin/LiveUpdateHttpClient.swift +10 -4
  49. package/ios/Plugin/LiveUpdatePlugin.swift +138 -35
  50. package/ios/Plugin/LiveUpdatePreferences.swift +56 -14
  51. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +3 -0
  52. package/package.json +19 -20
  53. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetBundleOptions.java +0 -18
  54. package/ios/Plugin/Classes/Options/SetBundleOptions.swift +0 -14
  55. package/ios/Plugin/LiveUpdatePlugin.h +0 -10
  56. package/ios/Plugin/LiveUpdatePlugin.m +0 -27
@@ -1,18 +1,22 @@
1
1
  import Foundation
2
2
  import CryptoKit
3
+ #if canImport(ZipArchive)
4
+ import ZipArchive
5
+ #else
3
6
  import SSZipArchive
7
+ #endif
4
8
  import Capacitor
5
9
  import Alamofire
6
10
  import CommonCrypto
7
11
 
8
12
  // swiftlint:disable type_body_length
9
13
  @objc public class LiveUpdate: NSObject {
14
+ private let autoUpdateIntervalMs: Int64 = 15 * 60 * 1000 // 15 minutes
10
15
  private let bundlesDirectory = "NoCloud/ionic_built_snapshots" // DO NOT CHANGE! (See https://dub.sh/BLluidt)
11
16
  private let cachesDirectoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
12
17
  private let config: LiveUpdateConfig
13
18
  private let defaultWebAssetDir = "public" // DO NOT CHANGE! (See https://dub.sh/Buvz4yj)
14
19
  private let defaultServerPathKey = "serverBasePath" // DO NOT CHANGE! (See https://dub.sh/ceDl0zT)
15
- private let host: String
16
20
  private let httpClient: LiveUpdateHttpClient
17
21
  private let libraryDirectoryUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
18
22
  private let manifestFileName = "capawesome-live-update-manifest.json" // DO NOT CHANGE!
@@ -20,41 +24,40 @@ import CommonCrypto
20
24
  private let preferences: LiveUpdatePreferences
21
25
 
22
26
  private var rollbackDispatchWorkItem: DispatchWorkItem?
27
+ private var rollbackPerformed = false
28
+ private var lastAutoUpdateCheckTimestamp: Int64 = 0
29
+ private var syncInProgress = false
23
30
 
24
31
  init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin) {
25
32
  self.config = config
26
- if (config.location != nil) && config.location == "eu" {
27
- self.host = "api.cloud.capawesome.eu"
28
- } else {
29
- self.host = "api.cloud.capawesome.io"
30
- }
31
33
  self.httpClient = LiveUpdateHttpClient(config: config)
32
34
  self.plugin = plugin
33
35
  self.preferences = LiveUpdatePreferences()
34
36
  super.init()
35
37
 
36
- if config.enabled {
37
- if wasUpdated() && config.resetOnUpdate {
38
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App was updated. Resetting to default bundle.")
39
- reset()
40
- } else {
41
- startRollbackTimer()
42
- }
43
- saveCurrentBundleShortVersionStringAndBundleVersion()
44
- }
38
+ // Check version and reset config if version changed
39
+ checkAndResetConfigIfVersionChanged()
40
+
41
+ // Start the rollback timer to rollback to the default bundle
42
+ // if the app is not ready after a certain time
43
+ startRollbackTimer()
44
+ }
45
+
46
+ @objc public func clearBlockedBundles() {
47
+ preferences.setBlockedBundleIds(nil)
45
48
  }
46
49
 
47
50
  @objc public func deleteBundle(_ options: DeleteBundleOptions, completion: @escaping (Error?) -> Void) {
48
51
  let bundleId = options.getBundleId()
49
52
 
50
- if !hasBundle(bundleId: bundleId) {
53
+ if !hasBundleById(bundleId) {
51
54
  let error = CustomError.bundleNotFound
52
55
  completion(error)
53
56
  return
54
57
  }
55
58
 
56
59
  do {
57
- try deleteBundle(bundleId: bundleId)
60
+ try deleteBundleById(bundleId)
58
61
  completion(nil)
59
62
  } catch {
60
63
  completion(error)
@@ -65,10 +68,11 @@ import CommonCrypto
65
68
  let artifactType = options.getArtifactType()
66
69
  let bundleId = options.getBundleId()
67
70
  let checksum = options.getChecksum()
71
+ let signature = options.getSignature()
68
72
  let url = options.getUrl()
69
73
 
70
74
  // Check if the bundle already exists
71
- if hasBundle(bundleId: bundleId) {
75
+ if hasBundleById(bundleId) {
72
76
  throw CustomError.bundleAlreadyExists
73
77
  }
74
78
 
@@ -76,41 +80,51 @@ import CommonCrypto
76
80
  if artifactType == .manifest {
77
81
  try await downloadBundleOfTypeManifest(bundleId: bundleId, url: url)
78
82
  } else {
79
- try await downloadBundleOfTypeZip(bundleId: bundleId, url: url, checksum: checksum)
83
+ try await downloadBundleOfTypeZip(bundleId: bundleId, checksum: checksum, signature: signature, url: url)
80
84
  }
81
85
  }
82
86
 
83
87
  @objc public func fetchLatestBundle(_ options: FetchLatestBundleOptions) async throws -> FetchLatestBundleResult {
84
88
  let response: GetLatestBundleResponse? = try await self.fetchLatestBundle(options)
85
- return FetchLatestBundleResult(artifactType: response?.artifactType, bundleId: response?.bundleId, downloadUrl: response?.url)
89
+ return FetchLatestBundleResult(artifactType: response?.artifactType, bundleId: response?.bundleId, checksum: response?.checksum, customProperties: response?.customProperties, downloadUrl: response?.url, signature: response?.signature)
86
90
  }
87
91
 
88
- @objc public func getBundle(completion: @escaping (Result?, Error?) -> Void) {
89
- var bundleId = getCurrentBundleId()
90
- if bundleId == defaultWebAssetDir {
91
- bundleId = nil
92
+ @objc public func getBlockedBundles(completion: @escaping (Result?, Error?) -> Void) {
93
+ var bundleIds: [String] = []
94
+ if let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty {
95
+ bundleIds = blockedIds.split(separator: ",").map(String.init)
92
96
  }
93
- let result = GetBundleResult(bundleId: bundleId)
97
+ let result = GetBlockedBundlesResult(bundleIds: bundleIds)
94
98
  completion(result, nil)
95
99
  }
96
100
 
97
101
  @objc public func getBundles(completion: @escaping (Result?, Error?) -> Void) {
98
- let bundleIds = getBundleIds()
102
+ let bundleIds = getDownloadedBundleIds()
99
103
  let result = GetBundlesResult(bundleIds: bundleIds)
100
104
  completion(result, nil)
101
105
  }
102
106
 
107
+ @objc public func getDownloadedBundles(completion: @escaping (Result?, Error?) -> Void) {
108
+ let bundleIds = getDownloadedBundleIds()
109
+ let result = GetDownloadedBundlesResult(bundleIds: bundleIds)
110
+ completion(result, nil)
111
+ }
112
+
103
113
  @objc public func getChannel(completion: @escaping (Result?, Error?) -> Void) {
104
114
  let channel = getChannel()
105
115
  let result = GetChannelResult(channel: channel)
106
116
  completion(result, nil)
107
117
  }
108
118
 
119
+ @objc public func getConfig(completion: @escaping (Result?, Error?) -> Void) {
120
+ let appId = getAppId()
121
+ let autoUpdateStrategy = config.autoUpdateStrategy
122
+ let result = GetConfigResult(appId: appId, autoUpdateStrategy: autoUpdateStrategy)
123
+ completion(result, nil)
124
+ }
125
+
109
126
  @objc public func getCurrentBundle(completion: @escaping (Result?, Error?) -> Void) {
110
- var bundleId = getCurrentBundleId()
111
- if bundleId == defaultWebAssetDir {
112
- bundleId = nil
113
- }
127
+ let bundleId = getCurrentBundleId()
114
128
  let result = GetCurrentBundleResult(bundleId: bundleId)
115
129
  completion(result, nil)
116
130
  }
@@ -129,10 +143,7 @@ import CommonCrypto
129
143
  }
130
144
 
131
145
  @objc public func getNextBundle(completion: @escaping (Result?, Error?) -> Void) {
132
- var bundleId: String? = getNextBundleId()
133
- if bundleId == defaultWebAssetDir {
134
- bundleId = nil
135
- }
146
+ let bundleId: String? = getNextBundleId()
136
147
  let result = GetNextBundleResult(bundleId: bundleId)
137
148
  completion(result, nil)
138
149
  }
@@ -149,12 +160,48 @@ import CommonCrypto
149
160
  completion(result, nil)
150
161
  }
151
162
 
152
- @objc public func ready() {
163
+ @objc public func isSyncing(completion: @escaping (Result?, Error?) -> Void) {
164
+ let result = IsSyncingResult(syncing: syncInProgress)
165
+ completion(result, nil)
166
+ }
167
+
168
+ @objc public func handleLoad() {
169
+ if config.autoUpdateStrategy == "background" {
170
+ performAutoUpdate()
171
+ }
172
+ }
173
+
174
+ @objc public func handleAppWillEnterForeground() {
175
+ if config.autoUpdateStrategy == "background" {
176
+ performAutoUpdate()
177
+ }
178
+ }
179
+
180
+ @objc public func ready(completion: @escaping (Result?, Error?) -> Void) {
153
181
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is ready.")
182
+ if config.readyTimeout <= 0 {
183
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Ready timeout is set to 0. Automatic rollback is disabled.")
184
+ }
185
+ // Stop the rollback timer
154
186
  stopRollbackTimer()
187
+ // Delete unused bundles
155
188
  if config.autoDeleteBundles {
156
189
  deleteUnusedBundles()
157
190
  }
191
+ // Get the current and previous bundle IDs
192
+ let currentBundleId = getCurrentBundleId()
193
+ let previousBundleId = getPreviousBundleId()
194
+ // Block the rolled back bundle if enabled
195
+ if config.autoBlockRolledBackBundles && rollbackPerformed, let previousBundleId = previousBundleId {
196
+ addBlockedBundleId(previousBundleId)
197
+ }
198
+ // Return the result
199
+ let result = ReadyResult(currentBundleId: currentBundleId, previousBundleId: previousBundleId, rollback: rollbackPerformed)
200
+ completion(result, nil)
201
+ // Set the new previous bundle ID
202
+ setPreviousBundleId(bundleId: currentBundleId)
203
+ // Reset the rollback flag
204
+ rollbackPerformed = false
158
205
  }
159
206
 
160
207
  @objc public func reload() {
@@ -164,21 +211,11 @@ import CommonCrypto
164
211
  }
165
212
 
166
213
  @objc public func reset() {
167
- self.setNextCapacitorServerPathToDefaultWebAssetDir()
214
+ self.setNextBundleById(nil)
168
215
  }
169
216
 
170
- @objc public func setBundle(_ options: SetBundleOptions, completion: @escaping (Error?) -> Void) {
171
- let bundleId = options.getBundleId()
172
-
173
- // Check if the bundle already exists
174
- if !hasBundle(bundleId: bundleId) {
175
- let error = CustomError.bundleNotFound
176
- completion(error)
177
- return
178
- }
179
-
180
- setNextBundle(bundleId: bundleId)
181
- completion(nil)
217
+ @objc public func resetConfig() {
218
+ preferences.setAppId(nil)
182
219
  }
183
220
 
184
221
  @objc public func setChannel(_ options: SetChannelOptions, completion: @escaping (Error?) -> Void) {
@@ -188,6 +225,11 @@ import CommonCrypto
188
225
  completion(nil)
189
226
  }
190
227
 
228
+ @objc public func setConfig(_ options: SetConfigOptions) {
229
+ let appId = options.getAppId()
230
+ preferences.setAppId(appId)
231
+ }
232
+
191
233
  @objc public func setCustomId(_ options: SetCustomIdOptions, completion: @escaping (Error?) -> Void) {
192
234
  let customId = options.getCustomId()
193
235
 
@@ -199,8 +241,8 @@ import CommonCrypto
199
241
  let bundleId = options.getBundleId()
200
242
 
201
243
  if let bundleId = bundleId {
202
- if hasBundle(bundleId: bundleId) {
203
- setNextBundle(bundleId: bundleId)
244
+ if hasBundleById(bundleId) {
245
+ setNextBundleById(bundleId)
204
246
  } else {
205
247
  let error = CustomError.bundleNotFound
206
248
  completion(error)
@@ -214,6 +256,14 @@ import CommonCrypto
214
256
  }
215
257
 
216
258
  @objc public func sync(_ options: SyncOptions) async throws -> SyncResult {
259
+ if syncInProgress {
260
+ throw CustomError.syncInProgress
261
+ }
262
+ syncInProgress = true
263
+ defer {
264
+ syncInProgress = false
265
+ }
266
+
217
267
  let channel = options.getChannel()
218
268
  // Fetch the latest bundle
219
269
  let fetchLatestBundleOptions = FetchLatestBundleOptions(channel: channel)
@@ -223,14 +273,21 @@ import CommonCrypto
223
273
  }
224
274
  let artifactType = response.artifactType
225
275
  let latestBundleId = response.bundleId
276
+ let checksum = response.checksum
277
+ let signature = response.signature
226
278
  let downloadUrl = response.url
279
+ // Check if the bundle is blocked
280
+ if isBlockedBundleId(latestBundleId) {
281
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Bundle is blocked and will not be downloaded.")
282
+ return SyncResult(nextBundleId: nil)
283
+ }
227
284
  // Check if bundle already exists
228
- if hasBundle(bundleId: latestBundleId) {
285
+ if hasBundleById(latestBundleId) {
229
286
  var nextBundleId: String?
230
287
  let currentBundleId = self.getCurrentBundleId()
231
288
  if latestBundleId != currentBundleId {
232
289
  // Set the next bundle
233
- setNextBundle(bundleId: latestBundleId)
290
+ setNextBundleById(latestBundleId)
234
291
  nextBundleId = latestBundleId
235
292
  }
236
293
  return SyncResult(nextBundleId: nextBundleId)
@@ -239,10 +296,10 @@ import CommonCrypto
239
296
  if artifactType == .manifest {
240
297
  try await downloadBundleOfTypeManifest(bundleId: latestBundleId, url: downloadUrl)
241
298
  } else {
242
- try await downloadBundleOfTypeZip(bundleId: latestBundleId, url: downloadUrl, checksum: nil)
299
+ try await downloadBundleOfTypeZip(bundleId: latestBundleId, checksum: checksum, signature: signature, url: downloadUrl)
243
300
  }
244
301
  // Set the next bundle
245
- setNextBundle(bundleId: latestBundleId)
302
+ setNextBundleById(latestBundleId)
246
303
  return SyncResult(nextBundleId: latestBundleId)
247
304
  }
248
305
 
@@ -271,12 +328,12 @@ import CommonCrypto
271
328
  try self.addBundle(bundleId: bundleId, directory: unzippedDirectory)
272
329
  }
273
330
 
274
- private func buildCapacitorServerPathFor(bundleId: String) -> String {
331
+ private func buildCapacitorServerPathFor(bundleId: String?) -> String {
275
332
  let path: String
276
- if bundleId == "public" {
277
- path = Bundle.main.bundleURL.appendingPathComponent(bundleId).path
278
- } else {
333
+ if let bundleId = bundleId {
279
334
  path = buildBundlePathFor(bundleId: bundleId)
335
+ } else {
336
+ path = Bundle.main.bundleURL.appendingPathComponent(defaultWebAssetDir).path
280
337
  }
281
338
  return path
282
339
  }
@@ -290,32 +347,41 @@ import CommonCrypto
290
347
  return url
291
348
  }
292
349
 
293
- private func copyCurrentBundleFile(href: String, toDirectory: URL) throws {
294
- guard let currentBundleId = getCurrentBundleId() else {
295
- return
296
- }
297
-
298
- let destination = toDirectory.appendingPathComponent(href)
350
+ private func copyCurrentBundleFile(fileToCopy: ManifestItem, toDirectory: URL) throws {
351
+ let currentBundleId = getCurrentBundleId()
352
+ let destination = toDirectory.appendingPathComponent(fileToCopy.href)
299
353
  let parentDirectory = destination.deletingLastPathComponent()
300
354
  try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
301
355
 
302
356
  let sourceURL: URL
303
- if currentBundleId == defaultWebAssetDir {
304
- guard let file = Bundle.main.url(forResource: href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
305
- return
357
+ if let currentBundleId = currentBundleId {
358
+ sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(fileToCopy.href)
359
+ } else {
360
+ guard let file = Bundle.main.url(forResource: fileToCopy.href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
361
+ // If the file does not exist in the current bundle, throw an error
362
+ // We can use CustomError.unknown here since this error will not be handled by the user
363
+ throw CustomError.unknown
306
364
  }
307
365
  sourceURL = file
308
- } else {
309
- sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(href)
310
366
  }
311
367
 
312
368
  try FileManager.default.copyItem(at: sourceURL, to: destination)
313
369
  }
314
370
 
315
- private func copyCurrentBundleFiles(hrefs: [String], toDirectory: URL) throws {
316
- for href in hrefs {
317
- try copyCurrentBundleFile(href: href, toDirectory: toDirectory)
371
+ private func copyCurrentBundleFilesAndReturnFailures(
372
+ filesToCopy: [ManifestItem],
373
+ toDirectory: URL
374
+ ) -> [ManifestItem] {
375
+ var missingItems = [ManifestItem]()
376
+ for fileToCopy in filesToCopy {
377
+ let success = tryCopyCurrentBundleFile(fileToCopy: fileToCopy, toDirectory: toDirectory)
378
+ if !success {
379
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to copy file: \(fileToCopy.href)")
380
+ // If the file could not be copied, add it to the list of missing items
381
+ missingItems.append(fileToCopy)
382
+ }
318
383
  }
384
+ return missingItems
319
385
  }
320
386
 
321
387
  private func createBundlesDirectory() {
@@ -336,26 +402,26 @@ import CommonCrypto
336
402
  return temporaryDirectory
337
403
  }
338
404
 
339
- private func deleteBundle(bundleId: String) throws {
405
+ private func deleteBundleById(_ bundleId: String) throws {
340
406
  // Delete the bundle directory
341
407
  let path = buildBundlePathFor(bundleId: bundleId)
342
408
  try FileManager.default.removeItem(atPath: path)
343
409
  // Reset the next bundle if it is the deleted bundle
344
410
  let nextBundleId = getNextBundleId()
345
411
  if bundleId == nextBundleId {
346
- setNextCapacitorServerPathToDefaultWebAssetDir()
412
+ setNextBundleById(nil)
347
413
  }
348
414
  }
349
415
 
350
416
  private func deleteUnusedBundles() {
351
- let bundleIds = getBundleIds()
417
+ let bundleIds = getDownloadedBundleIds()
352
418
  let currentBundleId = getCurrentBundleId()
353
419
  let nextBundleId = getNextBundleId()
354
420
 
355
421
  for bundleId in bundleIds {
356
422
  if bundleId != currentBundleId && bundleId != nextBundleId {
357
423
  do {
358
- try deleteBundle(bundleId: bundleId)
424
+ try deleteBundleById(bundleId)
359
425
  } catch {
360
426
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete bundle with id: \(bundleId)")
361
427
  }
@@ -363,12 +429,12 @@ import CommonCrypto
363
429
  }
364
430
  }
365
431
 
366
- private func downloadAndVerifyFile(url: String, file: URL, checksum: String?) async throws {
432
+ private func downloadAndVerifyFile(url: String, file: URL, checksum: String?, signature: String?, callback: ((Progress) -> Void)?) async throws {
367
433
  let destination: DownloadRequest.Destination = { _, _ in
368
434
  return (file, [.createIntermediateDirectories])
369
435
  }
370
436
  let urlComponents = URLComponents(string: url)!
371
- let result = try await httpClient.download(url: urlComponents.asURL(), destination: destination)
437
+ let result = try await httpClient.download(url: urlComponents.asURL(), destination: destination, callback: callback)
372
438
  if let error = result.error {
373
439
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to download file: \(error)")
374
440
  if let urlError = error.underlyingError as? URLError {
@@ -381,31 +447,59 @@ import CommonCrypto
381
447
  guard let response = result.response else {
382
448
  throw CustomError.unknown
383
449
  }
384
- let checksum = checksum == nil ? LiveUpdateHttpClient.getChecksumFromResponse(response: response) : checksum
385
- let signature = LiveUpdateHttpClient.getSignatureFromResponse(response: response)
450
+ let checksum = checksum ?? LiveUpdateHttpClient.getChecksumFromResponse(response: response)
451
+ let signature = signature ?? LiveUpdateHttpClient.getSignatureFromResponse(response: response)
386
452
  try verifyFile(url: file, checksum: checksum, signature: signature)
387
453
  }
388
454
 
389
- private func downloadBundleFile(baseUrl: String, href: String, directory: URL) async throws -> URL {
455
+ private func downloadBundleFile(baseUrl: String, href: String, directory: URL, callback: ((Progress) -> Void)?) async throws -> URL {
390
456
  let fileURL = directory.appendingPathComponent(href)
391
457
  var parameters = [String: String]()
392
458
  parameters["href"] = href
393
459
  var urlComponents = URLComponents(string: baseUrl)!
394
460
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
395
461
  let url = urlComponents.string!
396
- try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil)
462
+ try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil, signature: nil, callback: callback)
397
463
  return fileURL
398
464
  }
399
465
 
400
- private func downloadBundleFiles(url: String, hrefs: [String], directory: URL) async throws {
466
+ private func downloadBundleFiles(url: String, filesToDownload: [ManifestItem], directory: URL, callback: ((Progress) -> Void)?) async throws {
467
+ let totalBytesToDownload = Int64(filesToDownload.map { $0.sizeInBytes }.reduce(0, +))
468
+ actor TotalBytesDownloaded {
469
+ var value: Int64 = 0
470
+ func add(_ amount: Int64) {
471
+ value += amount
472
+ }
473
+ }
474
+ let totalBytesDownloaded = TotalBytesDownloaded()
401
475
  try await withThrowingTaskGroup(of: Void.self) { group in
402
- for href in hrefs {
476
+ for fileToDownload in filesToDownload {
403
477
  group.addTask {
404
- _ = try await self.downloadBundleFile(baseUrl: url, href: href, directory: directory)
478
+ _ = try await self.downloadBundleFile(baseUrl: url, href: fileToDownload.href, directory: directory, callback: { progress in
479
+ Task {
480
+ // Progress is only reported if the file is downloaded in chunks
481
+ if let callback = callback {
482
+ let totalBytesDownloaded = await totalBytesDownloaded.value
483
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: progress.completedUnitCount + totalBytesDownloaded)
484
+ callback(totalProgress)
485
+ }
486
+ }
487
+ })
488
+ // Emit the current progress in case the file was not downloaded in chunks
489
+ if let callback = callback {
490
+ await totalBytesDownloaded.add(Int64(fileToDownload.sizeInBytes))
491
+ let totalBytesDownloaded = await totalBytesDownloaded.value
492
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: totalBytesDownloaded)
493
+ callback(totalProgress)
494
+ }
405
495
  }
406
496
  }
407
-
408
497
  try await group.waitForAll()
498
+ // Call the callback one last time to make sure the progress is at 100%
499
+ if let callback = callback {
500
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: totalBytesToDownload)
501
+ callback(totalProgress)
502
+ }
409
503
  }
410
504
  }
411
505
 
@@ -413,7 +507,7 @@ import CommonCrypto
413
507
  // Create a temporary directory
414
508
  let temporaryDirectory = try createTemporaryDirectory()
415
509
  // Download the latest manifest
416
- let latestManifestFile = try await downloadBundleFile(baseUrl: url, href: manifestFileName, directory: temporaryDirectory)
510
+ let latestManifestFile = try await downloadBundleFile(baseUrl: url, href: manifestFileName, directory: temporaryDirectory, callback: nil)
417
511
  let latestManifest = try loadManifest(file: latestManifestFile)
418
512
  // Load the current manifest
419
513
  let currentManifest = try loadCurrentManifest()
@@ -427,18 +521,28 @@ import CommonCrypto
427
521
  itemsToDownload.append(contentsOf: latestManifest.items)
428
522
  }
429
523
  // Copy the files
430
- try self.copyCurrentBundleFiles(hrefs: itemsToCopy.map { $0.href }, toDirectory: temporaryDirectory)
524
+ let missingItems = copyCurrentBundleFilesAndReturnFailures(filesToCopy: itemsToCopy, toDirectory: temporaryDirectory)
525
+ // If items could not be copied, add them to the list of items to download
526
+ if !missingItems.isEmpty {
527
+ itemsToDownload.append(contentsOf: missingItems)
528
+ }
431
529
  // Download the files
432
- try await self.downloadBundleFiles(url: url, hrefs: itemsToDownload.map { $0.href }, directory: temporaryDirectory)
530
+ try await self.downloadBundleFiles(url: url, filesToDownload: itemsToDownload, directory: temporaryDirectory, callback: { progress in
531
+ let event = DownloadBundleProgressEvent(bundleId: bundleId, downloadedBytes: progress.completedUnitCount, totalBytes: progress.totalUnitCount)
532
+ self.notifyDownloadBundleProgressListeners(event)
533
+ })
433
534
  // Add the bundle
434
535
  try await addBundleOfTypeManifest(bundleId: bundleId, directory: temporaryDirectory)
435
536
  }
436
537
 
437
- private func downloadBundleOfTypeZip(bundleId: String, url: String, checksum: String?) async throws {
538
+ private func downloadBundleOfTypeZip(bundleId: String, checksum: String?, signature: String?, url: String) async throws {
438
539
  let timestamp = String(Int(Date().timeIntervalSince1970))
439
540
  let temporaryZipFileUrl = self.cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
440
541
  // Download the bundle
441
- try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum)
542
+ try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum, signature: signature, callback: { progress in
543
+ let event = DownloadBundleProgressEvent(bundleId: bundleId, downloadedBytes: progress.completedUnitCount, totalBytes: progress.totalUnitCount)
544
+ self.notifyDownloadBundleProgressListeners(event)
545
+ })
442
546
  // Add the bundle
443
547
  try await addBundleOfTypeZip(bundleId: bundleId, zipFile: temporaryZipFileUrl)
444
548
  }
@@ -455,7 +559,7 @@ import CommonCrypto
455
559
  parameters["osVersion"] = await UIDevice.current.systemVersion
456
560
  parameters["platform"] = "1"
457
561
  parameters["pluginVersion"] = LiveUpdatePlugin.version
458
- var urlComponents = URLComponents(string: "https://\(host)/v1/apps/\(config.appId ?? "")/bundles/latest")!
562
+ var urlComponents = URLComponents(string: "https://\(config.serverDomain)/v1/apps/\(getAppId() ?? "")/bundles/latest")!
459
563
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
460
564
  let url = try urlComponents.asURL()
461
565
  CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle: ", url)
@@ -478,7 +582,7 @@ import CommonCrypto
478
582
  }
479
583
  }
480
584
 
481
- private func getBundleIds() -> [String] {
585
+ private func getDownloadedBundleIds() -> [String] {
482
586
  let url = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
483
587
  do {
484
588
  let pathExists = FileManager.default.fileExists(atPath: url.path)
@@ -492,6 +596,13 @@ import CommonCrypto
492
596
  }
493
597
  }
494
598
 
599
+ private func getAppId() -> String? {
600
+ if let appId = preferences.getAppId() {
601
+ return appId
602
+ }
603
+ return config.appId
604
+ }
605
+
495
606
  private func getChannel() -> String? {
496
607
  var channel: String?
497
608
  if let _ = config.defaultChannel {
@@ -517,12 +628,16 @@ import CommonCrypto
517
628
  return digest.map { String(format: "%02hhx", $0) }.joined()
518
629
  }
519
630
 
520
- /// - Returns: The current bundle ID (`public` for the built-in bundle) or `nil` if no view controller was found.
631
+ /// - Returns: The current bundle ID or `nil` if no view controller was found (should never happen) or the default bundle is in use.
521
632
  private func getCurrentBundleId() -> String? {
522
633
  guard let path = getCurrentCapacitorServerPath() else {
523
634
  return nil
524
635
  }
525
- return URL(fileURLWithPath: path).lastPathComponent
636
+ let bundleId = URL(fileURLWithPath: path).lastPathComponent
637
+ if bundleId == defaultWebAssetDir {
638
+ return nil
639
+ }
640
+ return bundleId
526
641
  }
527
642
 
528
643
  /// - Returns: The path to the current bundle directory or `nil` if no view controller was found.
@@ -538,21 +653,30 @@ import CommonCrypto
538
653
  return deviceId.lowercased()
539
654
  }
540
655
 
541
- /// - Returns: The next bundle ID (`public` for the built-in bundle).
542
- private func getNextBundleId() -> String {
656
+ /// - Returns: The next bundle ID or `nil` if the default bundle will be used.
657
+ private func getNextBundleId() -> String? {
543
658
  let path = getNextCapacitorServerPath()
544
- return URL(fileURLWithPath: path).lastPathComponent
659
+ let bundleId = URL(fileURLWithPath: path).lastPathComponent
660
+ if bundleId == defaultWebAssetDir {
661
+ return nil
662
+ }
663
+ return bundleId
545
664
  }
546
665
 
547
666
  /// - Returns: The absolute path to the next bundle directory.
548
667
  private func getNextCapacitorServerPath() -> String {
549
- let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
668
+ let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: nil)
550
669
  if let path = KeyValueStore.standard[self.defaultServerPathKey, as: String.self] {
551
670
  return path.isEmpty ? defaultCapacitorServerPath : path
552
671
  }
553
672
  return defaultCapacitorServerPath
554
673
  }
555
674
 
675
+ /// - Returns: The previous bundle ID or `nil` if the default bundle was used.
676
+ private func getPreviousBundleId() -> String? {
677
+ return preferences.getPreviousBundleId()
678
+ }
679
+
556
680
  private func getVersionCode() -> String {
557
681
  guard let appVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
558
682
  return ""
@@ -567,27 +691,24 @@ import CommonCrypto
567
691
  return appVersionName
568
692
  }
569
693
 
570
- private func hasBundle(bundleId: String) -> Bool {
694
+ private func hasBundleById(_ bundleId: String) -> Bool {
571
695
  let path = buildBundlePathFor(bundleId: bundleId)
572
696
  return FileManager.default.fileExists(atPath: path)
573
697
  }
574
698
 
575
699
  private func loadCurrentManifest() throws -> Manifest? {
576
- guard let currentBundleId = getCurrentBundleId() else {
577
- return nil
578
- }
579
- if currentBundleId == defaultWebAssetDir {
580
- let files = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: defaultWebAssetDir) ?? []
581
- let manifestFileUrl = files.first { $0.lastPathComponent == manifestFileName }
582
- if let manifestFileUrl = manifestFileUrl {
700
+ if let currentBundleId = getCurrentBundleId() {
701
+ let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
702
+ let manifestFileExists = FileManager.default.fileExists(atPath: manifestFileUrl.path)
703
+ if manifestFileExists {
583
704
  return try loadManifest(file: manifestFileUrl)
584
705
  } else {
585
706
  return nil
586
707
  }
587
708
  } else {
588
- let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
589
- let manifestFileExists = FileManager.default.fileExists(atPath: manifestFileUrl.path)
590
- if manifestFileExists {
709
+ let files = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: defaultWebAssetDir) ?? []
710
+ let manifestFileUrl = files.first { $0.lastPathComponent == manifestFileName }
711
+ if let manifestFileUrl = manifestFileUrl {
591
712
  return try loadManifest(file: manifestFileUrl)
592
713
  } else {
593
714
  return nil
@@ -602,21 +723,48 @@ import CommonCrypto
602
723
  return Manifest(items: items)
603
724
  }
604
725
 
605
- private func rollback() {
606
- if getCurrentBundleId() == defaultWebAssetDir {
607
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Default bundle is already in use.")
726
+ private func notifyDownloadBundleProgressListeners(_ event: DownloadBundleProgressEvent) {
727
+ plugin.notifyDownloadBundleProgressListeners(event)
728
+ }
729
+
730
+ private func performAutoUpdate() {
731
+ // Check if enough time has passed since the last check
732
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
733
+ if lastAutoUpdateCheckTimestamp > 0 && (now - lastAutoUpdateCheckTimestamp) < autoUpdateIntervalMs {
734
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Auto-update skipped. Last check was less than 15 minutes ago.")
608
735
  return
609
736
  }
610
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Rolling back to default bundle.")
611
- setNextCapacitorServerPathToDefaultWebAssetDir()
612
- setCurrentCapacitorServerPathToDefaultWebAssetDir()
737
+
738
+ // Update the timestamp
739
+ lastAutoUpdateCheckTimestamp = now
740
+
741
+ // Run sync in background task
742
+ Task {
743
+ do {
744
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Auto-update started.")
745
+ let options = SyncOptions(channel: nil)
746
+ _ = try await sync(options)
747
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Auto-update completed successfully.")
748
+ } catch {
749
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Auto-update failed: ", error.localizedDescription)
750
+ }
751
+ }
613
752
  }
614
753
 
615
- private func saveCurrentBundleShortVersionStringAndBundleVersion() {
616
- let currentBundleShortVersionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
617
- preferences.setLastBundleShortVersionString(currentBundleShortVersionString)
618
- let currentBundleVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
619
- preferences.setLastBundleVersion(currentBundleVersion)
754
+ private func rollback() {
755
+ // Set the rollback flag
756
+ rollbackPerformed = true
757
+ // Set the new previous bundle ID
758
+ let currentBundleId = getCurrentBundleId()
759
+ setPreviousBundleId(bundleId: currentBundleId)
760
+ // Perform the rollback
761
+ if let _ = currentBundleId {
762
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Rolling back to default bundle.")
763
+ setNextBundleById(nil)
764
+ setCurrentBundleById(nil)
765
+ } else {
766
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Default bundle is already in use.")
767
+ }
620
768
  }
621
769
 
622
770
  private func searchIndexHtmlFile(url: URL) -> URL? {
@@ -646,21 +794,74 @@ import CommonCrypto
646
794
  return nil
647
795
  }
648
796
 
797
+ /// - Parameter bundleId: The bundle ID to set as the current bundle. If `nil`, the default bundle will be used.
798
+ private func setCurrentBundleById(_ bundleId: String?) {
799
+ let path = buildCapacitorServerPathFor(bundleId: bundleId)
800
+ setCurrentCapacitorServerPath(path: path)
801
+ }
802
+
649
803
  private func setCurrentCapacitorServerPath(path: String) {
650
804
  guard let viewController = self.plugin.bridge?.viewController as? CAPBridgeViewController else {
651
805
  return
652
806
  }
653
807
  viewController.setServerBasePath(path: path)
808
+ // Notify listeners
809
+ notifyReloadedListeners()
654
810
  }
655
811
 
656
- private func setCurrentCapacitorServerPathToDefaultWebAssetDir() {
657
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
658
- setCurrentCapacitorServerPath(path: path)
659
- }
660
-
661
- private func setNextBundle(bundleId: String) {
812
+ /// - Parameter bundleId: The bundle ID to set as the next bundle. If `nil`, the default bundle will be used.
813
+ private func setNextBundleById(_ bundleId: String?) {
662
814
  let path = buildCapacitorServerPathFor(bundleId: bundleId)
663
815
  setNextCapacitorServerPath(path: path)
816
+
817
+ // Notify listeners
818
+ notifyNextBundleSetListeners(bundleId)
819
+ }
820
+
821
+ private func notifyNextBundleSetListeners(_ bundleId: String?) {
822
+ let event = NextBundleSetEvent(bundleId: bundleId)
823
+ plugin.notifyNextBundleSetListeners(event)
824
+ }
825
+
826
+ private func notifyReloadedListeners() {
827
+ plugin.notifyReloadedListeners()
828
+ }
829
+
830
+ private func addBlockedBundleId(_ bundleId: String) {
831
+ var blockedList: [String] = []
832
+
833
+ // Parse existing blocked IDs
834
+ if let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty {
835
+ blockedList = blockedIds.split(separator: ",").map(String.init)
836
+ }
837
+
838
+ // Skip if already blocked
839
+ if blockedList.contains(bundleId) {
840
+ return
841
+ }
842
+
843
+ // Remove oldest if limit reached
844
+ if blockedList.count >= 100 {
845
+ blockedList.removeFirst()
846
+ }
847
+
848
+ // Add new bundle
849
+ blockedList.append(bundleId)
850
+
851
+ // Save back to preferences
852
+ let newBlockedIds = blockedList.joined(separator: ",")
853
+ preferences.setBlockedBundleIds(newBlockedIds)
854
+
855
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Bundle blocked: ", bundleId)
856
+ }
857
+
858
+ private func isBlockedBundleId(_ bundleId: String) -> Bool {
859
+ guard let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty else {
860
+ return false
861
+ }
862
+
863
+ let blockedList = blockedIds.split(separator: ",").map(String.init)
864
+ return blockedList.contains(bundleId)
664
865
  }
665
866
 
666
867
  private func setNextCapacitorServerPath(path: String) {
@@ -673,12 +874,32 @@ import CommonCrypto
673
874
  }
674
875
  }
675
876
 
676
- private func setNextCapacitorServerPathToDefaultWebAssetDir() {
677
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
678
- setNextCapacitorServerPath(path: path)
877
+ private func checkAndResetConfigIfVersionChanged() {
878
+ let currentVersionCode = getVersionCode()
879
+ let currentVersionName = getVersionName()
880
+ let lastVersionCode = preferences.getLastVersionCode()
881
+ let lastVersionName = preferences.getLastVersionName()
882
+
883
+ if lastVersionCode == nil || lastVersionName == nil || lastVersionCode != currentVersionCode || lastVersionName != currentVersionName {
884
+ CAPLog.print(
885
+ "[", LiveUpdatePlugin.tag, "] ",
886
+ "App version changed (last: \(lastVersionName ?? "nil")/\(lastVersionCode ?? "nil"), current: \(currentVersionName)/\(currentVersionCode)), resetting config."
887
+ )
888
+ resetConfig()
889
+ preferences.setLastVersionCode(currentVersionCode)
890
+ preferences.setLastVersionName(currentVersionName)
891
+ }
892
+ }
893
+
894
+ /// - Parameter bundleId: The bundle ID to save as the previous bundle ID. If `nil`, the value will be removed.
895
+ private func setPreviousBundleId(bundleId: String?) {
896
+ preferences.setPreviousBundleId(bundleId)
679
897
  }
680
898
 
681
899
  private func startRollbackTimer() {
900
+ guard config.readyTimeout > 0 else {
901
+ return
902
+ }
682
903
  stopRollbackTimer()
683
904
  rollbackDispatchWorkItem = DispatchWorkItem { [weak self] in
684
905
  self?.rollback()
@@ -690,6 +911,18 @@ import CommonCrypto
690
911
  rollbackDispatchWorkItem?.cancel()
691
912
  }
692
913
 
914
+ private func tryCopyCurrentBundleFile(
915
+ fileToCopy: ManifestItem,
916
+ toDirectory: URL
917
+ ) -> Bool {
918
+ do {
919
+ try copyCurrentBundleFile(fileToCopy: fileToCopy, toDirectory: toDirectory)
920
+ return true
921
+ } catch {
922
+ return false
923
+ }
924
+ }
925
+
693
926
  private func unzipFile(zipFile: URL) -> URL {
694
927
  let destinationDirectory = zipFile.deletingPathExtension()
695
928
  SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
@@ -785,27 +1018,6 @@ import CommonCrypto
785
1018
  }
786
1019
  return verificationResult
787
1020
  }
788
-
789
- private func wasUpdated() -> Bool {
790
- guard let lastBundleShortVersionString = preferences.getLastBundleShortVersionString() else {
791
- // If the last bundle short version is not set, the app may have been updated
792
- return true
793
- }
794
- guard let lastBundleVersionString = preferences.getLastBundleVersion() else {
795
- // If the last bundle version is not set, the app may have been updated
796
- return true
797
- }
798
- guard let currentBundleShortVersionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else {
799
- // If the current bundle short version is not set, return true for safety reasons
800
- return true
801
- }
802
- guard let currentBundleVersionString = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
803
- // If the current bundle version is not set, return true for safety reasons
804
- return true
805
- }
806
- // `CFBundleVersion` is not unique across different versions of the app
807
- return lastBundleShortVersionString + "_" + lastBundleVersionString != currentBundleShortVersionString + "_" + currentBundleVersionString
808
- }
809
1021
  }
810
1022
 
811
1023
  extension DispatchQueue {
@@ -820,3 +1032,10 @@ extension DispatchQueue {
820
1032
  }
821
1033
  }
822
1034
  }
1035
+
1036
+ extension Progress {
1037
+ convenience init(totalUnitCount: Int64, completedUnitCount: Int64) {
1038
+ self.init(totalUnitCount: totalUnitCount)
1039
+ self.completedUnitCount = completedUnitCount
1040
+ }
1041
+ }