@capawesome/capacitor-live-update 6.8.0 → 7.0.0-dev.2633ff2.1765953041

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 (54) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +3 -3
  2. package/Package.swift +32 -0
  3. package/README.md +438 -86
  4. package/android/build.gradle +12 -12
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +754 -279
  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 +210 -67
  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 +11 -2
  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/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/FetchLatestBundleResult.java +8 -0
  16. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +27 -0
  17. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  18. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +27 -0
  19. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/IsSyncingResult.java +26 -0
  20. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  21. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  22. package/dist/docs.json +597 -102
  23. package/dist/esm/definitions.d.ts +338 -39
  24. package/dist/esm/definitions.js.map +1 -1
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/index.js.map +1 -1
  27. package/dist/esm/web.d.ts +9 -2
  28. package/dist/esm/web.js +21 -0
  29. package/dist/esm/web.js.map +1 -1
  30. package/dist/plugin.cjs.js +22 -3
  31. package/dist/plugin.cjs.js.map +1 -1
  32. package/dist/plugin.js +22 -3
  33. package/dist/plugin.js.map +1 -1
  34. package/ios/Plugin/Classes/Events/DownloadBundleProgressEvent.swift +25 -0
  35. package/ios/Plugin/Classes/{Results/GetBundleResult.swift → Events/NextBundleSetEvent.swift} +3 -3
  36. package/ios/Plugin/Classes/Options/SetConfigOptions.swift +14 -0
  37. package/ios/Plugin/Classes/Options/SyncOptions.swift +4 -0
  38. package/ios/Plugin/Classes/Results/{FetchLatestBundle.swift → FetchLatestBundleResult.swift} +6 -1
  39. package/ios/Plugin/Classes/Results/GetBlockedBundlesResult.swift +16 -0
  40. package/ios/Plugin/Classes/Results/GetConfigResult.swift +19 -0
  41. package/ios/Plugin/Classes/Results/GetDownloadedBundlesResult.swift +16 -0
  42. package/ios/Plugin/Classes/Results/IsSyncingResult.swift +16 -0
  43. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  44. package/ios/Plugin/LiveUpdate.swift +365 -149
  45. package/ios/Plugin/LiveUpdateConfig.swift +4 -4
  46. package/ios/Plugin/LiveUpdateHttpClient.swift +10 -4
  47. package/ios/Plugin/LiveUpdatePlugin.swift +136 -34
  48. package/ios/Plugin/LiveUpdatePreferences.swift +54 -12
  49. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +1 -0
  50. package/package.json +19 -20
  51. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetBundleOptions.java +0 -18
  52. package/ios/Plugin/Classes/Options/SetBundleOptions.swift +0 -14
  53. package/ios/Plugin/LiveUpdatePlugin.h +0 -10
  54. 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)
@@ -69,7 +72,7 @@ import CommonCrypto
69
72
  let url = options.getUrl()
70
73
 
71
74
  // Check if the bundle already exists
72
- if hasBundle(bundleId: bundleId) {
75
+ if hasBundleById(bundleId) {
73
76
  throw CustomError.bundleAlreadyExists
74
77
  }
75
78
 
@@ -77,41 +80,51 @@ import CommonCrypto
77
80
  if artifactType == .manifest {
78
81
  try await downloadBundleOfTypeManifest(bundleId: bundleId, url: url)
79
82
  } else {
80
- try await downloadBundleOfTypeZip(bundleId: bundleId, url: url, checksum: checksum, signature: signature)
83
+ try await downloadBundleOfTypeZip(bundleId: bundleId, checksum: checksum, signature: signature, url: url)
81
84
  }
82
85
  }
83
86
 
84
87
  @objc public func fetchLatestBundle(_ options: FetchLatestBundleOptions) async throws -> FetchLatestBundleResult {
85
88
  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)
89
+ return FetchLatestBundleResult(artifactType: response?.artifactType, bundleId: response?.bundleId, checksum: response?.checksum, customProperties: response?.customProperties, downloadUrl: response?.url, signature: response?.signature)
87
90
  }
88
91
 
89
- @objc public func getBundle(completion: @escaping (Result?, Error?) -> Void) {
90
- var bundleId = getCurrentBundleId()
91
- if bundleId == defaultWebAssetDir {
92
- 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)
93
96
  }
94
- let result = GetBundleResult(bundleId: bundleId)
97
+ let result = GetBlockedBundlesResult(bundleIds: bundleIds)
95
98
  completion(result, nil)
96
99
  }
97
100
 
98
101
  @objc public func getBundles(completion: @escaping (Result?, Error?) -> Void) {
99
- let bundleIds = getBundleIds()
102
+ let bundleIds = getDownloadedBundleIds()
100
103
  let result = GetBundlesResult(bundleIds: bundleIds)
101
104
  completion(result, nil)
102
105
  }
103
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
+
104
113
  @objc public func getChannel(completion: @escaping (Result?, Error?) -> Void) {
105
114
  let channel = getChannel()
106
115
  let result = GetChannelResult(channel: channel)
107
116
  completion(result, nil)
108
117
  }
109
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
+
110
126
  @objc public func getCurrentBundle(completion: @escaping (Result?, Error?) -> Void) {
111
- var bundleId = getCurrentBundleId()
112
- if bundleId == defaultWebAssetDir {
113
- bundleId = nil
114
- }
127
+ let bundleId = getCurrentBundleId()
115
128
  let result = GetCurrentBundleResult(bundleId: bundleId)
116
129
  completion(result, nil)
117
130
  }
@@ -130,10 +143,7 @@ import CommonCrypto
130
143
  }
131
144
 
132
145
  @objc public func getNextBundle(completion: @escaping (Result?, Error?) -> Void) {
133
- var bundleId: String? = getNextBundleId()
134
- if bundleId == defaultWebAssetDir {
135
- bundleId = nil
136
- }
146
+ let bundleId: String? = getNextBundleId()
137
147
  let result = GetNextBundleResult(bundleId: bundleId)
138
148
  completion(result, nil)
139
149
  }
@@ -150,12 +160,48 @@ import CommonCrypto
150
160
  completion(result, nil)
151
161
  }
152
162
 
153
- @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) {
154
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
155
186
  stopRollbackTimer()
187
+ // Delete unused bundles
156
188
  if config.autoDeleteBundles {
157
189
  deleteUnusedBundles()
158
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
159
205
  }
160
206
 
161
207
  @objc public func reload() {
@@ -165,21 +211,11 @@ import CommonCrypto
165
211
  }
166
212
 
167
213
  @objc public func reset() {
168
- self.setNextCapacitorServerPathToDefaultWebAssetDir()
214
+ self.setNextBundleById(nil)
169
215
  }
170
216
 
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)
217
+ @objc public func resetConfig() {
218
+ preferences.setAppId(nil)
183
219
  }
184
220
 
185
221
  @objc public func setChannel(_ options: SetChannelOptions, completion: @escaping (Error?) -> Void) {
@@ -189,6 +225,11 @@ import CommonCrypto
189
225
  completion(nil)
190
226
  }
191
227
 
228
+ @objc public func setConfig(_ options: SetConfigOptions) {
229
+ let appId = options.getAppId()
230
+ preferences.setAppId(appId)
231
+ }
232
+
192
233
  @objc public func setCustomId(_ options: SetCustomIdOptions, completion: @escaping (Error?) -> Void) {
193
234
  let customId = options.getCustomId()
194
235
 
@@ -200,8 +241,8 @@ import CommonCrypto
200
241
  let bundleId = options.getBundleId()
201
242
 
202
243
  if let bundleId = bundleId {
203
- if hasBundle(bundleId: bundleId) {
204
- setNextBundle(bundleId: bundleId)
244
+ if hasBundleById(bundleId) {
245
+ setNextBundleById(bundleId)
205
246
  } else {
206
247
  let error = CustomError.bundleNotFound
207
248
  completion(error)
@@ -215,6 +256,14 @@ import CommonCrypto
215
256
  }
216
257
 
217
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
+
218
267
  let channel = options.getChannel()
219
268
  // Fetch the latest bundle
220
269
  let fetchLatestBundleOptions = FetchLatestBundleOptions(channel: channel)
@@ -227,13 +276,18 @@ import CommonCrypto
227
276
  let checksum = response.checksum
228
277
  let signature = response.signature
229
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
+ }
230
284
  // Check if bundle already exists
231
- if hasBundle(bundleId: latestBundleId) {
285
+ if hasBundleById(latestBundleId) {
232
286
  var nextBundleId: String?
233
287
  let currentBundleId = self.getCurrentBundleId()
234
288
  if latestBundleId != currentBundleId {
235
289
  // Set the next bundle
236
- setNextBundle(bundleId: latestBundleId)
290
+ setNextBundleById(latestBundleId)
237
291
  nextBundleId = latestBundleId
238
292
  }
239
293
  return SyncResult(nextBundleId: nextBundleId)
@@ -242,10 +296,10 @@ import CommonCrypto
242
296
  if artifactType == .manifest {
243
297
  try await downloadBundleOfTypeManifest(bundleId: latestBundleId, url: downloadUrl)
244
298
  } else {
245
- try await downloadBundleOfTypeZip(bundleId: latestBundleId, url: downloadUrl, checksum: checksum, signature: signature)
299
+ try await downloadBundleOfTypeZip(bundleId: latestBundleId, checksum: checksum, signature: signature, url: downloadUrl)
246
300
  }
247
301
  // Set the next bundle
248
- setNextBundle(bundleId: latestBundleId)
302
+ setNextBundleById(latestBundleId)
249
303
  return SyncResult(nextBundleId: latestBundleId)
250
304
  }
251
305
 
@@ -274,12 +328,12 @@ import CommonCrypto
274
328
  try self.addBundle(bundleId: bundleId, directory: unzippedDirectory)
275
329
  }
276
330
 
277
- private func buildCapacitorServerPathFor(bundleId: String) -> String {
331
+ private func buildCapacitorServerPathFor(bundleId: String?) -> String {
278
332
  let path: String
279
- if bundleId == "public" {
280
- path = Bundle.main.bundleURL.appendingPathComponent(bundleId).path
281
- } else {
333
+ if let bundleId = bundleId {
282
334
  path = buildBundlePathFor(bundleId: bundleId)
335
+ } else {
336
+ path = Bundle.main.bundleURL.appendingPathComponent(defaultWebAssetDir).path
283
337
  }
284
338
  return path
285
339
  }
@@ -293,32 +347,41 @@ import CommonCrypto
293
347
  return url
294
348
  }
295
349
 
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)
350
+ private func copyCurrentBundleFile(fileToCopy: ManifestItem, toDirectory: URL) throws {
351
+ let currentBundleId = getCurrentBundleId()
352
+ let destination = toDirectory.appendingPathComponent(fileToCopy.href)
302
353
  let parentDirectory = destination.deletingLastPathComponent()
303
354
  try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
304
355
 
305
356
  let sourceURL: URL
306
- if currentBundleId == defaultWebAssetDir {
307
- guard let file = Bundle.main.url(forResource: href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
308
- 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
309
364
  }
310
365
  sourceURL = file
311
- } else {
312
- sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(href)
313
366
  }
314
367
 
315
368
  try FileManager.default.copyItem(at: sourceURL, to: destination)
316
369
  }
317
370
 
318
- private func copyCurrentBundleFiles(hrefs: [String], toDirectory: URL) throws {
319
- for href in hrefs {
320
- 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
+ }
321
383
  }
384
+ return missingItems
322
385
  }
323
386
 
324
387
  private func createBundlesDirectory() {
@@ -339,26 +402,26 @@ import CommonCrypto
339
402
  return temporaryDirectory
340
403
  }
341
404
 
342
- private func deleteBundle(bundleId: String) throws {
405
+ private func deleteBundleById(_ bundleId: String) throws {
343
406
  // Delete the bundle directory
344
407
  let path = buildBundlePathFor(bundleId: bundleId)
345
408
  try FileManager.default.removeItem(atPath: path)
346
409
  // Reset the next bundle if it is the deleted bundle
347
410
  let nextBundleId = getNextBundleId()
348
411
  if bundleId == nextBundleId {
349
- setNextCapacitorServerPathToDefaultWebAssetDir()
412
+ setNextBundleById(nil)
350
413
  }
351
414
  }
352
415
 
353
416
  private func deleteUnusedBundles() {
354
- let bundleIds = getBundleIds()
417
+ let bundleIds = getDownloadedBundleIds()
355
418
  let currentBundleId = getCurrentBundleId()
356
419
  let nextBundleId = getNextBundleId()
357
420
 
358
421
  for bundleId in bundleIds {
359
422
  if bundleId != currentBundleId && bundleId != nextBundleId {
360
423
  do {
361
- try deleteBundle(bundleId: bundleId)
424
+ try deleteBundleById(bundleId)
362
425
  } catch {
363
426
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete bundle with id: \(bundleId)")
364
427
  }
@@ -366,12 +429,12 @@ import CommonCrypto
366
429
  }
367
430
  }
368
431
 
369
- private func downloadAndVerifyFile(url: String, file: URL, checksum: String?, signature: String?) async throws {
432
+ private func downloadAndVerifyFile(url: String, file: URL, checksum: String?, signature: String?, callback: ((Progress) -> Void)?) async throws {
370
433
  let destination: DownloadRequest.Destination = { _, _ in
371
434
  return (file, [.createIntermediateDirectories])
372
435
  }
373
436
  let urlComponents = URLComponents(string: url)!
374
- 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)
375
438
  if let error = result.error {
376
439
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to download file: \(error)")
377
440
  if let urlError = error.underlyingError as? URLError {
@@ -389,26 +452,54 @@ import CommonCrypto
389
452
  try verifyFile(url: file, checksum: checksum, signature: signature)
390
453
  }
391
454
 
392
- 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 {
393
456
  let fileURL = directory.appendingPathComponent(href)
394
457
  var parameters = [String: String]()
395
458
  parameters["href"] = href
396
459
  var urlComponents = URLComponents(string: baseUrl)!
397
460
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
398
461
  let url = urlComponents.string!
399
- try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil, signature: nil)
462
+ try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil, signature: nil, callback: callback)
400
463
  return fileURL
401
464
  }
402
465
 
403
- 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()
404
475
  try await withThrowingTaskGroup(of: Void.self) { group in
405
- for href in hrefs {
476
+ for fileToDownload in filesToDownload {
406
477
  group.addTask {
407
- _ = 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
+ }
408
495
  }
409
496
  }
410
-
411
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
+ }
412
503
  }
413
504
  }
414
505
 
@@ -416,7 +507,7 @@ import CommonCrypto
416
507
  // Create a temporary directory
417
508
  let temporaryDirectory = try createTemporaryDirectory()
418
509
  // Download the latest manifest
419
- 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)
420
511
  let latestManifest = try loadManifest(file: latestManifestFile)
421
512
  // Load the current manifest
422
513
  let currentManifest = try loadCurrentManifest()
@@ -430,18 +521,28 @@ import CommonCrypto
430
521
  itemsToDownload.append(contentsOf: latestManifest.items)
431
522
  }
432
523
  // Copy the files
433
- 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
+ }
434
529
  // Download the files
435
- 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
+ })
436
534
  // Add the bundle
437
535
  try await addBundleOfTypeManifest(bundleId: bundleId, directory: temporaryDirectory)
438
536
  }
439
537
 
440
- private func downloadBundleOfTypeZip(bundleId: String, url: String, checksum: String?, signature: String?) async throws {
538
+ private func downloadBundleOfTypeZip(bundleId: String, checksum: String?, signature: String?, url: String) async throws {
441
539
  let timestamp = String(Int(Date().timeIntervalSince1970))
442
540
  let temporaryZipFileUrl = self.cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
443
541
  // Download the bundle
444
- try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum, signature: signature)
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
+ })
445
546
  // Add the bundle
446
547
  try await addBundleOfTypeZip(bundleId: bundleId, zipFile: temporaryZipFileUrl)
447
548
  }
@@ -458,7 +559,7 @@ import CommonCrypto
458
559
  parameters["osVersion"] = await UIDevice.current.systemVersion
459
560
  parameters["platform"] = "1"
460
561
  parameters["pluginVersion"] = LiveUpdatePlugin.version
461
- 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")!
462
563
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
463
564
  let url = try urlComponents.asURL()
464
565
  CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle: ", url)
@@ -481,7 +582,7 @@ import CommonCrypto
481
582
  }
482
583
  }
483
584
 
484
- private func getBundleIds() -> [String] {
585
+ private func getDownloadedBundleIds() -> [String] {
485
586
  let url = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
486
587
  do {
487
588
  let pathExists = FileManager.default.fileExists(atPath: url.path)
@@ -495,6 +596,13 @@ import CommonCrypto
495
596
  }
496
597
  }
497
598
 
599
+ private func getAppId() -> String? {
600
+ if let appId = preferences.getAppId() {
601
+ return appId
602
+ }
603
+ return config.appId
604
+ }
605
+
498
606
  private func getChannel() -> String? {
499
607
  var channel: String?
500
608
  if let _ = config.defaultChannel {
@@ -520,12 +628,16 @@ import CommonCrypto
520
628
  return digest.map { String(format: "%02hhx", $0) }.joined()
521
629
  }
522
630
 
523
- /// - 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.
524
632
  private func getCurrentBundleId() -> String? {
525
633
  guard let path = getCurrentCapacitorServerPath() else {
526
634
  return nil
527
635
  }
528
- return URL(fileURLWithPath: path).lastPathComponent
636
+ let bundleId = URL(fileURLWithPath: path).lastPathComponent
637
+ if bundleId == defaultWebAssetDir {
638
+ return nil
639
+ }
640
+ return bundleId
529
641
  }
530
642
 
531
643
  /// - Returns: The path to the current bundle directory or `nil` if no view controller was found.
@@ -541,21 +653,30 @@ import CommonCrypto
541
653
  return deviceId.lowercased()
542
654
  }
543
655
 
544
- /// - Returns: The next bundle ID (`public` for the built-in bundle).
545
- private func getNextBundleId() -> String {
656
+ /// - Returns: The next bundle ID or `nil` if the default bundle will be used.
657
+ private func getNextBundleId() -> String? {
546
658
  let path = getNextCapacitorServerPath()
547
- return URL(fileURLWithPath: path).lastPathComponent
659
+ let bundleId = URL(fileURLWithPath: path).lastPathComponent
660
+ if bundleId == defaultWebAssetDir {
661
+ return nil
662
+ }
663
+ return bundleId
548
664
  }
549
665
 
550
666
  /// - Returns: The absolute path to the next bundle directory.
551
667
  private func getNextCapacitorServerPath() -> String {
552
- let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
668
+ let defaultCapacitorServerPath = buildCapacitorServerPathFor(bundleId: nil)
553
669
  if let path = KeyValueStore.standard[self.defaultServerPathKey, as: String.self] {
554
670
  return path.isEmpty ? defaultCapacitorServerPath : path
555
671
  }
556
672
  return defaultCapacitorServerPath
557
673
  }
558
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
+
559
680
  private func getVersionCode() -> String {
560
681
  guard let appVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
561
682
  return ""
@@ -570,27 +691,24 @@ import CommonCrypto
570
691
  return appVersionName
571
692
  }
572
693
 
573
- private func hasBundle(bundleId: String) -> Bool {
694
+ private func hasBundleById(_ bundleId: String) -> Bool {
574
695
  let path = buildBundlePathFor(bundleId: bundleId)
575
696
  return FileManager.default.fileExists(atPath: path)
576
697
  }
577
698
 
578
699
  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 {
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 {
586
704
  return try loadManifest(file: manifestFileUrl)
587
705
  } else {
588
706
  return nil
589
707
  }
590
708
  } else {
591
- let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
592
- let manifestFileExists = FileManager.default.fileExists(atPath: manifestFileUrl.path)
593
- 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 {
594
712
  return try loadManifest(file: manifestFileUrl)
595
713
  } else {
596
714
  return nil
@@ -605,21 +723,48 @@ import CommonCrypto
605
723
  return Manifest(items: items)
606
724
  }
607
725
 
608
- private func rollback() {
609
- if getCurrentBundleId() == defaultWebAssetDir {
610
- 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.")
611
735
  return
612
736
  }
613
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Rolling back to default bundle.")
614
- setNextCapacitorServerPathToDefaultWebAssetDir()
615
- 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
+ }
616
752
  }
617
753
 
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)
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
+ }
623
768
  }
624
769
 
625
770
  private func searchIndexHtmlFile(url: URL) -> URL? {
@@ -649,21 +794,74 @@ import CommonCrypto
649
794
  return nil
650
795
  }
651
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
+
652
803
  private func setCurrentCapacitorServerPath(path: String) {
653
804
  guard let viewController = self.plugin.bridge?.viewController as? CAPBridgeViewController else {
654
805
  return
655
806
  }
656
807
  viewController.setServerBasePath(path: path)
808
+ // Notify listeners
809
+ notifyReloadedListeners()
657
810
  }
658
811
 
659
- private func setCurrentCapacitorServerPathToDefaultWebAssetDir() {
660
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
661
- setCurrentCapacitorServerPath(path: path)
662
- }
663
-
664
- 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?) {
665
814
  let path = buildCapacitorServerPathFor(bundleId: bundleId)
666
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)
667
865
  }
668
866
 
669
867
  private func setNextCapacitorServerPath(path: String) {
@@ -676,12 +874,32 @@ import CommonCrypto
676
874
  }
677
875
  }
678
876
 
679
- private func setNextCapacitorServerPathToDefaultWebAssetDir() {
680
- let path = buildCapacitorServerPathFor(bundleId: defaultWebAssetDir)
681
- 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)
682
897
  }
683
898
 
684
899
  private func startRollbackTimer() {
900
+ guard config.readyTimeout > 0 else {
901
+ return
902
+ }
685
903
  stopRollbackTimer()
686
904
  rollbackDispatchWorkItem = DispatchWorkItem { [weak self] in
687
905
  self?.rollback()
@@ -693,6 +911,18 @@ import CommonCrypto
693
911
  rollbackDispatchWorkItem?.cancel()
694
912
  }
695
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
+
696
926
  private func unzipFile(zipFile: URL) -> URL {
697
927
  let destinationDirectory = zipFile.deletingPathExtension()
698
928
  SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
@@ -788,27 +1018,6 @@ import CommonCrypto
788
1018
  }
789
1019
  return verificationResult
790
1020
  }
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
1021
  }
813
1022
 
814
1023
  extension DispatchQueue {
@@ -823,3 +1032,10 @@ extension DispatchQueue {
823
1032
  }
824
1033
  }
825
1034
  }
1035
+
1036
+ extension Progress {
1037
+ convenience init(totalUnitCount: Int64, completedUnitCount: Int64) {
1038
+ self.init(totalUnitCount: totalUnitCount)
1039
+ self.completedUnitCount = completedUnitCount
1040
+ }
1041
+ }