@capawesome/capacitor-live-update 6.8.0 → 6.9.0

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