@capawesome/capacitor-live-update 6.8.0 → 6.9.1

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