@capawesome/capacitor-live-update 6.4.2 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +46 -5
  2. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +363 -251
  3. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateHttpClient.java +71 -0
  4. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +65 -60
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/Manifest.java +58 -0
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/ManifestItem.java +37 -0
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +19 -27
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +15 -1
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +22 -0
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/enums/ArtifactType.java +6 -0
  11. package/dist/docs.json +67 -4
  12. package/dist/esm/definitions.d.ts +42 -3
  13. package/dist/esm/definitions.js.map +1 -1
  14. package/dist/esm/web.d.ts +2 -1
  15. package/dist/esm/web.js +3 -0
  16. package/dist/esm/web.js.map +1 -1
  17. package/dist/plugin.cjs.js +3 -0
  18. package/dist/plugin.cjs.js.map +1 -1
  19. package/dist/plugin.js +3 -0
  20. package/dist/plugin.js.map +1 -1
  21. package/ios/Plugin/Classes/Manifest.swift +20 -0
  22. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +11 -1
  23. package/ios/Plugin/Classes/Results/FetchLatestBundle.swift +16 -0
  24. package/ios/Plugin/Enums/ArtifactType.swift +4 -0
  25. package/ios/Plugin/{Classes → Enums}/CustomError.swift +3 -0
  26. package/ios/Plugin/LiveUpdate.swift +279 -198
  27. package/ios/Plugin/LiveUpdateHttpClient.swift +42 -0
  28. package/ios/Plugin/LiveUpdatePlugin.m +1 -0
  29. package/ios/Plugin/LiveUpdatePlugin.swift +77 -61
  30. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -3
  31. package/ios/Plugin/Protocols/Api/ManifestItem.swift +5 -0
  32. package/package.json +1 -1
@@ -7,19 +7,28 @@ import CommonCrypto
7
7
 
8
8
  // swiftlint:disable type_body_length
9
9
  @objc public class LiveUpdate: NSObject {
10
+ private let bundlesDirectory = "NoCloud/ionic_built_snapshots" // DO NOT CHANGE! (See https://dub.sh/BLluidt)
10
11
  private let cachesDirectoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
11
12
  private let config: LiveUpdateConfig
12
- private let defaultWebAssetDir = "public" // DO NOT CHANGE (See https://dub.sh/Buvz4yj)
13
- private let defaultServerPathKey = "serverBasePath" // DO NOT CHANGE (See https://dub.sh/ceDl0zT)
13
+ private let defaultWebAssetDir = "public" // DO NOT CHANGE! (See https://dub.sh/Buvz4yj)
14
+ private let defaultServerPathKey = "serverBasePath" // DO NOT CHANGE! (See https://dub.sh/ceDl0zT)
15
+ private let host: String
16
+ private let httpClient: LiveUpdateHttpClient
14
17
  private let libraryDirectoryUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
18
+ private let manifestFileName = "capawesome-live-update-manifest.json" // DO NOT CHANGE!
15
19
  private let plugin: LiveUpdatePlugin
16
- private let bundlesDirectory = "NoCloud/ionic_built_snapshots" // DO NOT CHANGE (See https://dub.sh/BLluidt)
17
20
  private let preferences: LiveUpdatePreferences
18
21
 
19
22
  private var rollbackDispatchWorkItem: DispatchWorkItem?
20
23
 
21
24
  init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin) {
22
25
  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
+ self.httpClient = LiveUpdateHttpClient(config: config)
23
32
  self.plugin = plugin
24
33
  self.preferences = LiveUpdatePreferences()
25
34
  super.init()
@@ -51,26 +60,28 @@ import CommonCrypto
51
60
  }
52
61
  }
53
62
 
54
- @objc public func downloadBundle(_ options: DownloadBundleOptions, completion: @escaping (Error?) -> Void) {
63
+ @objc public func downloadBundle(_ options: DownloadBundleOptions) async throws {
64
+ let artifactType = options.getArtifactType()
55
65
  let bundleId = options.getBundleId()
56
66
  let checksum = options.getChecksum()
57
67
  let url = options.getUrl()
58
68
 
59
69
  // Check if the bundle already exists
60
70
  if hasBundle(bundleId: bundleId) {
61
- let error = CustomError.bundleAlreadyExists
62
- completion(error)
63
- return
71
+ throw CustomError.bundleAlreadyExists
64
72
  }
65
73
 
66
74
  // Download the bundle
67
- downloadBundle(bundleId: bundleId, checksum: checksum, signature: nil, url: url, completion: { error in
68
- if let error = error {
69
- completion(error)
70
- return
71
- }
72
- completion(nil)
73
- })
75
+ if artifactType == .manifest {
76
+ try await downloadBundleOfTypeManifest(bundleId: bundleId, url: url)
77
+ } else {
78
+ try await downloadBundleOfTypeZip(bundleId: bundleId, url: url, checksum: checksum)
79
+ }
80
+ }
81
+
82
+ @objc public func fetchLatestBundle() async throws -> FetchLatestBundleResult {
83
+ let response: GetLatestBundleResponse? = try await self.fetchLatestBundle()
84
+ return FetchLatestBundleResult(bundleId: response?.bundleId)
74
85
  }
75
86
 
76
87
  @objc public func getBundle(completion: @escaping (Result?, Error?) -> Void) {
@@ -165,68 +176,60 @@ import CommonCrypto
165
176
  completion(nil)
166
177
  }
167
178
 
168
- @objc public func sync(completion: @escaping (SyncResult?, Error?) -> Void) {
179
+ @objc public func sync() async throws -> SyncResult {
169
180
  // Fetch the latest bundle
170
- fetchLatestBundle(completion: { response, error in
171
- if let _ = error {
172
- // No update available
173
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "No update available.")
174
- let result = SyncResult(nextBundleId: nil)
175
- completion(result, nil)
176
- return
181
+ guard let response = try await fetchLatestBundle() else {
182
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "No update available.")
183
+ return SyncResult(nextBundleId: nil)
184
+ }
185
+ let artifactType = response.artifactType
186
+ let latestBundleId = response.bundleId
187
+ let downloadUrl = response.url
188
+ // Check if bundle already exists
189
+ if hasBundle(bundleId: latestBundleId) {
190
+ var nextBundleId: String?
191
+ let currentBundleId = self.getCurrentBundleId()
192
+ if latestBundleId != currentBundleId {
193
+ // Set the next bundle
194
+ setNextBundle(bundleId: latestBundleId)
195
+ nextBundleId = latestBundleId
177
196
  }
178
- if let response = response {
179
- let latestBundleId = response.bundleId
180
- // Check if the bundle already exists
181
- if self.hasBundle(bundleId: latestBundleId) {
182
- var nextBundleId: String?
183
- let currentBundleId = self.getCurrentBundleId()
184
- if latestBundleId != currentBundleId {
185
- // Set the next bundle
186
- self.setNextBundle(bundleId: latestBundleId)
187
- nextBundleId = latestBundleId
188
- }
189
- let result = SyncResult(nextBundleId: nextBundleId)
190
- completion(result, nil)
191
- } else {
192
- // Download and unzip the bundle
193
- self.downloadBundle(bundleId: latestBundleId, checksum: response.checksum, signature: response.signature, url: response.url, completion: { error in
194
- if let error = error {
195
- completion(nil, error)
196
- return
197
- }
198
- // Set the next bundle
199
- self.setNextBundle(bundleId: latestBundleId)
200
- let result = SyncResult(nextBundleId: latestBundleId)
201
- completion(result, nil)
202
- })
203
- }
204
- }
205
- })
197
+ return SyncResult(nextBundleId: nextBundleId)
198
+ }
199
+ // Download the bundle
200
+ if artifactType == .manifest {
201
+ try await downloadBundleOfTypeManifest(bundleId: latestBundleId, url: downloadUrl)
202
+ } else {
203
+ try await downloadBundleOfTypeZip(bundleId: latestBundleId, url: downloadUrl, checksum: nil)
204
+ }
205
+ // Set the next bundle
206
+ setNextBundle(bundleId: latestBundleId)
207
+ return SyncResult(nextBundleId: latestBundleId)
206
208
  }
207
209
 
208
- private func addBundle(bundleId: String, zipFile: URL, completion: @escaping (Error?) -> Void) {
209
- // Unzip the bundle
210
- self.unzipFile(zipFile: zipFile, completion: { url in
211
- // Search folder with index.html file
212
- guard let indexHtmlFile = self.searchIndexHtmlFile(url: url) else {
213
- completion(CustomError.bundleIndexHtmlMissing)
214
- return
215
- }
210
+ private func addBundle(bundleId: String, directory: URL) throws {
211
+ // Search folder with index.html file
212
+ guard let indexHtmlFile = self.searchIndexHtmlFile(url: directory) else {
213
+ throw CustomError.bundleIndexHtmlMissing
214
+ }
216
215
 
217
- // Create the bundles directory if it does not exist
218
- self.createBundlesDirectory()
216
+ // Create the bundles directory if it does not exist
217
+ self.createBundlesDirectory()
219
218
 
220
- // Move the unzipped files to the bundles directory
221
- let bundlePath = self.buildBundlePathFor(bundleId: bundleId)
222
- do {
223
- try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
224
- completion(nil)
225
- } catch {
226
- completion(error)
227
- return
228
- }
229
- })
219
+ // Move the unzipped files to the bundles directory
220
+ let bundlePath = self.buildBundlePathFor(bundleId: bundleId)
221
+ try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
222
+ }
223
+
224
+ private func addBundleOfTypeManifest(bundleId: String, directory: URL) async throws {
225
+ try addBundle(bundleId: bundleId, directory: directory)
226
+ }
227
+
228
+ private func addBundleOfTypeZip(bundleId: String, zipFile: URL) async throws {
229
+ // Unzip the bundle
230
+ let unzippedDirectory = self.unzipFile(zipFile: zipFile)
231
+ // Add the bundle
232
+ try self.addBundle(bundleId: bundleId, directory: unzippedDirectory)
230
233
  }
231
234
 
232
235
  private func buildCapacitorServerPathFor(bundleId: String) -> String {
@@ -240,22 +243,40 @@ import CommonCrypto
240
243
  }
241
244
 
242
245
  private func buildBundlePathFor(bundleId: String) -> String {
246
+ return buildBundleURLFor(bundleId: bundleId).path
247
+ }
248
+
249
+ private func buildBundleURLFor(bundleId: String) -> URL {
243
250
  let url = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory).appendingPathComponent(bundleId)
244
- return url.path
251
+ return url
245
252
  }
246
253
 
247
- /// - Returns: The sha256 checksum of the file at the given URL.
248
- private func getChecksumForFile(url: URL) throws -> String {
249
- let handle = try FileHandle(forReadingFrom: url)
250
- var hasher = SHA256()
251
- while autoreleasepool(invoking: {
252
- let nextChunk = handle.readData(ofLength: 2048)
253
- guard !nextChunk.isEmpty else { return false }
254
- hasher.update(data: nextChunk)
255
- return true
256
- }) { }
257
- let digest = hasher.finalize()
258
- return digest.map { String(format: "%02hhx", $0) }.joined()
254
+ private func copyCurrentBundleFile(href: String, toDirectory: URL) throws {
255
+ guard let currentBundleId = getCurrentBundleId() else {
256
+ return
257
+ }
258
+
259
+ let destination = toDirectory.appendingPathComponent(href)
260
+ let parentDirectory = destination.deletingLastPathComponent()
261
+ try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
262
+
263
+ let sourceURL: URL
264
+ if currentBundleId == defaultWebAssetDir {
265
+ guard let file = Bundle.main.url(forResource: href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
266
+ return
267
+ }
268
+ sourceURL = file
269
+ } else {
270
+ sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(href)
271
+ }
272
+
273
+ try FileManager.default.copyItem(at: sourceURL, to: destination)
274
+ }
275
+
276
+ private func copyCurrentBundleFiles(hrefs: [String], toDirectory: URL) throws {
277
+ for href in hrefs {
278
+ try copyCurrentBundleFile(href: href, toDirectory: toDirectory)
279
+ }
259
280
  }
260
281
 
261
282
  private func createBundlesDirectory() {
@@ -270,14 +291,19 @@ import CommonCrypto
270
291
  }
271
292
  }
272
293
 
294
+ private func createTemporaryDirectory() throws -> URL {
295
+ let temporaryDirectory = cachesDirectoryUrl.appendingPathComponent(UUID().uuidString)
296
+ try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
297
+ return temporaryDirectory
298
+ }
299
+
273
300
  private func deleteBundle(bundleId: String) throws {
274
301
  // Delete the bundle directory
275
302
  let path = buildBundlePathFor(bundleId: bundleId)
276
303
  try FileManager.default.removeItem(atPath: path)
277
304
  // Reset the next bundle if it is the deleted bundle
278
- let currentBundleId = getCurrentBundleId()
279
305
  let nextBundleId = getNextBundleId()
280
- if bundleId == currentBundleId && bundleId == nextBundleId {
306
+ if bundleId == nextBundleId {
281
307
  setNextCapacitorServerPathToDefaultWebAssetDir()
282
308
  }
283
309
  }
@@ -298,94 +324,87 @@ import CommonCrypto
298
324
  }
299
325
  }
300
326
 
301
- private func downloadBundle(bundleId: String, checksum: String?, signature: String?, url: String, completion: @escaping (Error?) -> Void) {
302
- // Download the bundle
303
- downloadFile(url: url, completion: { url, error in
304
- if let error = error {
305
- completion(error)
306
- return
307
- }
308
- if let url = url {
309
- // Verify the signature
310
- if let publicKey = self.config.publicKey {
311
- guard let signature = signature else {
312
- completion(CustomError.signatureMissing)
313
- return
314
- }
315
- // Verify the signature
316
- do {
317
- let verified = try self.verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
318
- if !verified {
319
- completion(CustomError.signatureVerificationFailed)
320
- return
321
- }
322
- } catch {
323
- completion(CustomError.signatureVerificationFailed)
324
- return
325
- }
326
- }
327
- // Verify the checksum
328
- else if let expectedChecksum = checksum {
329
- // Calculate the checksum
330
- do {
331
- let receivedChecksum = try self.getChecksumForFile(url: url)
332
- if receivedChecksum != expectedChecksum {
333
- completion(CustomError.checksumMismatch)
334
- return
335
- }
336
- } catch {
337
- completion(CustomError.checksumCalculationFailed)
338
- return
339
- }
327
+ private func downloadAndVerifyFile(url: String, file: URL, checksum: String?) async throws {
328
+ let destination: DownloadRequest.Destination = { _, _ in
329
+ return (file, [.createIntermediateDirectories])
330
+ }
331
+ let urlComponents = URLComponents(string: url)!
332
+ let result = try await httpClient.download(url: urlComponents.asURL(), destination: destination)
333
+ if let error = result.error {
334
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to download file: \(error)")
335
+ if let urlError = error.underlyingError as? URLError {
336
+ if urlError.code == .timedOut {
337
+ throw urlError
340
338
  }
341
-
342
- // Add the bundle
343
- self.addBundle(bundleId: bundleId, zipFile: url, completion: { error in
344
- // Delete the temporary file
345
- do {
346
- try FileManager.default.removeItem(atPath: url.path)
347
- } catch {
348
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete temporary file.")
349
- }
350
- if let error = error {
351
- completion(error)
352
- return
353
- }
354
- completion(nil)
355
- })
356
339
  }
357
- })
340
+ throw CustomError.downloadFailed
341
+ }
342
+ guard let response = result.response else {
343
+ throw CustomError.unknown
344
+ }
345
+ let checksum = checksum == nil ? LiveUpdateHttpClient.getChecksumFromResponse(response: response) : checksum
346
+ let signature = LiveUpdateHttpClient.getSignatureFromResponse(response: response)
347
+ try verifyFile(url: file, checksum: checksum, signature: signature)
358
348
  }
359
349
 
360
- private func downloadFile(url: String, completion: @escaping (URL?, Error?) -> Void) {
361
- let destination: DownloadRequest.Destination = { _, _ in
362
- let timestamp = String(Int(Date().timeIntervalSince1970))
363
- let temporaryZipFileUrl = self.cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
364
- return (temporaryZipFileUrl, [.createIntermediateDirectories])
365
- }
366
- var request = URLRequest(url: URL(string: url)!)
367
- request.httpMethod = HTTPMethod.get.rawValue
368
- request.timeoutInterval = Double(config.httpTimeout) / 1000.0
369
- AF.download(request, to: destination).validate().response { response in
370
- if let error = response.error {
371
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", error)
372
- if let urlError = error.underlyingError as? URLError {
373
- switch urlError.code {
374
- case .timedOut:
375
- completion(nil, CustomError.httpTimeout)
376
- default:
377
- completion(nil, CustomError.downloadFailed)
378
- }
379
- } else {
380
- completion(nil, CustomError.downloadFailed)
350
+ private func downloadBundleFile(baseUrl: String, href: String, directory: URL) async throws -> URL {
351
+ let fileURL = directory.appendingPathComponent(href)
352
+ var parameters = [String: String]()
353
+ parameters["href"] = href
354
+ var urlComponents = URLComponents(string: baseUrl)!
355
+ urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
356
+ let url = urlComponents.string!
357
+ try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil)
358
+ return fileURL
359
+ }
360
+
361
+ private func downloadBundleFiles(url: String, hrefs: [String], directory: URL) async throws {
362
+ try await withThrowingTaskGroup(of: Void.self) { group in
363
+ for href in hrefs {
364
+ group.addTask {
365
+ _ = try await self.downloadBundleFile(baseUrl: url, href: href, directory: directory)
381
366
  }
382
- return
383
367
  }
384
- completion(response.fileURL, nil)
368
+
369
+ try await group.waitForAll()
385
370
  }
386
371
  }
387
372
 
388
- private func fetchLatestBundle(completion: @escaping (GetLatestBundleResponse?, Error?) -> Void) {
373
+ private func downloadBundleOfTypeManifest(bundleId: String, url: String) async throws {
374
+ // Create a temporary directory
375
+ let temporaryDirectory = try createTemporaryDirectory()
376
+ // Download the latest manifest
377
+ let latestManifestFile = try await downloadBundleFile(baseUrl: url, href: manifestFileName, directory: temporaryDirectory)
378
+ let latestManifest = try loadManifest(file: latestManifestFile)
379
+ // Load the current manifest
380
+ let currentManifest = try loadCurrentManifest()
381
+ // Compare the manifests
382
+ var itemsToCopy = [ManifestItem]()
383
+ var itemsToDownload = [ManifestItem]()
384
+ if let currentManifest = currentManifest {
385
+ itemsToCopy.append(contentsOf: Manifest.findDuplicateItems(latestManifest, currentManifest))
386
+ itemsToDownload.append(contentsOf: Manifest.findMissingItems(latestManifest, currentManifest))
387
+ } else {
388
+ itemsToDownload.append(contentsOf: latestManifest.items)
389
+ }
390
+ // Copy the files
391
+ try self.copyCurrentBundleFiles(hrefs: itemsToCopy.map { $0.href }, toDirectory: temporaryDirectory)
392
+ // Download the files
393
+ try await self.downloadBundleFiles(url: url, hrefs: itemsToDownload.map { $0.href }, directory: temporaryDirectory)
394
+ // Add the bundle
395
+ try await addBundleOfTypeManifest(bundleId: bundleId, directory: temporaryDirectory)
396
+ }
397
+
398
+ private func downloadBundleOfTypeZip(bundleId: String, url: String, checksum: String?) async throws {
399
+ let timestamp = String(Int(Date().timeIntervalSince1970))
400
+ let temporaryZipFileUrl = self.cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
401
+ // Download the bundle
402
+ try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum)
403
+ // Add the bundle
404
+ try await addBundleOfTypeZip(bundleId: bundleId, zipFile: temporaryZipFileUrl)
405
+ }
406
+
407
+ private func fetchLatestBundle() async throws -> GetLatestBundleResponse? {
389
408
  var parameters = [String: String]()
390
409
  parameters["appVersionCode"] = getVersionCode()
391
410
  parameters["appVersionName"] = getVersionName()
@@ -393,40 +412,29 @@ import CommonCrypto
393
412
  parameters["channelName"] = getChannel()
394
413
  parameters["customId"] = preferences.getCustomId()
395
414
  parameters["deviceId"] = getDeviceId()
396
- parameters["osVersion"] = UIDevice.current.systemVersion
415
+ parameters["osVersion"] = await UIDevice.current.systemVersion
397
416
  parameters["platform"] = "1"
398
417
  parameters["pluginVersion"] = LiveUpdatePlugin.version
399
- var host = "api.cloud.capawesome.io"
400
- if let location = config.location {
401
- if location == "eu" {
402
- host = "api.cloud.capawesome.eu"
403
- }
404
- }
405
418
  var urlComponents = URLComponents(string: "https://\(host)/v1/apps/\(config.appId ?? "")/bundles/latest")!
406
419
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
407
- var request = URLRequest(url: urlComponents.url!)
408
- request.httpMethod = HTTPMethod.get.rawValue
409
- request.timeoutInterval = Double(config.httpTimeout) / 1000.0
410
- AF.request(request).validate().responseDecodable(of: GetLatestBundleResponse.self) { response in
411
- CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle from ", response.request?.url?.absoluteString ?? "")
412
- if let error = response.error {
413
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", error)
414
- if let data = response.data {
415
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", String(decoding: data, as: UTF8.self))
420
+ let url = try urlComponents.asURL()
421
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle: ", url)
422
+ let response = try await self.httpClient.request(url: url, type: GetLatestBundleResponse.self)
423
+ if let data = response.data {
424
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Latest bundle response: ", String(decoding: data, as: UTF8.self))
425
+ }
426
+ if let error = response.error {
427
+ if let urlError = error.underlyingError as? URLError {
428
+ if urlError.code == .timedOut {
429
+ throw urlError
416
430
  }
417
- if let urlError = error.underlyingError as? URLError {
418
- switch urlError.code {
419
- case .timedOut:
420
- completion(nil, CustomError.httpTimeout)
421
- default:
422
- completion(nil, error)
423
- }
424
- } else {
425
- completion(nil, error)
426
- }
427
- return
428
431
  }
429
- completion(response.value, nil)
432
+ return nil
433
+ }
434
+ if let value = response.value {
435
+ return value
436
+ } else {
437
+ return nil
430
438
  }
431
439
  }
432
440
 
@@ -455,6 +463,20 @@ import CommonCrypto
455
463
  return channel
456
464
  }
457
465
 
466
+ /// - Returns: The sha256 checksum of the file at the given URL.
467
+ private func getChecksumForFile(url: URL) throws -> String {
468
+ let handle = try FileHandle(forReadingFrom: url)
469
+ var hasher = SHA256()
470
+ while autoreleasepool(invoking: {
471
+ let nextChunk = handle.readData(ofLength: 2048)
472
+ guard !nextChunk.isEmpty else { return false }
473
+ hasher.update(data: nextChunk)
474
+ return true
475
+ }) { }
476
+ let digest = hasher.finalize()
477
+ return digest.map { String(format: "%02hhx", $0) }.joined()
478
+ }
479
+
458
480
  /// - Returns: The current bundle ID (`public` for the built-in bundle) or `nil` if no view controller was found.
459
481
  private func getCurrentBundleId() -> String? {
460
482
  guard let path = getCurrentCapacitorServerPath() else {
@@ -510,6 +532,36 @@ import CommonCrypto
510
532
  return FileManager.default.fileExists(atPath: path)
511
533
  }
512
534
 
535
+ private func loadCurrentManifest() throws -> Manifest? {
536
+ guard let currentBundleId = getCurrentBundleId() else {
537
+ return nil
538
+ }
539
+ if currentBundleId == defaultWebAssetDir {
540
+ let files = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: defaultWebAssetDir) ?? []
541
+ let manifestFileUrl = files.first { $0.lastPathComponent == manifestFileName }
542
+ if let manifestFileUrl = manifestFileUrl {
543
+ return try loadManifest(file: manifestFileUrl)
544
+ } else {
545
+ return nil
546
+ }
547
+ } else {
548
+ let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
549
+ let manifestFileExists = FileManager.default.fileExists(atPath: manifestFileUrl.path)
550
+ if manifestFileExists {
551
+ return try loadManifest(file: manifestFileUrl)
552
+ } else {
553
+ return nil
554
+ }
555
+ }
556
+ }
557
+
558
+ private func loadManifest(file: URL) throws -> Manifest {
559
+ let data = try Data(contentsOf: file)
560
+ let decoder = JSONDecoder()
561
+ let items = try decoder.decode([ManifestItem].self, from: data)
562
+ return Manifest(items: items)
563
+ }
564
+
513
565
  private func rollback() {
514
566
  if getCurrentBundleId() == defaultWebAssetDir {
515
567
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Default bundle is already in use.")
@@ -598,13 +650,42 @@ import CommonCrypto
598
650
  rollbackDispatchWorkItem?.cancel()
599
651
  }
600
652
 
601
- private func unzipFile(zipFile: URL, completion: @escaping (URL) -> Void) {
653
+ private func unzipFile(zipFile: URL) -> URL {
602
654
  let destinationDirectory = zipFile.deletingPathExtension()
603
- DispatchQueue.background(background: {
604
- SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
605
- }, completion: {
606
- completion(destinationDirectory)
607
- })
655
+ SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
656
+ return destinationDirectory
657
+ }
658
+
659
+ private func verifyFile(url: URL, checksum: String?, signature: String?) throws {
660
+ // Verify the signature
661
+ if let publicKey = self.config.publicKey {
662
+ guard let signature = signature else {
663
+ throw CustomError.signatureMissing
664
+ }
665
+ // Verify the signature
666
+ let verified: Bool
667
+ do {
668
+ verified = try self.verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
669
+ } catch {
670
+ throw CustomError.signatureVerificationFailed
671
+ }
672
+ if !verified {
673
+ throw CustomError.signatureVerificationFailed
674
+ }
675
+ }
676
+ // Verify the checksum
677
+ else if let expectedChecksum = checksum {
678
+ // Calculate the checksum
679
+ let receivedChecksum: String
680
+ do {
681
+ receivedChecksum = try self.getChecksumForFile(url: url)
682
+ } catch {
683
+ throw CustomError.checksumCalculationFailed
684
+ }
685
+ if receivedChecksum != expectedChecksum {
686
+ throw CustomError.checksumMismatch
687
+ }
688
+ }
608
689
  }
609
690
 
610
691
  private func verifySignatureForFile(url: URL, signature: String, publicKey: String) throws -> Bool {
@@ -0,0 +1,42 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import Alamofire
4
+
5
+ public class LiveUpdateHttpClient: NSObject {
6
+
7
+ private let config: LiveUpdateConfig
8
+
9
+ public static func getChecksumFromResponse(response: HTTPURLResponse) -> String? {
10
+ return response.allHeaderFields["x-checksum"] as? String // Must be lowercase!
11
+ }
12
+
13
+ public static func getSignatureFromResponse(response: HTTPURLResponse) -> String? {
14
+ return response.allHeaderFields["x-signature"] as? String // Must be lowercase!
15
+ }
16
+
17
+ init(config: LiveUpdateConfig) {
18
+ self.config = config
19
+ }
20
+
21
+ public func download(url: URL, destination: @escaping DownloadRequest.Destination) async throws -> AFDownloadResponse<Data> {
22
+ var request = URLRequest(url: url)
23
+ request.httpMethod = HTTPMethod.get.rawValue
24
+ request.timeoutInterval = Double(config.httpTimeout) / 1000.0
25
+ return try await withCheckedThrowingContinuation { continuation in
26
+ AF.download(request, to: destination).responseData { response in
27
+ continuation.resume(returning: response)
28
+ }
29
+ }
30
+ }
31
+
32
+ public func request<T: Decodable>(url: URL, type: T.Type) async throws -> AFDataResponse<T> {
33
+ var request = URLRequest(url: url)
34
+ request.httpMethod = HTTPMethod.get.rawValue
35
+ request.timeoutInterval = Double(config.httpTimeout) / 1000.0
36
+ return try await withCheckedThrowingContinuation { continuation in
37
+ AF.request(request).validate().responseDecodable(of: type) { response in
38
+ continuation.resume(returning: response)
39
+ }
40
+ }
41
+ }
42
+ }
@@ -6,6 +6,7 @@
6
6
  CAP_PLUGIN(LiveUpdatePlugin, "LiveUpdate",
7
7
  CAP_PLUGIN_METHOD(deleteBundle, CAPPluginReturnPromise);
8
8
  CAP_PLUGIN_METHOD(downloadBundle, CAPPluginReturnPromise);
9
+ CAP_PLUGIN_METHOD(fetchLatestBundle, CAPPluginReturnPromise);
9
10
  CAP_PLUGIN_METHOD(getBundle, CAPPluginReturnPromise);
10
11
  CAP_PLUGIN_METHOD(getBundles, CAPPluginReturnPromise);
11
12
  CAP_PLUGIN_METHOD(getChannel, CAPPluginReturnPromise);