@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.
- package/LICENSE +21 -0
- package/README.md +1113 -0
- package/dist/docs.json +1654 -0
- package/dist/esm/definitions.d.ts +788 -0
- package/dist/esm/definitions.js +7 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/exec.d.ts +1 -0
- package/dist/esm/exec.js +8 -0
- package/dist/esm/exec.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +46 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/plugin.js +56 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +93 -0
- package/plugin.xml +268 -0
- package/src/android/capawesome-cordova-live-update.gradle +12 -0
- package/src/android/capawesome-live-update.xml +5 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdate.java +1480 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateConfig.java +105 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateHttpClient.java +114 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePathHandler.java +96 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePlugin.java +550 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePreferences.java +151 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/Manifest.java +58 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/ManifestItem.java +37 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetChannelsResponseItem.java +28 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +74 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +33 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/NextBundleSetEvent.java +26 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DeleteBundleOptions.java +18 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DownloadBundleOptions.java +66 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchChannelsOptions.java +39 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +25 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetChannelOptions.java +18 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetConfigOptions.java +20 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetCustomIdOptions.java +18 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetNextBundleOptions.java +21 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SyncOptions.java +25 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ChannelResult.java +29 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchChannelsResult.java +29 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +69 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +28 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBundlesResult.java +28 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetChannelResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCurrentBundleResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCustomIdResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDeviceIdResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +28 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetNextBundleResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionCodeResult.java +21 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionNameResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/IsSyncingResult.java +27 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/SyncResult.java +22 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/enums/ArtifactType.java +6 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Callback.java +5 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/EmptyCallback.java +5 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/NonEmptyCallback.java +7 -0
- package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Result.java +8 -0
- package/src/ios/LiveUpdate.swift +895 -0
- package/src/ios/LiveUpdateArtifactType.swift +4 -0
- package/src/ios/LiveUpdateChannelResult.swift +18 -0
- package/src/ios/LiveUpdateConfig.swift +11 -0
- package/src/ios/LiveUpdateDeleteBundleOptions.swift +13 -0
- package/src/ios/LiveUpdateDownloadBundleOptions.swift +41 -0
- package/src/ios/LiveUpdateDownloadBundleProgressEvent.swift +24 -0
- package/src/ios/LiveUpdateError.swift +62 -0
- package/src/ios/LiveUpdateFetchChannelsOptions.swift +25 -0
- package/src/ios/LiveUpdateFetchChannelsResult.swift +15 -0
- package/src/ios/LiveUpdateFetchLatestBundleOptions.swift +17 -0
- package/src/ios/LiveUpdateFetchLatestBundleResult.swift +42 -0
- package/src/ios/LiveUpdateGetBlockedBundlesResult.swift +15 -0
- package/src/ios/LiveUpdateGetBundlesResult.swift +15 -0
- package/src/ios/LiveUpdateGetChannelResult.swift +15 -0
- package/src/ios/LiveUpdateGetChannelsResponseItem.swift +4 -0
- package/src/ios/LiveUpdateGetConfigResult.swift +18 -0
- package/src/ios/LiveUpdateGetCurrentBundleResult.swift +15 -0
- package/src/ios/LiveUpdateGetCustomIdResult.swift +15 -0
- package/src/ios/LiveUpdateGetDeviceIdResult.swift +15 -0
- package/src/ios/LiveUpdateGetDownloadedBundlesResult.swift +15 -0
- package/src/ios/LiveUpdateGetLatestBundleResponse.swift +8 -0
- package/src/ios/LiveUpdateGetNextBundleResult.swift +15 -0
- package/src/ios/LiveUpdateGetVersionCodeResult.swift +15 -0
- package/src/ios/LiveUpdateGetVersionNameResult.swift +15 -0
- package/src/ios/LiveUpdateHttpClient.swift +58 -0
- package/src/ios/LiveUpdateIsSyncingResult.swift +15 -0
- package/src/ios/LiveUpdateManifest.swift +19 -0
- package/src/ios/LiveUpdateManifestItem.swift +5 -0
- package/src/ios/LiveUpdateNextBundleSetEvent.swift +15 -0
- package/src/ios/LiveUpdatePlugin.swift +521 -0
- package/src/ios/LiveUpdatePreferences.swift +116 -0
- package/src/ios/LiveUpdateReadyResult.swift +21 -0
- package/src/ios/LiveUpdateResult.swift +5 -0
- package/src/ios/LiveUpdateSchemeHandler.swift +286 -0
- package/src/ios/LiveUpdateSetChannelOptions.swift +13 -0
- package/src/ios/LiveUpdateSetConfigOptions.swift +13 -0
- package/src/ios/LiveUpdateSetCustomIdOptions.swift +13 -0
- package/src/ios/LiveUpdateSetNextBundleOptions.swift +13 -0
- package/src/ios/LiveUpdateSyncOptions.swift +17 -0
- 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
|
+
}
|