@capawesome/cordova-live-update 0.1.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1113 -0
  3. package/dist/docs.json +1654 -0
  4. package/dist/esm/definitions.d.ts +788 -0
  5. package/dist/esm/definitions.js +7 -0
  6. package/dist/esm/definitions.js.map +1 -0
  7. package/dist/esm/exec.d.ts +1 -0
  8. package/dist/esm/exec.js +8 -0
  9. package/dist/esm/exec.js.map +1 -0
  10. package/dist/esm/index.d.ts +4 -0
  11. package/dist/esm/index.js +46 -0
  12. package/dist/esm/index.js.map +1 -0
  13. package/dist/plugin.js +56 -0
  14. package/dist/plugin.js.map +1 -0
  15. package/package.json +93 -0
  16. package/plugin.xml +268 -0
  17. package/src/android/capawesome-cordova-live-update.gradle +12 -0
  18. package/src/android/capawesome-live-update.xml +5 -0
  19. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdate.java +1480 -0
  20. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateConfig.java +105 -0
  21. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateHttpClient.java +114 -0
  22. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePathHandler.java +96 -0
  23. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePlugin.java +550 -0
  24. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePreferences.java +151 -0
  25. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/Manifest.java +58 -0
  26. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/ManifestItem.java +37 -0
  27. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetChannelsResponseItem.java +28 -0
  28. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +74 -0
  29. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +33 -0
  30. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/NextBundleSetEvent.java +26 -0
  31. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DeleteBundleOptions.java +18 -0
  32. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DownloadBundleOptions.java +66 -0
  33. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchChannelsOptions.java +39 -0
  34. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +25 -0
  35. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetChannelOptions.java +18 -0
  36. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetConfigOptions.java +20 -0
  37. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetCustomIdOptions.java +18 -0
  38. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetNextBundleOptions.java +21 -0
  39. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SyncOptions.java +25 -0
  40. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ChannelResult.java +29 -0
  41. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchChannelsResult.java +29 -0
  42. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +69 -0
  43. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +28 -0
  44. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBundlesResult.java +28 -0
  45. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetChannelResult.java +22 -0
  46. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  47. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCurrentBundleResult.java +22 -0
  48. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCustomIdResult.java +22 -0
  49. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDeviceIdResult.java +22 -0
  50. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +28 -0
  51. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetNextBundleResult.java +22 -0
  52. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionCodeResult.java +21 -0
  53. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionNameResult.java +22 -0
  54. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/IsSyncingResult.java +27 -0
  55. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  56. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/SyncResult.java +22 -0
  57. package/src/android/io/capawesome/cordova/plugins/liveupdate/enums/ArtifactType.java +6 -0
  58. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Callback.java +5 -0
  59. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  60. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/EmptyCallback.java +5 -0
  61. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/NonEmptyCallback.java +7 -0
  62. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Result.java +8 -0
  63. package/src/ios/LiveUpdate.swift +895 -0
  64. package/src/ios/LiveUpdateArtifactType.swift +4 -0
  65. package/src/ios/LiveUpdateChannelResult.swift +18 -0
  66. package/src/ios/LiveUpdateConfig.swift +11 -0
  67. package/src/ios/LiveUpdateDeleteBundleOptions.swift +13 -0
  68. package/src/ios/LiveUpdateDownloadBundleOptions.swift +41 -0
  69. package/src/ios/LiveUpdateDownloadBundleProgressEvent.swift +24 -0
  70. package/src/ios/LiveUpdateError.swift +62 -0
  71. package/src/ios/LiveUpdateFetchChannelsOptions.swift +25 -0
  72. package/src/ios/LiveUpdateFetchChannelsResult.swift +15 -0
  73. package/src/ios/LiveUpdateFetchLatestBundleOptions.swift +17 -0
  74. package/src/ios/LiveUpdateFetchLatestBundleResult.swift +42 -0
  75. package/src/ios/LiveUpdateGetBlockedBundlesResult.swift +15 -0
  76. package/src/ios/LiveUpdateGetBundlesResult.swift +15 -0
  77. package/src/ios/LiveUpdateGetChannelResult.swift +15 -0
  78. package/src/ios/LiveUpdateGetChannelsResponseItem.swift +4 -0
  79. package/src/ios/LiveUpdateGetConfigResult.swift +18 -0
  80. package/src/ios/LiveUpdateGetCurrentBundleResult.swift +15 -0
  81. package/src/ios/LiveUpdateGetCustomIdResult.swift +15 -0
  82. package/src/ios/LiveUpdateGetDeviceIdResult.swift +15 -0
  83. package/src/ios/LiveUpdateGetDownloadedBundlesResult.swift +15 -0
  84. package/src/ios/LiveUpdateGetLatestBundleResponse.swift +8 -0
  85. package/src/ios/LiveUpdateGetNextBundleResult.swift +15 -0
  86. package/src/ios/LiveUpdateGetVersionCodeResult.swift +15 -0
  87. package/src/ios/LiveUpdateGetVersionNameResult.swift +15 -0
  88. package/src/ios/LiveUpdateHttpClient.swift +58 -0
  89. package/src/ios/LiveUpdateIsSyncingResult.swift +15 -0
  90. package/src/ios/LiveUpdateManifest.swift +19 -0
  91. package/src/ios/LiveUpdateManifestItem.swift +5 -0
  92. package/src/ios/LiveUpdateNextBundleSetEvent.swift +15 -0
  93. package/src/ios/LiveUpdatePlugin.swift +521 -0
  94. package/src/ios/LiveUpdatePreferences.swift +116 -0
  95. package/src/ios/LiveUpdateReadyResult.swift +21 -0
  96. package/src/ios/LiveUpdateResult.swift +5 -0
  97. package/src/ios/LiveUpdateSchemeHandler.swift +286 -0
  98. package/src/ios/LiveUpdateSetChannelOptions.swift +13 -0
  99. package/src/ios/LiveUpdateSetConfigOptions.swift +13 -0
  100. package/src/ios/LiveUpdateSetCustomIdOptions.swift +13 -0
  101. package/src/ios/LiveUpdateSetNextBundleOptions.swift +13 -0
  102. package/src/ios/LiveUpdateSyncOptions.swift +17 -0
  103. package/src/ios/LiveUpdateSyncResult.swift +15 -0
@@ -0,0 +1,895 @@
1
+ import Foundation
2
+ import CryptoKit
3
+ import ZIPFoundation
4
+ import Alamofire
5
+ import CommonCrypto
6
+ import UIKit
7
+
8
+ // swiftlint:disable type_body_length
9
+ @objc public class LiveUpdate: NSObject {
10
+ private let autoUpdateIntervalMs: Int64 = 15 * 60 * 1000 // 15 minutes
11
+ private let bundlesDirectory = "NoCloud/capawesome_live_update_bundles" // DO NOT CHANGE!
12
+ private let cachesDirectoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
13
+ private let config: LiveUpdateConfig
14
+ private let defaultWebAssetDir = "www" // Cordova builds place web assets under "www/" inside the app bundle.
15
+ private let httpClient: LiveUpdateHttpClient
16
+ private let libraryDirectoryUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
17
+ private let manifestFileName = "capawesome-live-update-manifest.json" // DO NOT CHANGE!
18
+ private let plugin: LiveUpdatePlugin
19
+ private let preferences: LiveUpdatePreferences
20
+ private let schemeHandler: LiveUpdateSchemeHandler
21
+
22
+ private var rollbackDispatchWorkItem: DispatchWorkItem?
23
+ private var rollbackPerformed = false
24
+ private var lastAutoUpdateCheckTimestamp: Int64 = 0
25
+ private var syncInProgress = false
26
+
27
+ init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin, schemeHandler: LiveUpdateSchemeHandler) {
28
+ self.config = config
29
+ self.httpClient = LiveUpdateHttpClient(config: config)
30
+ self.plugin = plugin
31
+ self.preferences = LiveUpdatePreferences()
32
+ self.schemeHandler = schemeHandler
33
+ super.init()
34
+
35
+ // Check version and reset config (and the next bundle) if the native
36
+ // app version changed. This must run before promoting the next bundle
37
+ // below so that a stale, now-incompatible bundle is not activated.
38
+ checkAndResetConfigIfVersionChanged()
39
+
40
+ // Promote the persisted next bundle to active for this session. This
41
+ // matches Capacitor's launch-time behavior where the persisted next
42
+ // path becomes the current server base path on each cold start.
43
+ if let nextBundleId = preferences.getNextBundleId(), hasBundleById(nextBundleId) {
44
+ schemeHandler.activeBundleDir = buildBundleURLFor(bundleId: nextBundleId)
45
+ }
46
+
47
+ // Set the device ID on the HTTP client (after any potential config reset)
48
+ self.httpClient.setDeviceId(getDeviceId())
49
+
50
+ // Start the rollback timer to rollback to the default bundle
51
+ // if the app is not ready after a certain time
52
+ startRollbackTimer()
53
+ }
54
+
55
+ @objc public func clearBlockedBundles() {
56
+ preferences.setBlockedBundleIds(nil)
57
+ }
58
+
59
+ @objc public func deleteBundle(_ options: LiveUpdateDeleteBundleOptions, completion: @escaping (Error?) -> Void) {
60
+ let bundleId = options.getBundleId()
61
+
62
+ if !hasBundleById(bundleId) {
63
+ completion(LiveUpdateError.bundleNotFound)
64
+ return
65
+ }
66
+
67
+ do {
68
+ try deleteBundleById(bundleId)
69
+ completion(nil)
70
+ } catch {
71
+ completion(error)
72
+ }
73
+ }
74
+
75
+ @objc public func downloadBundle(_ options: LiveUpdateDownloadBundleOptions) async throws {
76
+ let artifactType = options.getArtifactType()
77
+ let bundleId = options.getBundleId()
78
+ let checksum = options.getChecksum()
79
+ let signature = options.getSignature()
80
+ let url = options.getUrl()
81
+
82
+ if hasBundleById(bundleId) {
83
+ throw LiveUpdateError.bundleAlreadyExists
84
+ }
85
+
86
+ if artifactType == .manifest {
87
+ try await downloadBundleOfTypeManifest(bundleId: bundleId, url: url)
88
+ } else {
89
+ try await downloadBundleOfTypeZip(bundleId: bundleId, checksum: checksum, signature: signature, url: url)
90
+ }
91
+ }
92
+
93
+ @objc public func fetchChannels(_ options: LiveUpdateFetchChannelsOptions) async throws -> LiveUpdateFetchChannelsResult {
94
+ var parameters = [String: String]()
95
+ if let limit = options.getLimit() {
96
+ parameters["limit"] = String(limit)
97
+ }
98
+ if let offset = options.getOffset() {
99
+ parameters["offset"] = String(offset)
100
+ }
101
+ if let query = options.getQuery() {
102
+ parameters["query"] = query
103
+ }
104
+ var urlComponents = URLComponents(string: "https://\(config.serverDomain)/v1/apps/\(getAppId() ?? "")/channels")!
105
+ if !parameters.isEmpty {
106
+ urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
107
+ }
108
+ let url = try urlComponents.asURL()
109
+ let response = try await self.httpClient.request(url: url, type: [LiveUpdateGetChannelsResponseItem].self)
110
+ if let error = response.error {
111
+ if response.response?.statusCode == 401 {
112
+ throw LiveUpdateError.channelDiscoveryNotEnabled
113
+ }
114
+ if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut {
115
+ throw urlError
116
+ }
117
+ throw error
118
+ }
119
+ let items = response.value ?? []
120
+ let channels = items.map { LiveUpdateChannelResult(id: $0.id, name: $0.name) }
121
+ return LiveUpdateFetchChannelsResult(channels: channels)
122
+ }
123
+
124
+ @objc public func fetchLatestBundle(_ options: LiveUpdateFetchLatestBundleOptions) async throws -> LiveUpdateFetchLatestBundleResult {
125
+ let response: LiveUpdateGetLatestBundleResponse? = try await self.fetchLatestBundle(options)
126
+ return LiveUpdateFetchLatestBundleResult(
127
+ artifactType: response?.artifactType,
128
+ bundleId: response?.bundleId,
129
+ checksum: response?.checksum,
130
+ customProperties: response?.customProperties,
131
+ downloadUrl: response?.url,
132
+ signature: response?.signature
133
+ )
134
+ }
135
+
136
+ @objc public func getBlockedBundles(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
137
+ var bundleIds: [String] = []
138
+ if let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty {
139
+ bundleIds = blockedIds.split(separator: ",").map(String.init)
140
+ }
141
+ completion(LiveUpdateGetBlockedBundlesResult(bundleIds: bundleIds), nil)
142
+ }
143
+
144
+ @objc public func getBundles(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
145
+ completion(LiveUpdateGetBundlesResult(bundleIds: getDownloadedBundleIds()), nil)
146
+ }
147
+
148
+ @objc public func getDownloadedBundles(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
149
+ completion(LiveUpdateGetDownloadedBundlesResult(bundleIds: getDownloadedBundleIds()), nil)
150
+ }
151
+
152
+ @objc public func getChannel(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
153
+ completion(LiveUpdateGetChannelResult(channel: getChannel()), nil)
154
+ }
155
+
156
+ @objc public func getConfig(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
157
+ completion(LiveUpdateGetConfigResult(appId: getAppId(), autoUpdateStrategy: config.autoUpdateStrategy), nil)
158
+ }
159
+
160
+ @objc public func getCurrentBundle(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
161
+ completion(LiveUpdateGetCurrentBundleResult(bundleId: getCurrentBundleId()), nil)
162
+ }
163
+
164
+ @objc public func getCustomId(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
165
+ completion(LiveUpdateGetCustomIdResult(customId: preferences.getCustomId()), nil)
166
+ }
167
+
168
+ @objc public func getDeviceId(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
169
+ completion(LiveUpdateGetDeviceIdResult(deviceId: getDeviceId()), nil)
170
+ }
171
+
172
+ @objc public func getNextBundle(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
173
+ completion(LiveUpdateGetNextBundleResult(bundleId: getNextBundleId()), nil)
174
+ }
175
+
176
+ @objc public func getVersionCode(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
177
+ completion(LiveUpdateGetVersionCodeResult(versionCode: getVersionCode()), nil)
178
+ }
179
+
180
+ @objc public func getVersionName(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
181
+ completion(LiveUpdateGetVersionNameResult(versionName: getVersionName()), nil)
182
+ }
183
+
184
+ @objc public func isSyncing(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
185
+ completion(LiveUpdateIsSyncingResult(syncing: syncInProgress), nil)
186
+ }
187
+
188
+ @objc public func handleLoad() {
189
+ if config.autoUpdateStrategy == "background" {
190
+ performAutoUpdate()
191
+ }
192
+ }
193
+
194
+ @objc public func handleAppWillEnterForeground() {
195
+ if config.autoUpdateStrategy == "background" {
196
+ performAutoUpdate()
197
+ }
198
+ }
199
+
200
+ @objc public func ready(completion: @escaping (LiveUpdateResult?, Error?) -> Void) {
201
+ NSLog("[\(LiveUpdatePlugin.tag)] App is ready.")
202
+ if config.readyTimeout <= 0 {
203
+ NSLog("[\(LiveUpdatePlugin.tag)] Ready timeout is set to 0. Automatic rollback is disabled.")
204
+ }
205
+ stopRollbackTimer()
206
+ if config.autoDeleteBundles {
207
+ deleteUnusedBundles()
208
+ }
209
+ let currentBundleId = getCurrentBundleId()
210
+ let previousBundleId = getPreviousBundleId()
211
+ if config.autoBlockRolledBackBundles && rollbackPerformed, let previousBundleId = previousBundleId {
212
+ addBlockedBundleId(previousBundleId)
213
+ }
214
+ let result = LiveUpdateReadyResult(currentBundleId: currentBundleId, previousBundleId: previousBundleId, rollback: rollbackPerformed)
215
+ completion(result, nil)
216
+ setPreviousBundleId(bundleId: currentBundleId)
217
+ rollbackPerformed = false
218
+ }
219
+
220
+ @objc public func reload() {
221
+ let nextBundleId = getNextBundleId()
222
+ setCurrentBundleById(nextBundleId)
223
+ startRollbackTimer()
224
+ }
225
+
226
+ @objc public func reset() {
227
+ setNextBundleById(nil)
228
+ }
229
+
230
+ @objc public func resetConfig() {
231
+ preferences.setAppId(nil)
232
+ }
233
+
234
+ @objc public func setChannel(_ options: LiveUpdateSetChannelOptions, completion: @escaping (Error?) -> Void) {
235
+ preferences.setChannel(options.getChannel())
236
+ completion(nil)
237
+ }
238
+
239
+ @objc public func setConfig(_ options: LiveUpdateSetConfigOptions) {
240
+ preferences.setAppId(options.getAppId())
241
+ }
242
+
243
+ @objc public func setCustomId(_ options: LiveUpdateSetCustomIdOptions, completion: @escaping (Error?) -> Void) {
244
+ preferences.setCustomId(options.getCustomId())
245
+ completion(nil)
246
+ }
247
+
248
+ @objc public func setNextBundle(_ options: LiveUpdateSetNextBundleOptions, completion: @escaping (Error?) -> Void) {
249
+ let bundleId = options.getBundleId()
250
+ if let bundleId = bundleId {
251
+ if hasBundleById(bundleId) {
252
+ setNextBundleById(bundleId)
253
+ } else {
254
+ completion(LiveUpdateError.bundleNotFound)
255
+ return
256
+ }
257
+ } else {
258
+ reset()
259
+ }
260
+ completion(nil)
261
+ }
262
+
263
+ @objc public func sync(_ options: LiveUpdateSyncOptions) async throws -> LiveUpdateSyncResult {
264
+ if syncInProgress {
265
+ throw LiveUpdateError.syncInProgress
266
+ }
267
+ syncInProgress = true
268
+ defer { syncInProgress = false }
269
+
270
+ let channel = options.getChannel()
271
+ let fetchLatestBundleOptions = LiveUpdateFetchLatestBundleOptions(channel: channel)
272
+ guard let response = try await fetchLatestBundle(fetchLatestBundleOptions) else {
273
+ NSLog("[\(LiveUpdatePlugin.tag)] No update available.")
274
+ return LiveUpdateSyncResult(nextBundleId: nil)
275
+ }
276
+ let artifactType = response.artifactType
277
+ let latestBundleId = response.bundleId
278
+ let checksum = response.checksum
279
+ let signature = response.signature
280
+ let downloadUrl = response.url
281
+ if isBlockedBundleId(latestBundleId) {
282
+ NSLog("[\(LiveUpdatePlugin.tag)] Bundle is blocked and will not be downloaded.")
283
+ return LiveUpdateSyncResult(nextBundleId: nil)
284
+ }
285
+ if hasBundleById(latestBundleId) {
286
+ var nextBundleId: String?
287
+ let currentBundleId = getCurrentBundleId()
288
+ if latestBundleId != currentBundleId {
289
+ setNextBundleById(latestBundleId)
290
+ nextBundleId = latestBundleId
291
+ }
292
+ return LiveUpdateSyncResult(nextBundleId: nextBundleId)
293
+ }
294
+ if artifactType == .manifest {
295
+ try await downloadBundleOfTypeManifest(bundleId: latestBundleId, url: downloadUrl)
296
+ } else {
297
+ try await downloadBundleOfTypeZip(bundleId: latestBundleId, checksum: checksum, signature: signature, url: downloadUrl)
298
+ }
299
+ setNextBundleById(latestBundleId)
300
+ return LiveUpdateSyncResult(nextBundleId: latestBundleId)
301
+ }
302
+
303
+ // MARK: - Private helpers
304
+
305
+ private func addBundle(bundleId: String, directory: URL) throws {
306
+ guard let indexHtmlFile = searchIndexHtmlFile(url: directory) else {
307
+ throw LiveUpdateError.bundleIndexHtmlMissing
308
+ }
309
+ createBundlesDirectory()
310
+ let bundlePath = buildBundlePathFor(bundleId: bundleId)
311
+ try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
312
+ }
313
+
314
+ private func addBundleOfTypeManifest(bundleId: String, directory: URL) async throws {
315
+ try addBundle(bundleId: bundleId, directory: directory)
316
+ }
317
+
318
+ private func addBundleOfTypeZip(bundleId: String, zipFile: URL) async throws {
319
+ let unzippedDirectory = try unzipFile(zipFile: zipFile)
320
+ try addBundle(bundleId: bundleId, directory: unzippedDirectory)
321
+ }
322
+
323
+ private func buildBundlePathFor(bundleId: String) -> String {
324
+ return buildBundleURLFor(bundleId: bundleId).path
325
+ }
326
+
327
+ private func buildBundleURLFor(bundleId: String) -> URL {
328
+ return libraryDirectoryUrl.appendingPathComponent(bundlesDirectory).appendingPathComponent(bundleId)
329
+ }
330
+
331
+ private func copyCurrentBundleFile(fileToCopy: LiveUpdateManifestItem, toDirectory: URL) throws {
332
+ let currentBundleId = getCurrentBundleId()
333
+ let destination = toDirectory.appendingPathComponent(fileToCopy.href)
334
+ let parentDirectory = destination.deletingLastPathComponent()
335
+ try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
336
+
337
+ let sourceURL: URL
338
+ if let currentBundleId = currentBundleId {
339
+ sourceURL = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(fileToCopy.href)
340
+ } else {
341
+ guard let file = Bundle.main.url(forResource: fileToCopy.href, withExtension: nil, subdirectory: defaultWebAssetDir) else {
342
+ throw LiveUpdateError.unknown
343
+ }
344
+ sourceURL = file
345
+ }
346
+
347
+ try FileManager.default.copyItem(at: sourceURL, to: destination)
348
+ }
349
+
350
+ private func copyCurrentBundleFilesAndReturnFailures(
351
+ filesToCopy: [LiveUpdateManifestItem],
352
+ toDirectory: URL
353
+ ) -> [LiveUpdateManifestItem] {
354
+ var missingItems = [LiveUpdateManifestItem]()
355
+ for fileToCopy in filesToCopy {
356
+ if !tryCopyCurrentBundleFile(fileToCopy: fileToCopy, toDirectory: toDirectory) {
357
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to copy file: \(fileToCopy.href)")
358
+ missingItems.append(fileToCopy)
359
+ }
360
+ }
361
+ return missingItems
362
+ }
363
+
364
+ private func createBundlesDirectory() {
365
+ let bundlesDirectoryUrl = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
366
+ let exists = FileManager.default.fileExists(atPath: bundlesDirectoryUrl.path)
367
+ if !exists {
368
+ do {
369
+ try FileManager.default.createDirectory(at: bundlesDirectoryUrl, withIntermediateDirectories: true, attributes: nil)
370
+ } catch {
371
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to create bundles directory.")
372
+ }
373
+ }
374
+ }
375
+
376
+ private func createTemporaryDirectory() throws -> URL {
377
+ let temporaryDirectory = cachesDirectoryUrl.appendingPathComponent(UUID().uuidString)
378
+ try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
379
+ return temporaryDirectory
380
+ }
381
+
382
+ private func deleteBundleById(_ bundleId: String) throws {
383
+ let path = buildBundlePathFor(bundleId: bundleId)
384
+ try FileManager.default.removeItem(atPath: path)
385
+ if bundleId == getNextBundleId() {
386
+ setNextBundleById(nil)
387
+ }
388
+ }
389
+
390
+ private func deleteUnusedBundles() {
391
+ let bundleIds = getDownloadedBundleIds()
392
+ let currentBundleId = getCurrentBundleId()
393
+ let nextBundleId = getNextBundleId()
394
+ for bundleId in bundleIds where bundleId != currentBundleId && bundleId != nextBundleId {
395
+ do {
396
+ try deleteBundleById(bundleId)
397
+ } catch {
398
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to delete bundle with id: \(bundleId)")
399
+ }
400
+ }
401
+ }
402
+
403
+ private func downloadAndVerifyFile(url: String, file: URL, checksum: String?, signature: String?, callback: ((Progress) -> Void)?) async throws {
404
+ let destination: DownloadRequest.Destination = { _, _ in
405
+ return (file, [.createIntermediateDirectories])
406
+ }
407
+ let urlComponents = URLComponents(string: url)!
408
+ let result = try await httpClient.download(url: urlComponents.asURL(), destination: destination, callback: callback)
409
+ if let error = result.error {
410
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to download file: \(error)")
411
+ if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut {
412
+ throw urlError
413
+ }
414
+ throw LiveUpdateError.downloadFailed
415
+ }
416
+ guard let response = result.response else {
417
+ throw LiveUpdateError.unknown
418
+ }
419
+ let resolvedChecksum = checksum ?? LiveUpdateHttpClient.getChecksumFromResponse(response: response)
420
+ let resolvedSignature = signature ?? LiveUpdateHttpClient.getSignatureFromResponse(response: response)
421
+ try verifyFile(url: file, checksum: resolvedChecksum, signature: resolvedSignature)
422
+ }
423
+
424
+ private func downloadBundleFile(baseUrl: String, href: String, directory: URL, callback: ((Progress) -> Void)?) async throws -> URL {
425
+ let fileURL = directory.appendingPathComponent(href)
426
+ var urlComponents = URLComponents(string: baseUrl)!
427
+ urlComponents.queryItems = [URLQueryItem(name: "href", value: href)]
428
+ let url = urlComponents.string!
429
+ try await downloadAndVerifyFile(url: url, file: fileURL, checksum: nil, signature: nil, callback: callback)
430
+ return fileURL
431
+ }
432
+
433
+ private func downloadBundleFiles(url: String, filesToDownload: [LiveUpdateManifestItem], directory: URL, callback: ((Progress) -> Void)?) async throws {
434
+ let totalBytesToDownload = Int64(filesToDownload.map { $0.sizeInBytes }.reduce(0, +))
435
+ actor TotalBytesDownloaded {
436
+ var value: Int64 = 0
437
+ func add(_ amount: Int64) {
438
+ value += amount
439
+ }
440
+ }
441
+ let totalBytesDownloaded = TotalBytesDownloaded()
442
+ try await withThrowingTaskGroup(of: Void.self) { group in
443
+ for fileToDownload in filesToDownload {
444
+ group.addTask {
445
+ _ = try await self.downloadBundleFile(baseUrl: url, href: fileToDownload.href, directory: directory, callback: { progress in
446
+ Task {
447
+ if let callback = callback {
448
+ let total = await totalBytesDownloaded.value
449
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: progress.completedUnitCount + total)
450
+ callback(totalProgress)
451
+ }
452
+ }
453
+ })
454
+ if let callback = callback {
455
+ await totalBytesDownloaded.add(Int64(fileToDownload.sizeInBytes))
456
+ let total = await totalBytesDownloaded.value
457
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: total)
458
+ callback(totalProgress)
459
+ }
460
+ }
461
+ }
462
+ try await group.waitForAll()
463
+ if let callback = callback {
464
+ let totalProgress = Progress(totalUnitCount: totalBytesToDownload, completedUnitCount: totalBytesToDownload)
465
+ callback(totalProgress)
466
+ }
467
+ }
468
+ }
469
+
470
+ private func downloadBundleOfTypeManifest(bundleId: String, url: String) async throws {
471
+ let temporaryDirectory = try createTemporaryDirectory()
472
+ let latestManifestFile = try await downloadBundleFile(baseUrl: url, href: manifestFileName, directory: temporaryDirectory, callback: nil)
473
+ let latestManifest = try loadManifest(file: latestManifestFile)
474
+ let currentManifest = try loadCurrentManifest()
475
+ var itemsToCopy = [LiveUpdateManifestItem]()
476
+ var itemsToDownload = [LiveUpdateManifestItem]()
477
+ if let currentManifest = currentManifest {
478
+ itemsToCopy.append(contentsOf: LiveUpdateManifest.findDuplicateItems(latestManifest, currentManifest))
479
+ itemsToDownload.append(contentsOf: LiveUpdateManifest.findMissingItems(latestManifest, currentManifest))
480
+ } else {
481
+ itemsToDownload.append(contentsOf: latestManifest.items)
482
+ }
483
+ let missingItems = copyCurrentBundleFilesAndReturnFailures(filesToCopy: itemsToCopy, toDirectory: temporaryDirectory)
484
+ if !missingItems.isEmpty {
485
+ itemsToDownload.append(contentsOf: missingItems)
486
+ }
487
+ try await downloadBundleFiles(url: url, filesToDownload: itemsToDownload, directory: temporaryDirectory, callback: { progress in
488
+ let event = LiveUpdateDownloadBundleProgressEvent(bundleId: bundleId, downloadedBytes: progress.completedUnitCount, totalBytes: progress.totalUnitCount)
489
+ self.notifyDownloadBundleProgressListeners(event)
490
+ })
491
+ try await addBundleOfTypeManifest(bundleId: bundleId, directory: temporaryDirectory)
492
+ }
493
+
494
+ private func downloadBundleOfTypeZip(bundleId: String, checksum: String?, signature: String?, url: String) async throws {
495
+ let timestamp = String(Int(Date().timeIntervalSince1970))
496
+ let temporaryZipFileUrl = cachesDirectoryUrl.appendingPathComponent(timestamp + ".zip")
497
+ try await downloadAndVerifyFile(url: url, file: temporaryZipFileUrl, checksum: checksum, signature: signature, callback: { progress in
498
+ let event = LiveUpdateDownloadBundleProgressEvent(bundleId: bundleId, downloadedBytes: progress.completedUnitCount, totalBytes: progress.totalUnitCount)
499
+ self.notifyDownloadBundleProgressListeners(event)
500
+ })
501
+ try await addBundleOfTypeZip(bundleId: bundleId, zipFile: temporaryZipFileUrl)
502
+ }
503
+
504
+ private func fetchLatestBundle(_ options: LiveUpdateFetchLatestBundleOptions) async throws -> LiveUpdateGetLatestBundleResponse? {
505
+ let channel = options.getChannel() ?? getChannel()
506
+ var parameters = [String: String]()
507
+ parameters["appVersionCode"] = getVersionCode()
508
+ parameters["appVersionName"] = getVersionName()
509
+ parameters["bundleId"] = getCurrentBundleId()
510
+ parameters["channelName"] = channel
511
+ parameters["customId"] = preferences.getCustomId()
512
+ parameters["deviceId"] = getDeviceId()
513
+ parameters["osVersion"] = await UIDevice.current.systemVersion
514
+ parameters["platform"] = "1"
515
+ parameters["pluginVersion"] = LiveUpdatePlugin.version
516
+ parameters["runtime"] = "cordova"
517
+ var urlComponents = URLComponents(string: "https://\(config.serverDomain)/v1/apps/\(getAppId() ?? "")/bundles/latest")!
518
+ urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
519
+ let url = try urlComponents.asURL()
520
+ NSLog("[\(LiveUpdatePlugin.tag)] Fetching latest bundle: \(url)")
521
+ let response = try await self.httpClient.request(url: url, type: LiveUpdateGetLatestBundleResponse.self)
522
+ if let data = response.data {
523
+ NSLog("[\(LiveUpdatePlugin.tag)] Latest bundle response: \(String(decoding: data, as: UTF8.self))")
524
+ }
525
+ if let error = response.error {
526
+ if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut {
527
+ throw urlError
528
+ }
529
+ return nil
530
+ }
531
+ return response.value
532
+ }
533
+
534
+ private func getDownloadedBundleIds() -> [String] {
535
+ let url = libraryDirectoryUrl.appendingPathComponent(bundlesDirectory)
536
+ do {
537
+ guard FileManager.default.fileExists(atPath: url.path) else { return [] }
538
+ return try FileManager.default.contentsOfDirectory(atPath: url.path)
539
+ } catch {
540
+ return []
541
+ }
542
+ }
543
+
544
+ private func getAppId() -> String? {
545
+ if let appId = preferences.getAppId() {
546
+ return appId
547
+ }
548
+ return config.appId
549
+ }
550
+
551
+ private func getChannel() -> String? {
552
+ var channel: String?
553
+ if let defaultChannel = config.defaultChannel {
554
+ channel = defaultChannel
555
+ }
556
+ if let nativeChannel = getNativeChannel() {
557
+ channel = nativeChannel
558
+ }
559
+ if let preferencesChannel = preferences.getChannel() {
560
+ channel = preferencesChannel
561
+ }
562
+ return channel
563
+ }
564
+
565
+ private func getNativeChannel() -> String? {
566
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "CapawesomeLiveUpdateDefaultChannel") as? String else {
567
+ return nil
568
+ }
569
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
570
+ return trimmed.isEmpty ? nil : trimmed
571
+ }
572
+
573
+ private func getChecksumForFile(url: URL) throws -> String {
574
+ let handle = try FileHandle(forReadingFrom: url)
575
+ var hasher = SHA256()
576
+ while autoreleasepool(invoking: {
577
+ let nextChunk = handle.readData(ofLength: 2048)
578
+ guard !nextChunk.isEmpty else { return false }
579
+ hasher.update(data: nextChunk)
580
+ return true
581
+ }) {}
582
+ let digest = hasher.finalize()
583
+ return digest.map { String(format: "%02hhx", $0) }.joined()
584
+ }
585
+
586
+ /// - Returns: The current bundle ID or `nil` if the default bundle is in use.
587
+ private func getCurrentBundleId() -> String? {
588
+ guard let dir = schemeHandler.activeBundleDir else {
589
+ return nil
590
+ }
591
+ return dir.lastPathComponent
592
+ }
593
+
594
+ private func getDeviceId() -> String {
595
+ let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? ""
596
+ return deviceId.lowercased()
597
+ }
598
+
599
+ /// - Returns: The next bundle ID or `nil` if the default bundle will be used.
600
+ private func getNextBundleId() -> String? {
601
+ return preferences.getNextBundleId()
602
+ }
603
+
604
+ private func getPreviousBundleId() -> String? {
605
+ return preferences.getPreviousBundleId()
606
+ }
607
+
608
+ private func getVersionCode() -> String {
609
+ return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
610
+ }
611
+
612
+ private func getVersionName() -> String {
613
+ return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
614
+ }
615
+
616
+ private func hasBundleById(_ bundleId: String) -> Bool {
617
+ return FileManager.default.fileExists(atPath: buildBundlePathFor(bundleId: bundleId))
618
+ }
619
+
620
+ private func loadCurrentManifest() throws -> LiveUpdateManifest? {
621
+ if let currentBundleId = getCurrentBundleId() {
622
+ let manifestFileUrl = buildBundleURLFor(bundleId: currentBundleId).appendingPathComponent(manifestFileName)
623
+ if FileManager.default.fileExists(atPath: manifestFileUrl.path) {
624
+ return try loadManifest(file: manifestFileUrl)
625
+ }
626
+ return nil
627
+ } else {
628
+ let files = Bundle.main.urls(forResourcesWithExtension: nil, subdirectory: defaultWebAssetDir) ?? []
629
+ if let manifestFileUrl = files.first(where: { $0.lastPathComponent == manifestFileName }) {
630
+ return try loadManifest(file: manifestFileUrl)
631
+ }
632
+ return nil
633
+ }
634
+ }
635
+
636
+ private func loadManifest(file: URL) throws -> LiveUpdateManifest {
637
+ let data = try Data(contentsOf: file)
638
+ let items = try JSONDecoder().decode([LiveUpdateManifestItem].self, from: data)
639
+ return LiveUpdateManifest(items: items)
640
+ }
641
+
642
+ private func notifyDownloadBundleProgressListeners(_ event: LiveUpdateDownloadBundleProgressEvent) {
643
+ plugin.notifyDownloadBundleProgressListeners(event)
644
+ }
645
+
646
+ private func performAutoUpdate() {
647
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
648
+ if lastAutoUpdateCheckTimestamp > 0 && (now - lastAutoUpdateCheckTimestamp) < autoUpdateIntervalMs {
649
+ NSLog("[\(LiveUpdatePlugin.tag)] Auto-update skipped. Last check was less than 15 minutes ago.")
650
+ return
651
+ }
652
+
653
+ if (getAppId() ?? "").isEmpty {
654
+ NSLog("[\(LiveUpdatePlugin.tag)] Auto-update skipped. appId is not configured.")
655
+ return
656
+ }
657
+
658
+ lastAutoUpdateCheckTimestamp = now
659
+
660
+ Task {
661
+ do {
662
+ NSLog("[\(LiveUpdatePlugin.tag)] Auto-update started.")
663
+ _ = try await sync(LiveUpdateSyncOptions(channel: nil))
664
+ NSLog("[\(LiveUpdatePlugin.tag)] Auto-update completed successfully.")
665
+ } catch {
666
+ NSLog("[\(LiveUpdatePlugin.tag)] Auto-update failed: \(error.localizedDescription)")
667
+ }
668
+ }
669
+ }
670
+
671
+ private func rollback() {
672
+ rollbackPerformed = true
673
+ let currentBundleId = getCurrentBundleId()
674
+ setPreviousBundleId(bundleId: currentBundleId)
675
+ if currentBundleId != nil {
676
+ NSLog("[\(LiveUpdatePlugin.tag)] App is not ready. Rolling back to default bundle.")
677
+ setNextBundleById(nil)
678
+ setCurrentBundleById(nil)
679
+ } else {
680
+ NSLog("[\(LiveUpdatePlugin.tag)] App is not ready. Default bundle is already in use.")
681
+ }
682
+ }
683
+
684
+ private func searchIndexHtmlFile(url: URL) -> URL? {
685
+ do {
686
+ let directoryContents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
687
+ if directoryContents.isEmpty {
688
+ return nil
689
+ }
690
+ let fileNames = directoryContents.map { $0.lastPathComponent }
691
+ if fileNames.contains("index.html") {
692
+ return url.appendingPathComponent("index.html")
693
+ }
694
+ for fileUrl in directoryContents {
695
+ var isDirectory: ObjCBool = false
696
+ if FileManager.default.fileExists(atPath: fileUrl.path, isDirectory: &isDirectory), isDirectory.boolValue {
697
+ if let indexHtmlFile = searchIndexHtmlFile(url: fileUrl) {
698
+ return indexHtmlFile
699
+ }
700
+ }
701
+ }
702
+ } catch {
703
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to search index.html file: \(error.localizedDescription)")
704
+ }
705
+ return nil
706
+ }
707
+
708
+ /// - Parameter bundleId: The bundle ID to set as the current bundle. If `nil`, the default bundle will be used.
709
+ private func setCurrentBundleById(_ bundleId: String?) {
710
+ if let bundleId = bundleId {
711
+ schemeHandler.activeBundleDir = buildBundleURLFor(bundleId: bundleId)
712
+ } else {
713
+ schemeHandler.activeBundleDir = nil
714
+ }
715
+ plugin.reloadWebView()
716
+ }
717
+
718
+ /// - Parameter bundleId: The bundle ID to set as the next bundle. If `nil`, the default bundle will be used.
719
+ private func setNextBundleById(_ bundleId: String?) {
720
+ preferences.setNextBundleId(bundleId)
721
+ notifyNextBundleSetListeners(bundleId)
722
+ }
723
+
724
+ private func notifyNextBundleSetListeners(_ bundleId: String?) {
725
+ let event = LiveUpdateNextBundleSetEvent(bundleId: bundleId)
726
+ plugin.notifyNextBundleSetListeners(event)
727
+ }
728
+
729
+ private func addBlockedBundleId(_ bundleId: String) {
730
+ var blockedList: [String] = []
731
+ if let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty {
732
+ blockedList = blockedIds.split(separator: ",").map(String.init)
733
+ }
734
+ if blockedList.contains(bundleId) {
735
+ return
736
+ }
737
+ if blockedList.count >= 100 {
738
+ blockedList.removeFirst()
739
+ }
740
+ blockedList.append(bundleId)
741
+ preferences.setBlockedBundleIds(blockedList.joined(separator: ","))
742
+ NSLog("[\(LiveUpdatePlugin.tag)] Bundle blocked: \(bundleId)")
743
+ }
744
+
745
+ private func isBlockedBundleId(_ bundleId: String) -> Bool {
746
+ guard let blockedIds = preferences.getBlockedBundleIds(), !blockedIds.isEmpty else {
747
+ return false
748
+ }
749
+ return blockedIds.split(separator: ",").map(String.init).contains(bundleId)
750
+ }
751
+
752
+ private func checkAndResetConfigIfVersionChanged() {
753
+ let currentVersionCode = getVersionCode()
754
+ let currentVersionName = getVersionName()
755
+ let lastVersionCode = preferences.getLastVersionCode()
756
+ let lastVersionName = preferences.getLastVersionName()
757
+
758
+ if lastVersionCode == nil || lastVersionName == nil || lastVersionCode != currentVersionCode || lastVersionName != currentVersionName {
759
+ NSLog(
760
+ "[\(LiveUpdatePlugin.tag)] App version changed (last: \(lastVersionName ?? "nil")/\(lastVersionCode ?? "nil"), current: \(currentVersionName)/\(currentVersionCode)), resetting config."
761
+ )
762
+ resetConfig()
763
+ // Reset the next bundle to the built-in one. The previously persisted
764
+ // bundle was built for the old native binary and is no longer
765
+ // compatible. Capacitor gets this for free because its core resets the
766
+ // server base path on a native update; Cordova has no such mechanism,
767
+ // so we must do it ourselves.
768
+ preferences.setNextBundleId(nil)
769
+ preferences.setLastVersionCode(currentVersionCode)
770
+ preferences.setLastVersionName(currentVersionName)
771
+ }
772
+ }
773
+
774
+ private func setPreviousBundleId(bundleId: String?) {
775
+ preferences.setPreviousBundleId(bundleId)
776
+ }
777
+
778
+ private func startRollbackTimer() {
779
+ guard config.readyTimeout > 0 else {
780
+ return
781
+ }
782
+ stopRollbackTimer()
783
+ rollbackDispatchWorkItem = DispatchWorkItem { [weak self] in
784
+ self?.rollback()
785
+ }
786
+ DispatchQueue.main.asyncAfter(deadline: .now() + DispatchTimeInterval.milliseconds(config.readyTimeout), execute: rollbackDispatchWorkItem!)
787
+ }
788
+
789
+ private func stopRollbackTimer() {
790
+ rollbackDispatchWorkItem?.cancel()
791
+ }
792
+
793
+ private func tryCopyCurrentBundleFile(fileToCopy: LiveUpdateManifestItem, toDirectory: URL) -> Bool {
794
+ do {
795
+ try copyCurrentBundleFile(fileToCopy: fileToCopy, toDirectory: toDirectory)
796
+ return true
797
+ } catch {
798
+ return false
799
+ }
800
+ }
801
+
802
+ private func unzipFile(zipFile: URL) throws -> URL {
803
+ let destinationDirectory = zipFile.deletingPathExtension()
804
+ try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
805
+ try FileManager.default.unzipItem(at: zipFile, to: destinationDirectory)
806
+ return destinationDirectory
807
+ }
808
+
809
+ private func verifyFile(url: URL, checksum: String?, signature: String?) throws {
810
+ if let publicKey = config.publicKey {
811
+ guard let signature = signature else {
812
+ throw LiveUpdateError.signatureMissing
813
+ }
814
+ let verified: Bool
815
+ do {
816
+ verified = try verifySignatureForFile(url: url, signature: signature, publicKey: publicKey)
817
+ } catch {
818
+ throw LiveUpdateError.signatureVerificationFailed
819
+ }
820
+ if !verified {
821
+ throw LiveUpdateError.signatureVerificationFailed
822
+ }
823
+ } else if let expectedChecksum = checksum {
824
+ let receivedChecksum: String
825
+ do {
826
+ receivedChecksum = try getChecksumForFile(url: url)
827
+ } catch {
828
+ throw LiveUpdateError.checksumCalculationFailed
829
+ }
830
+ if receivedChecksum != expectedChecksum {
831
+ throw LiveUpdateError.checksumMismatch
832
+ }
833
+ }
834
+ }
835
+
836
+ private func verifySignatureForFile(url: URL, signature: String, publicKey: String) throws -> Bool {
837
+ let publicKeyAsBase64 = publicKey
838
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
839
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
840
+ .replacingOccurrences(of: "\n", with: "")
841
+ guard let publicKeyData = Data(base64Encoded: publicKeyAsBase64) else {
842
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to decode public key.")
843
+ return false
844
+ }
845
+ let publicKeyAttributes: [CFString: Any] = [
846
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
847
+ kSecAttrKeyClass: kSecAttrKeyClassPublic,
848
+ kSecAttrKeySizeInBits: 2048,
849
+ kSecReturnPersistentRef: true
850
+ ]
851
+ var secKeyCreateWithDataError: Unmanaged<CFError>?
852
+ guard let secPublicKey = SecKeyCreateWithData(publicKeyData as CFData, publicKeyAttributes as CFDictionary, &secKeyCreateWithDataError) else {
853
+ if let error = secKeyCreateWithDataError?.takeRetainedValue() {
854
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to create public key: \(error)")
855
+ }
856
+ return false
857
+ }
858
+ guard let signatureData = Data(base64Encoded: signature) else {
859
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to decode signature.")
860
+ return false
861
+ }
862
+
863
+ // SHA256 digest of file contents
864
+ var digestContext = CC_SHA256_CTX()
865
+ CC_SHA256_Init(&digestContext)
866
+ let handle = try FileHandle(forReadingFrom: url)
867
+ while autoreleasepool(invoking: {
868
+ let nextChunk = handle.readData(ofLength: 2048)
869
+ guard !nextChunk.isEmpty else { return false }
870
+ nextChunk.withUnsafeBytes {
871
+ _ = CC_SHA256_Update(&digestContext, $0.baseAddress, CC_LONG(nextChunk.count))
872
+ }
873
+ return true
874
+ }) {}
875
+ var digest = Data(count: Int(CC_SHA256_DIGEST_LENGTH))
876
+ digest.withUnsafeMutableBytes {
877
+ _ = CC_SHA256_Final($0.bindMemory(to: UInt8.self).baseAddress, &digestContext)
878
+ }
879
+
880
+ var secKeyVerifySignatureError: Unmanaged<CFError>?
881
+ let signatureAlgorithm = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256
882
+ let verificationResult = SecKeyVerifySignature(secPublicKey, signatureAlgorithm, digest as CFData, signatureData as CFData, &secKeyVerifySignatureError)
883
+ if let error = secKeyVerifySignatureError?.takeRetainedValue() {
884
+ NSLog("[\(LiveUpdatePlugin.tag)] Failed to verify signature: \(error)")
885
+ }
886
+ return verificationResult
887
+ }
888
+ }
889
+
890
+ extension Progress {
891
+ convenience init(totalUnitCount: Int64, completedUnitCount: Int64) {
892
+ self.init(totalUnitCount: totalUnitCount)
893
+ self.completedUnitCount = completedUnitCount
894
+ }
895
+ }