@capawesome/capacitor-live-update 6.4.1 → 6.5.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 (21) hide show
  1. package/README.md +2 -1
  2. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +351 -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 +42 -59
  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/results/GetChannelResult.java +2 -1
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/enums/ArtifactType.java +6 -0
  10. package/dist/docs.json +1 -1
  11. package/dist/esm/definitions.d.ts +1 -1
  12. package/dist/esm/definitions.js.map +1 -1
  13. package/ios/Plugin/Classes/Manifest.swift +20 -0
  14. package/ios/Plugin/Enums/ArtifactType.swift +4 -0
  15. package/ios/Plugin/{Classes → Enums}/CustomError.swift +3 -0
  16. package/ios/Plugin/LiveUpdate.swift +270 -199
  17. package/ios/Plugin/LiveUpdateHttpClient.swift +42 -0
  18. package/ios/Plugin/LiveUpdatePlugin.swift +65 -63
  19. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -3
  20. package/ios/Plugin/Protocols/Api/ManifestItem.swift +5 -0
  21. package/package.json +1 -1
@@ -4,22 +4,31 @@ import SSZipArchive
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 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,18 @@ 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 {
55
64
  let bundleId = options.getBundleId()
56
65
  let checksum = options.getChecksum()
57
66
  let url = options.getUrl()
58
67
 
59
68
  // Check if the bundle already exists
60
69
  if hasBundle(bundleId: bundleId) {
61
- let error = CustomError.bundleAlreadyExists
62
- completion(error)
63
- return
70
+ throw CustomError.bundleAlreadyExists
64
71
  }
65
72
 
66
73
  // 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
- })
74
+ try await downloadBundleOfTypeZip(bundleId: bundleId, url: url, checksum: checksum)
74
75
  }
75
76
 
76
77
  @objc public func getBundle(completion: @escaping (Result?, Error?) -> Void) {
@@ -165,68 +166,60 @@ import CommonCrypto
165
166
  completion(nil)
166
167
  }
167
168
 
168
- @objc public func sync(completion: @escaping (SyncResult?, Error?) -> Void) {
169
+ @objc public func sync() async throws -> SyncResult {
169
170
  // 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
171
+ guard let response = try await fetchLatestBundle() else {
172
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "No update available.")
173
+ return SyncResult(nextBundleId: nil)
174
+ }
175
+ let artifactType = response.artifactType
176
+ let latestBundleId = response.bundleId
177
+ let downloadUrl = response.url
178
+ // Check if bundle already exists
179
+ if hasBundle(bundleId: latestBundleId) {
180
+ var nextBundleId: String?
181
+ let currentBundleId = self.getCurrentBundleId()
182
+ if latestBundleId != currentBundleId {
183
+ // Set the next bundle
184
+ setNextBundle(bundleId: latestBundleId)
185
+ nextBundleId = latestBundleId
177
186
  }
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
- })
187
+ return SyncResult(nextBundleId: nextBundleId)
188
+ }
189
+ // Download the bundle
190
+ if artifactType == .manifest {
191
+ try await downloadBundleOfTypeManifest(bundleId: latestBundleId, url: downloadUrl)
192
+ } else {
193
+ try await downloadBundleOfTypeZip(bundleId: latestBundleId, url: downloadUrl, checksum: nil)
194
+ }
195
+ // Set the next bundle
196
+ setNextBundle(bundleId: latestBundleId)
197
+ return SyncResult(nextBundleId: latestBundleId)
206
198
  }
207
199
 
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
- }
200
+ private func addBundle(bundleId: String, directory: URL) throws {
201
+ // Search folder with index.html file
202
+ guard let indexHtmlFile = self.searchIndexHtmlFile(url: directory) else {
203
+ throw CustomError.bundleIndexHtmlMissing
204
+ }
216
205
 
217
- // Create the bundles directory if it does not exist
218
- self.createBundlesDirectory()
206
+ // Create the bundles directory if it does not exist
207
+ self.createBundlesDirectory()
219
208
 
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
- })
209
+ // Move the unzipped files to the bundles directory
210
+ let bundlePath = self.buildBundlePathFor(bundleId: bundleId)
211
+ try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
212
+ }
213
+
214
+ private func addBundleOfTypeManifest(bundleId: String, directory: URL) async throws {
215
+ try addBundle(bundleId: bundleId, directory: directory)
216
+ }
217
+
218
+ private func addBundleOfTypeZip(bundleId: String, zipFile: URL) async throws {
219
+ // Unzip the bundle
220
+ let unzippedDirectory = self.unzipFile(zipFile: zipFile)
221
+ // Add the bundle
222
+ try self.addBundle(bundleId: bundleId, directory: unzippedDirectory)
230
223
  }
231
224
 
232
225
  private func buildCapacitorServerPathFor(bundleId: String) -> String {
@@ -240,22 +233,40 @@ import CommonCrypto
240
233
  }
241
234
 
242
235
  private func buildBundlePathFor(bundleId: String) -> String {
236
+ return buildBundleURLFor(bundleId: bundleId).path
237
+ }
238
+
239
+ private func buildBundleURLFor(bundleId: String) -> URL {
243
240
  let url = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory).appendingPathComponent(bundleId)
244
- return url.path
241
+ return url
245
242
  }
246
243
 
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()
244
+ private func copyCurrentBundleFile(href: String, toDirectory: URL) throws {
245
+ guard let currentBundleId = getCurrentBundleId() else {
246
+ return
247
+ }
248
+
249
+ let destination = toDirectory.appendingPathComponent(href)
250
+ let parentDirectory = destination.deletingLastPathComponent()
251
+ try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
252
+
253
+ let sourceURL: URL
254
+ if currentBundleId == defaultWebAssetDir {
255
+ guard let file = Bundle.main.url(forResource: href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
256
+ return
257
+ }
258
+ sourceURL = file
259
+ } else {
260
+ sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(href)
261
+ }
262
+
263
+ try FileManager.default.copyItem(at: sourceURL, to: destination)
264
+ }
265
+
266
+ private func copyCurrentBundleFiles(hrefs: [String], toDirectory: URL) throws {
267
+ for href in hrefs {
268
+ try copyCurrentBundleFile(href: href, toDirectory: toDirectory)
269
+ }
259
270
  }
260
271
 
261
272
  private func createBundlesDirectory() {
@@ -270,14 +281,19 @@ import CommonCrypto
270
281
  }
271
282
  }
272
283
 
284
+ private func createTemporaryDirectory() throws -> URL {
285
+ let temporaryDirectory = cachesDirectoryUrl.appendingPathComponent(UUID().uuidString)
286
+ try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
287
+ return temporaryDirectory
288
+ }
289
+
273
290
  private func deleteBundle(bundleId: String) throws {
274
291
  // Delete the bundle directory
275
292
  let path = buildBundlePathFor(bundleId: bundleId)
276
293
  try FileManager.default.removeItem(atPath: path)
277
294
  // Reset the next bundle if it is the deleted bundle
278
- let currentBundleId = getCurrentBundleId()
279
295
  let nextBundleId = getNextBundleId()
280
- if bundleId == currentBundleId && bundleId == nextBundleId {
296
+ if bundleId == nextBundleId {
281
297
  setNextCapacitorServerPathToDefaultWebAssetDir()
282
298
  }
283
299
  }
@@ -298,94 +314,87 @@ import CommonCrypto
298
314
  }
299
315
  }
300
316
 
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
- }
317
+ private func downloadAndVerifyFile(url: String, file: URL, checksum: String?) async throws {
318
+ let destination: DownloadRequest.Destination = { _, _ in
319
+ return (file, [.createIntermediateDirectories])
320
+ }
321
+ let urlComponents = URLComponents(string: url)!
322
+ let result = try await httpClient.download(url: urlComponents.asURL(), destination: destination)
323
+ if let error = result.error {
324
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to download file: \(error)")
325
+ if let urlError = error.underlyingError as? URLError {
326
+ if urlError.code == .timedOut {
327
+ throw urlError
340
328
  }
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
329
  }
357
- })
330
+ throw CustomError.downloadFailed
331
+ }
332
+ guard let response = result.response else {
333
+ throw CustomError.unknown
334
+ }
335
+ let checksum = checksum == nil ? LiveUpdateHttpClient.getChecksumFromResponse(response: response) : checksum
336
+ let signature = LiveUpdateHttpClient.getSignatureFromResponse(response: response)
337
+ try verifyFile(url: file, checksum: checksum, signature: signature)
358
338
  }
359
339
 
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)
340
+ private func downloadBundleFile(baseUrl: String, href: String, directory: URL) async throws -> URL {
341
+ let fileURL = directory.appendingPathComponent(href)
342
+ var parameters = [String: String]()
343
+ parameters["href"] = href
344
+ var urlComponents = URLComponents(string: baseUrl)!
345
+ urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
346
+ let url = urlComponents.string!
347
+ try await self.downloadAndVerifyFile(url: url, file: fileURL, checksum: nil)
348
+ return fileURL
349
+ }
350
+
351
+ private func downloadBundleFiles(url: String, hrefs: [String], directory: URL) async throws {
352
+ try await withThrowingTaskGroup(of: Void.self) { group in
353
+ for href in hrefs {
354
+ group.addTask {
355
+ _ = try await self.downloadBundleFile(baseUrl: url, href: href, directory: directory)
381
356
  }
382
- return
383
357
  }
384
- completion(response.fileURL, nil)
358
+
359
+ try await group.waitForAll()
385
360
  }
386
361
  }
387
362
 
388
- private func fetchLatestBundle(completion: @escaping (GetLatestBundleResponse?, Error?) -> Void) {
363
+ private func downloadBundleOfTypeManifest(bundleId: String, url: String) async throws {
364
+ // Create a temporary directory
365
+ let temporaryDirectory = try createTemporaryDirectory()
366
+ // Download the latest manifest
367
+ let latestManifestFile = try await downloadBundleFile(baseUrl: url, href: manifestFileName, directory: temporaryDirectory)
368
+ let latestManifest = try loadManifest(file: latestManifestFile)
369
+ // Load the current manifest
370
+ let currentManifest = try loadCurrentManifest()
371
+ // Compare the manifests
372
+ var itemsToCopy = [ManifestItem]()
373
+ var itemsToDownload = [ManifestItem]()
374
+ if let currentManifest = currentManifest {
375
+ itemsToCopy.append(contentsOf: Manifest.findDuplicateItems(latestManifest, currentManifest))
376
+ itemsToDownload.append(contentsOf: Manifest.findMissingItems(latestManifest, currentManifest))
377
+ } else {
378
+ itemsToDownload.append(contentsOf: latestManifest.items)
379
+ }
380
+ // Copy the files
381
+ try self.copyCurrentBundleFiles(hrefs: itemsToCopy.map { $0.href }, toDirectory: temporaryDirectory)
382
+ // Download the files
383
+ try await self.downloadBundleFiles(url: url, hrefs: itemsToDownload.map { $0.href }, directory: temporaryDirectory)
384
+ // Add the bundle
385
+ try await addBundleOfTypeManifest(bundleId: bundleId, directory: temporaryDirectory)
386
+ }
387
+
388
+ private func downloadBundleOfTypeZip(bundleId: String, url: String, checksum: String?) async throws {
389
+ let timestamp = String(Int(Date().timeIntervalSince1970))
390
+ let temporaryZipFileUrl = self.cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
391
+ // Download the bundle
392
+ try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum)
393
+ // Add the bundle
394
+ try await addBundleOfTypeZip(bundleId: bundleId, zipFile: temporaryZipFileUrl)
395
+ }
396
+
397
+ private func fetchLatestBundle() async throws -> GetLatestBundleResponse? {
389
398
  var parameters = [String: String]()
390
399
  parameters["appVersionCode"] = getVersionCode()
391
400
  parameters["appVersionName"] = getVersionName()
@@ -393,40 +402,29 @@ import CommonCrypto
393
402
  parameters["channelName"] = getChannel()
394
403
  parameters["customId"] = preferences.getCustomId()
395
404
  parameters["deviceId"] = getDeviceId()
396
- parameters["osVersion"] = UIDevice.current.systemVersion
405
+ parameters["osVersion"] = await UIDevice.current.systemVersion
397
406
  parameters["platform"] = "1"
398
407
  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
408
  var urlComponents = URLComponents(string: "https://\(host)/v1/apps/\(config.appId ?? "")/bundles/latest")!
406
409
  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))
416
- }
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)
410
+ let url = try urlComponents.asURL()
411
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Fetching latest bundle: ", url)
412
+ let response = try await self.httpClient.request(url: url, type: GetLatestBundleResponse.self)
413
+ if let data = response.data {
414
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Latest bundle response: ", String(decoding: data, as: UTF8.self))
415
+ }
416
+ if let error = response.error {
417
+ if let urlError = error.underlyingError as? URLError {
418
+ if urlError.code == .timedOut {
419
+ throw urlError
426
420
  }
427
- return
428
421
  }
429
- completion(response.value, nil)
422
+ return nil
423
+ }
424
+ if let value = response.value {
425
+ return value
426
+ } else {
427
+ return nil
430
428
  }
431
429
  }
432
430
 
@@ -455,6 +453,20 @@ import CommonCrypto
455
453
  return channel
456
454
  }
457
455
 
456
+ /// - Returns: The sha256 checksum of the file at the given URL.
457
+ private func getChecksumForFile(url: URL) throws -> String {
458
+ let handle = try FileHandle(forReadingFrom: url)
459
+ var hasher = SHA256()
460
+ while autoreleasepool(invoking: {
461
+ let nextChunk = handle.readData(ofLength: 2048)
462
+ guard !nextChunk.isEmpty else { return false }
463
+ hasher.update(data: nextChunk)
464
+ return true
465
+ }) { }
466
+ let digest = hasher.finalize()
467
+ return digest.map { String(format: "%02hhx", $0) }.joined()
468
+ }
469
+
458
470
  /// - Returns: The current bundle ID (`public` for the built-in bundle) or `nil` if no view controller was found.
459
471
  private func getCurrentBundleId() -> String? {
460
472
  guard let path = getCurrentCapacitorServerPath() else {
@@ -510,6 +522,36 @@ import CommonCrypto
510
522
  return FileManager.default.fileExists(atPath: path)
511
523
  }
512
524
 
525
+ private func loadCurrentManifest() throws -> Manifest? {
526
+ guard let currentBundleId = getCurrentBundleId() else {
527
+ return nil
528
+ }
529
+ if currentBundleId == defaultWebAssetDir {
530
+ let files = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: defaultWebAssetDir) ?? []
531
+ let manifestFileUrl = files.first { $0.lastPathComponent == manifestFileName }
532
+ if let manifestFileUrl = manifestFileUrl {
533
+ return try loadManifest(file: manifestFileUrl)
534
+ } else {
535
+ return nil
536
+ }
537
+ } else {
538
+ let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
539
+ let manifestFileExists = FileManager.default.fileExists(atPath: manifestFileUrl.path)
540
+ if manifestFileExists {
541
+ return try loadManifest(file: manifestFileUrl)
542
+ } else {
543
+ return nil
544
+ }
545
+ }
546
+ }
547
+
548
+ private func loadManifest(file: URL) throws -> Manifest {
549
+ let data = try Data(contentsOf: file)
550
+ let decoder = JSONDecoder()
551
+ let items = try decoder.decode([ManifestItem].self, from: data)
552
+ return Manifest(items: items)
553
+ }
554
+
513
555
  private func rollback() {
514
556
  if getCurrentBundleId() == defaultWebAssetDir {
515
557
  CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "App is not ready. Default bundle is already in use.")
@@ -598,13 +640,42 @@ import CommonCrypto
598
640
  rollbackDispatchWorkItem?.cancel()
599
641
  }
600
642
 
601
- private func unzipFile(zipFile: URL, completion: @escaping (URL) -> Void) {
643
+ private func unzipFile(zipFile: URL) -> URL {
602
644
  let destinationDirectory = zipFile.deletingPathExtension()
603
- DispatchQueue.background(background: {
604
- SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
605
- }, completion: {
606
- completion(destinationDirectory)
607
- })
645
+ SSZipArchive.unzipFile(atPath: zipFile.path, toDestination: destinationDirectory.path)
646
+ return destinationDirectory
647
+ }
648
+
649
+ private func verifyFile(url: URL, checksum: String?, signature: String?) throws {
650
+ // Verify the signature
651
+ if let publicKey = self.config.publicKey {
652
+ guard let signature = signature else {
653
+ throw CustomError.signatureMissing
654
+ }
655
+ // Verify the signature
656
+ let verified: Bool
657
+ do {
658
+ verified = try self.verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
659
+ } catch {
660
+ throw CustomError.signatureVerificationFailed
661
+ }
662
+ if !verified {
663
+ throw CustomError.signatureVerificationFailed
664
+ }
665
+ }
666
+ // Verify the checksum
667
+ else if let expectedChecksum = checksum {
668
+ // Calculate the checksum
669
+ let receivedChecksum: String
670
+ do {
671
+ receivedChecksum = try self.getChecksumForFile(url: url)
672
+ } catch {
673
+ throw CustomError.checksumCalculationFailed
674
+ }
675
+ if receivedChecksum != expectedChecksum {
676
+ throw CustomError.checksumMismatch
677
+ }
678
+ }
608
679
  }
609
680
 
610
681
  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
+ }