@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.
- package/README.md +46 -5
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +363 -251
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateHttpClient.java +71 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +65 -60
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/Manifest.java +58 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/ManifestItem.java +37 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +19 -27
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +15 -1
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +22 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/enums/ArtifactType.java +6 -0
- package/dist/docs.json +67 -4
- package/dist/esm/definitions.d.ts +42 -3
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.js +3 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +3 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +3 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Plugin/Classes/Manifest.swift +20 -0
- package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +11 -1
- package/ios/Plugin/Classes/Results/FetchLatestBundle.swift +16 -0
- package/ios/Plugin/Enums/ArtifactType.swift +4 -0
- package/ios/Plugin/{Classes → Enums}/CustomError.swift +3 -0
- package/ios/Plugin/LiveUpdate.swift +279 -198
- package/ios/Plugin/LiveUpdateHttpClient.swift +42 -0
- package/ios/Plugin/LiveUpdatePlugin.m +1 -0
- package/ios/Plugin/LiveUpdatePlugin.swift +77 -61
- package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -3
- package/ios/Plugin/Protocols/Api/ManifestItem.swift +5 -0
- 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
|
|
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
|
-
|
|
62
|
-
completion(error)
|
|
63
|
-
return
|
|
71
|
+
throw CustomError.bundleAlreadyExists
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
// Download the bundle
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
179
|
+
@objc public func sync() async throws -> SyncResult {
|
|
169
180
|
// Fetch the latest bundle
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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,
|
|
209
|
-
//
|
|
210
|
-
self.
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
216
|
+
// Create the bundles directory if it does not exist
|
|
217
|
+
self.createBundlesDirectory()
|
|
219
218
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
251
|
+
return url
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 ==
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if
|
|
311
|
-
|
|
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
|
|
361
|
-
let
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
368
|
+
|
|
369
|
+
try await group.waitForAll()
|
|
385
370
|
}
|
|
386
371
|
}
|
|
387
372
|
|
|
388
|
-
private func
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
CAPLog.print("[", LiveUpdatePlugin.tag, "]
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
653
|
+
private func unzipFile(zipFile: URL) -> URL {
|
|
602
654
|
let destinationDirectory = zipFile.deletingPathExtension()
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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);
|