@otakit/capacitor-updater 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -128
- package/android/src/main/java/com/otakit/updater/BundleStore.java +37 -8
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +2 -3
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +20 -7
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +726 -0
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +551 -493
- package/dist/esm/definitions.d.ts +41 -66
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +5 -47
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +4 -3
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +5 -2
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +10 -49
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +10 -49
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +28 -6
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +7 -3
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +635 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +1 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +498 -489
- package/package.json +1 -1
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import Capacitor
|
|
2
2
|
import CryptoKit
|
|
3
3
|
import Foundation
|
|
4
|
+
import UIKit
|
|
4
5
|
|
|
5
6
|
@objc(UpdaterPlugin)
|
|
6
7
|
public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
7
|
-
private enum
|
|
8
|
-
case
|
|
9
|
-
case
|
|
10
|
-
case
|
|
8
|
+
private enum Policy: String {
|
|
9
|
+
case off
|
|
10
|
+
case shadow
|
|
11
|
+
case applyStaged = "apply-staged"
|
|
11
12
|
case immediate
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
private enum
|
|
15
|
-
case
|
|
16
|
-
case
|
|
15
|
+
private enum CheckResolution {
|
|
16
|
+
case noUpdate
|
|
17
|
+
case alreadyStaged(latest: LatestManifest, bundle: BundleInfo)
|
|
18
|
+
case updateAvailable(LatestManifest)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private enum DownloadResolution {
|
|
22
|
+
case noUpdate
|
|
23
|
+
case staged(BundleInfo)
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
public let identifier = "UpdaterPlugin"
|
|
@@ -23,18 +30,22 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
23
30
|
CAPPluginMethod(name: "check", returnType: CAPPluginReturnPromise),
|
|
24
31
|
CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
|
|
25
32
|
CAPPluginMethod(name: "apply", returnType: CAPPluginReturnPromise),
|
|
33
|
+
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
|
|
26
34
|
CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
|
|
27
35
|
CAPPluginMethod(name: "getLastFailure", returnType: CAPPluginReturnPromise),
|
|
28
36
|
]
|
|
29
37
|
|
|
30
38
|
private let store = BundleStore()
|
|
39
|
+
private lazy var coordinator = UpdaterCoordinator(store: store)
|
|
31
40
|
private var downloader = Downloader()
|
|
32
41
|
private let zipUtils = ZipUtils()
|
|
33
42
|
private let fileManager = FileManager.default
|
|
34
43
|
|
|
35
44
|
private var appReadyTimeoutMs = 10_000
|
|
36
45
|
private var allowInsecureUrls = false
|
|
37
|
-
private var
|
|
46
|
+
private var launchPolicy: Policy = .applyStaged
|
|
47
|
+
private var resumePolicy: Policy = .shadow
|
|
48
|
+
private var runtimePolicy: Policy = .immediate
|
|
38
49
|
private var ingestUrl = UpdaterPlugin.defaultIngestURL
|
|
39
50
|
private var cdnUrl = UpdaterPlugin.defaultCdnURL
|
|
40
51
|
private var appId: String?
|
|
@@ -43,13 +54,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
43
54
|
private var manifestKeys: [(kid: String, key: Data)] = []
|
|
44
55
|
private var trialTimeoutWorkItem: DispatchWorkItem?
|
|
45
56
|
private var checkIntervalMs: Int = 600_000
|
|
46
|
-
private let isCheckInProgress = NSLock()
|
|
47
|
-
private var checkInProgress = false
|
|
48
57
|
private var foregroundObserver: NSObjectProtocol?
|
|
49
58
|
private static let defaultIngestURL = "https://ingest.otakit.app/v1"
|
|
50
59
|
private static let defaultCdnURL = "https://cdn.otakit.app"
|
|
51
60
|
private static let ingestPathSuffix = "/v1"
|
|
52
61
|
private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
|
|
62
|
+
private static let defaultRuntimeKey = "__default__"
|
|
53
63
|
|
|
54
64
|
public override func load() {
|
|
55
65
|
let envIngestUrl = ProcessInfo.processInfo.environment["OTAKIT_INGEST_URL"]
|
|
@@ -67,11 +77,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
67
77
|
runtimeVersion = trimToNil(getConfig().getString("runtimeVersion"))
|
|
68
78
|
store.appRuntimeVersion = runtimeVersion
|
|
69
79
|
allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
launchPolicy = resolvePolicy(configured: getConfig().getString("launchPolicy"), defaultPolicy: .applyStaged)
|
|
81
|
+
resumePolicy = resolvePolicy(configured: getConfig().getString("resumePolicy"), defaultPolicy: .shadow)
|
|
82
|
+
runtimePolicy = resolvePolicy(configured: getConfig().getString("runtimePolicy"), defaultPolicy: .immediate)
|
|
72
83
|
downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
|
|
73
84
|
appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
|
|
74
|
-
checkIntervalMs =
|
|
85
|
+
checkIntervalMs = getConfig().getInt("checkInterval", 600_000)
|
|
75
86
|
|
|
76
87
|
let rawKeysValue = getConfig().getArray("manifestKeys")
|
|
77
88
|
if let rawKeys = rawKeysValue as? [[String: String]] {
|
|
@@ -96,32 +107,30 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
96
107
|
|
|
97
108
|
pruneIncompatibleBundles()
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
110
|
+
let startup = coordinator.normalizeStartupState(
|
|
111
|
+
isBundleUsable: isBundleUsable
|
|
112
|
+
)
|
|
113
|
+
coordinator.cleanupBundles(startup.cleanupBundleIds)
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
do {
|
|
116
|
+
try applyServerBasePathSynchronously(startup.activationPath)
|
|
117
|
+
} catch {
|
|
118
|
+
print("[OtaKit] startup activation failed: \(error.localizedDescription)")
|
|
107
119
|
}
|
|
108
120
|
|
|
109
|
-
if
|
|
110
|
-
|
|
111
|
-
} else {
|
|
112
|
-
applyServerBasePath(nil)
|
|
121
|
+
if let eventPayload = startup.eventPayload {
|
|
122
|
+
sendDeviceEvent(eventPayload)
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
if
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
if let trialBundleId = startup.trialBundleId {
|
|
126
|
+
scheduleTrialTimeout(for: trialBundleId)
|
|
127
|
+
} else {
|
|
128
|
+
cancelTrialTimeout()
|
|
118
129
|
}
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
runAutomaticUpdate(trigger: .launch)
|
|
122
|
-
}
|
|
131
|
+
dispatchColdStart()
|
|
123
132
|
|
|
124
|
-
if
|
|
133
|
+
if resumePolicy != .off {
|
|
125
134
|
foregroundObserver = NotificationCenter.default.addObserver(
|
|
126
135
|
forName: UIApplication.willEnterForegroundNotification,
|
|
127
136
|
object: nil,
|
|
@@ -139,191 +148,203 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
private func handleAppWillEnterForeground() {
|
|
142
|
-
|
|
151
|
+
handleResume()
|
|
143
152
|
}
|
|
144
153
|
|
|
145
|
-
private func
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let stagedId = store.getStagedBundleId(),
|
|
153
|
-
store.getBundle(id: stagedId) != nil {
|
|
154
|
-
activateStagedBundleForReload()
|
|
155
|
-
reloadWebView()
|
|
156
|
-
return
|
|
157
|
-
}
|
|
154
|
+
private func shouldSkipCheckInterval() -> Bool {
|
|
155
|
+
guard checkIntervalMs > 0 else { return false }
|
|
156
|
+
let lastCheck = UserDefaults.standard.double(forKey: UpdaterPlugin.lastCheckTimestampKey)
|
|
157
|
+
guard lastCheck > 0 else { return false }
|
|
158
|
+
let elapsed = Date().timeIntervalSince1970 * 1000 - lastCheck
|
|
159
|
+
return elapsed < Double(checkIntervalMs)
|
|
160
|
+
}
|
|
158
161
|
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
private func recordCheckTimestamp() {
|
|
163
|
+
UserDefaults.standard.set(
|
|
164
|
+
Date().timeIntervalSince1970 * 1000,
|
|
165
|
+
forKey: UpdaterPlugin.lastCheckTimestampKey
|
|
166
|
+
)
|
|
167
|
+
}
|
|
161
168
|
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
private func dispatchColdStart() {
|
|
170
|
+
if isRuntimeUnresolved() {
|
|
171
|
+
handleRuntime()
|
|
172
|
+
} else {
|
|
173
|
+
handleLaunch()
|
|
164
174
|
}
|
|
175
|
+
}
|
|
165
176
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
private func handleRuntime() {
|
|
178
|
+
switch runtimePolicy {
|
|
179
|
+
case .off:
|
|
180
|
+
resolveCurrentRuntimeKey()
|
|
181
|
+
case .applyStaged:
|
|
182
|
+
let hasStagedBundle = coordinator.snapshotState(
|
|
183
|
+
isStagedBundleUsable: { [self] bundle in
|
|
184
|
+
isCompatibleRuntime(bundle) && isBundleUsable(bundle)
|
|
185
|
+
}
|
|
186
|
+
).staged != nil
|
|
187
|
+
if hasStagedBundle {
|
|
188
|
+
resolveCurrentRuntimeKey()
|
|
173
189
|
do {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
activateStagedBundleForReload()
|
|
177
|
-
reloadWebView()
|
|
190
|
+
if !(try applyStaged(reloadAfterApply: false)) {
|
|
191
|
+
print("[OtaKit] Failed to apply a valid staged bundle during runtime handling")
|
|
178
192
|
}
|
|
179
193
|
} catch {
|
|
180
|
-
print("[OtaKit]
|
|
194
|
+
print("[OtaKit] runtime apply-staged failed: \(error.localizedDescription)")
|
|
181
195
|
}
|
|
196
|
+
return
|
|
182
197
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
executeAutomaticUpdate(label: "runtime apply-staged fallback") { [self] in
|
|
199
|
+
_ = try await downloadLatest(respectInterval: false, channel: nil)
|
|
200
|
+
resolveCurrentRuntimeKey()
|
|
201
|
+
}
|
|
202
|
+
case .shadow:
|
|
203
|
+
executeAutomaticUpdate(label: "runtime shadow") { [self] in
|
|
204
|
+
_ = try await downloadLatest(respectInterval: false, channel: nil)
|
|
205
|
+
resolveCurrentRuntimeKey()
|
|
206
|
+
}
|
|
207
|
+
case .immediate:
|
|
208
|
+
executeAutomaticUpdate(label: "runtime immediate") { [self] in
|
|
209
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
210
|
+
switch result {
|
|
211
|
+
case .noUpdate:
|
|
212
|
+
resolveCurrentRuntimeKey()
|
|
213
|
+
case .staged:
|
|
214
|
+
resolveCurrentRuntimeKey()
|
|
215
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
|
-
return
|
|
201
218
|
}
|
|
219
|
+
}
|
|
202
220
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
221
|
+
private func handleLaunch() {
|
|
222
|
+
switch launchPolicy {
|
|
223
|
+
case .off:
|
|
224
|
+
return
|
|
225
|
+
case .applyStaged:
|
|
206
226
|
do {
|
|
207
|
-
|
|
208
|
-
|
|
227
|
+
if try applyStaged(reloadAfterApply: false) {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
209
230
|
} catch {
|
|
210
|
-
|
|
231
|
+
print("[OtaKit] launch apply-staged failed: \(error.localizedDescription)")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
executeAutomaticUpdate(label: "launch apply-staged fallback") { [self] in
|
|
235
|
+
_ = try await downloadLatest(respectInterval: false, channel: nil)
|
|
236
|
+
}
|
|
237
|
+
case .shadow:
|
|
238
|
+
executeAutomaticUpdate(label: "launch shadow") { [self] in
|
|
239
|
+
_ = try await downloadLatest(respectInterval: false, channel: nil)
|
|
240
|
+
}
|
|
241
|
+
case .immediate:
|
|
242
|
+
executeAutomaticUpdate(label: "launch immediate") { [self] in
|
|
243
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
244
|
+
if case .staged = result {
|
|
245
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
246
|
+
}
|
|
211
247
|
}
|
|
212
248
|
}
|
|
213
249
|
}
|
|
214
250
|
|
|
215
|
-
private func
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
private func handleResume() {
|
|
252
|
+
switch resumePolicy {
|
|
253
|
+
case .off:
|
|
254
|
+
return
|
|
255
|
+
case .applyStaged:
|
|
256
|
+
executeAutomaticUpdate(label: "resume apply-staged") { [self] in
|
|
257
|
+
if try applyStaged(reloadAfterApply: true) {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
_ = try await downloadLatest(respectInterval: true, channel: nil)
|
|
261
|
+
}
|
|
262
|
+
case .shadow:
|
|
263
|
+
executeAutomaticUpdate(label: "resume shadow") { [self] in
|
|
264
|
+
_ = try await downloadLatest(respectInterval: true, channel: nil)
|
|
265
|
+
}
|
|
266
|
+
case .immediate:
|
|
267
|
+
executeAutomaticUpdate(label: "resume immediate") { [self] in
|
|
268
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
269
|
+
if case .staged = result {
|
|
270
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
220
274
|
}
|
|
221
275
|
|
|
222
|
-
private func
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
|
|
276
|
+
private func executeAutomaticUpdate(
|
|
277
|
+
label: String,
|
|
278
|
+
operation: @escaping () async throws -> Void
|
|
279
|
+
) {
|
|
280
|
+
guard coordinator.tryBeginOperation() else {
|
|
281
|
+
print("[OtaKit] Skipping \(label): update already in progress")
|
|
282
|
+
return
|
|
283
|
+
}
|
|
228
284
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
285
|
+
Task {
|
|
286
|
+
defer { coordinator.endOperation() }
|
|
287
|
+
do {
|
|
288
|
+
try await operation()
|
|
289
|
+
} catch {
|
|
290
|
+
print("[OtaKit] \(label) failed: \(error.localizedDescription)")
|
|
291
|
+
}
|
|
292
|
+
}
|
|
235
293
|
}
|
|
236
294
|
|
|
237
|
-
private func
|
|
238
|
-
|
|
239
|
-
defer { isCheckInProgress.unlock() }
|
|
240
|
-
checkInProgress = false
|
|
295
|
+
private func currentRuntimeKey() -> String {
|
|
296
|
+
runtimeVersion ?? UpdaterPlugin.defaultRuntimeKey
|
|
241
297
|
}
|
|
242
298
|
|
|
243
|
-
private func
|
|
244
|
-
|
|
299
|
+
private func isRuntimeUnresolved() -> Bool {
|
|
300
|
+
coordinator.isRuntimeUnresolved(currentRuntimeKey: currentRuntimeKey())
|
|
245
301
|
}
|
|
246
302
|
|
|
247
|
-
private func
|
|
248
|
-
|
|
303
|
+
private func resolveCurrentRuntimeKey() {
|
|
304
|
+
coordinator.resolveRuntimeKey(currentRuntimeKey())
|
|
249
305
|
}
|
|
250
306
|
|
|
251
|
-
private func
|
|
307
|
+
private func resolvePolicy(configured: String?, defaultPolicy: Policy) -> Policy {
|
|
252
308
|
let raw = configured?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
253
309
|
switch raw {
|
|
254
|
-
case ""
|
|
255
|
-
return
|
|
256
|
-
case
|
|
257
|
-
return .
|
|
258
|
-
case
|
|
259
|
-
return .
|
|
260
|
-
case
|
|
310
|
+
case "":
|
|
311
|
+
return defaultPolicy
|
|
312
|
+
case Policy.off.rawValue:
|
|
313
|
+
return .off
|
|
314
|
+
case Policy.shadow.rawValue:
|
|
315
|
+
return .shadow
|
|
316
|
+
case Policy.applyStaged.rawValue:
|
|
317
|
+
return .applyStaged
|
|
318
|
+
case Policy.immediate.rawValue:
|
|
261
319
|
return .immediate
|
|
262
320
|
default:
|
|
263
|
-
print("[OtaKit] Unknown
|
|
264
|
-
return
|
|
321
|
+
print("[OtaKit] Unknown policy '\(raw)', defaulting to '\(defaultPolicy.rawValue)'")
|
|
322
|
+
return defaultPolicy
|
|
265
323
|
}
|
|
266
324
|
}
|
|
267
325
|
|
|
268
326
|
@objc func getState(_ call: CAPPluginCall) {
|
|
269
|
-
let
|
|
270
|
-
let staged: [String: Any]? = {
|
|
271
|
-
guard let stagedId = store.getStagedBundleId() else {
|
|
272
|
-
return nil
|
|
273
|
-
}
|
|
274
|
-
guard let staged = store.getBundle(id: stagedId) else {
|
|
275
|
-
store.setStagedBundleId(nil)
|
|
276
|
-
return nil
|
|
277
|
-
}
|
|
278
|
-
return staged.toDictionary()
|
|
279
|
-
}()
|
|
327
|
+
let snapshot = coordinator.snapshotState(isStagedBundleUsable: isBundleUsable)
|
|
280
328
|
var payload: [String: Any] = [
|
|
281
|
-
"current": current.toDictionary(),
|
|
282
|
-
"fallback":
|
|
283
|
-
"builtinVersion":
|
|
329
|
+
"current": snapshot.current.toDictionary(),
|
|
330
|
+
"fallback": snapshot.fallback.toDictionary(),
|
|
331
|
+
"builtinVersion": snapshot.builtinVersion,
|
|
284
332
|
]
|
|
285
|
-
payload["staged"] = staged ?? NSNull()
|
|
333
|
+
payload["staged"] = snapshot.staged?.toDictionary() ?? NSNull()
|
|
286
334
|
call.resolve(payload)
|
|
287
335
|
}
|
|
288
336
|
|
|
289
337
|
@objc func check(_ call: CAPPluginCall) {
|
|
290
|
-
if !
|
|
291
|
-
|
|
292
|
-
let staged = store.getBundle(id: stagedId) {
|
|
293
|
-
var payload: [String: Any] = [
|
|
294
|
-
"version": staged.version,
|
|
295
|
-
"url": "",
|
|
296
|
-
"sha256": staged.sha256 ?? "",
|
|
297
|
-
"size": 0,
|
|
298
|
-
"downloaded": true,
|
|
299
|
-
]
|
|
300
|
-
if let runtimeVersion = staged.runtimeVersion {
|
|
301
|
-
payload["runtimeVersion"] = runtimeVersion
|
|
302
|
-
}
|
|
303
|
-
if let releaseId = staged.releaseId {
|
|
304
|
-
payload["releaseId"] = releaseId
|
|
305
|
-
}
|
|
306
|
-
call.resolve(payload)
|
|
307
|
-
} else {
|
|
308
|
-
call.resolve()
|
|
309
|
-
}
|
|
338
|
+
if !coordinator.tryBeginOperation() {
|
|
339
|
+
call.reject("Another update operation is already in progress")
|
|
310
340
|
return
|
|
311
341
|
}
|
|
312
|
-
|
|
342
|
+
|
|
313
343
|
Task {
|
|
314
|
-
defer {
|
|
344
|
+
defer { coordinator.endOperation() }
|
|
315
345
|
do {
|
|
316
|
-
let
|
|
317
|
-
|
|
318
|
-
if isCurrentBundleLatest(latest: latest, targetChannel: targetChannel) {
|
|
319
|
-
call.resolve()
|
|
320
|
-
return
|
|
321
|
-
}
|
|
322
|
-
let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
|
|
323
|
-
call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
|
|
324
|
-
} else {
|
|
325
|
-
call.resolve()
|
|
326
|
-
}
|
|
346
|
+
let result = try await checkLatest(respectInterval: false, channel: nil)
|
|
347
|
+
call.resolve(checkResultDictionary(result))
|
|
327
348
|
} catch {
|
|
328
349
|
call.reject("check failed: \(error.localizedDescription)")
|
|
329
350
|
}
|
|
@@ -331,24 +352,16 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
@objc func download(_ call: CAPPluginCall) {
|
|
334
|
-
if !
|
|
335
|
-
|
|
336
|
-
let staged = store.getBundle(id: stagedId) {
|
|
337
|
-
call.resolve(staged.toDictionary())
|
|
338
|
-
} else {
|
|
339
|
-
call.resolve()
|
|
340
|
-
}
|
|
355
|
+
if !coordinator.tryBeginOperation() {
|
|
356
|
+
call.reject("Another update operation is already in progress")
|
|
341
357
|
return
|
|
342
358
|
}
|
|
359
|
+
|
|
343
360
|
Task {
|
|
344
|
-
defer {
|
|
361
|
+
defer { coordinator.endOperation() }
|
|
345
362
|
do {
|
|
346
|
-
let
|
|
347
|
-
|
|
348
|
-
call.resolve(bundle.toDictionary())
|
|
349
|
-
} else {
|
|
350
|
-
call.resolve()
|
|
351
|
-
}
|
|
363
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
364
|
+
call.resolve(downloadResultDictionary(result))
|
|
352
365
|
} catch {
|
|
353
366
|
call.reject("download failed: \(error.localizedDescription)")
|
|
354
367
|
}
|
|
@@ -356,59 +369,57 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
356
369
|
}
|
|
357
370
|
|
|
358
371
|
@objc func apply(_ call: CAPPluginCall) {
|
|
359
|
-
guard
|
|
360
|
-
call.reject("
|
|
372
|
+
guard coordinator.tryBeginOperation() else {
|
|
373
|
+
call.reject("Another update operation is already in progress")
|
|
361
374
|
return
|
|
362
375
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
376
|
+
defer { coordinator.endOperation() }
|
|
377
|
+
|
|
378
|
+
do {
|
|
379
|
+
guard try applyStaged(reloadAfterApply: true) else {
|
|
380
|
+
call.reject("No valid staged update to apply")
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
call.reject("apply failed: \(error.localizedDescription)")
|
|
366
385
|
return
|
|
367
386
|
}
|
|
368
|
-
|
|
369
|
-
activateStagedBundleForReload()
|
|
370
|
-
call.resolve()
|
|
371
|
-
reloadWebView()
|
|
372
387
|
}
|
|
373
388
|
|
|
374
|
-
@objc func
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
call.resolve()
|
|
389
|
+
@objc func update(_ call: CAPPluginCall) {
|
|
390
|
+
guard coordinator.tryBeginOperation() else {
|
|
391
|
+
call.reject("Another update operation is already in progress")
|
|
378
392
|
return
|
|
379
393
|
}
|
|
380
394
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
395
|
+
Task {
|
|
396
|
+
defer { coordinator.endOperation() }
|
|
397
|
+
do {
|
|
398
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
399
|
+
if case .staged = result {
|
|
400
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
call.resolve()
|
|
404
|
+
} catch {
|
|
405
|
+
call.reject("update failed: \(error.localizedDescription)")
|
|
391
406
|
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
392
409
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
if !oldFallback.isBuiltin,
|
|
402
|
-
oldFallback.id != current.id {
|
|
403
|
-
try? store.deleteBundle(id: oldFallback.id)
|
|
404
|
-
}
|
|
410
|
+
@objc func notifyAppReady(_ call: CAPPluginCall) {
|
|
411
|
+
cancelTrialTimeout()
|
|
412
|
+
let preparation = coordinator.prepareNotifyAppReady()
|
|
413
|
+
coordinator.cleanupBundles(preparation.cleanupBundleIds)
|
|
414
|
+
if let eventPayload = preparation.eventPayload {
|
|
415
|
+
sendDeviceEvent(eventPayload)
|
|
405
416
|
}
|
|
406
417
|
|
|
407
418
|
call.resolve()
|
|
408
419
|
}
|
|
409
420
|
|
|
410
421
|
@objc func getLastFailure(_ call: CAPPluginCall) {
|
|
411
|
-
guard let failed =
|
|
422
|
+
guard let failed = coordinator.lastFailure() else {
|
|
412
423
|
call.resolve()
|
|
413
424
|
return
|
|
414
425
|
}
|
|
@@ -432,82 +443,123 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
432
443
|
)
|
|
433
444
|
}
|
|
434
445
|
|
|
435
|
-
private func
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
) async throws ->
|
|
446
|
+
private func checkLatest(
|
|
447
|
+
respectInterval: Bool,
|
|
448
|
+
channel: String?
|
|
449
|
+
) async throws -> CheckResolution {
|
|
439
450
|
let targetChannel = resolveTargetChannel(channel)
|
|
440
|
-
|
|
451
|
+
if respectInterval, shouldSkipCheckInterval() {
|
|
452
|
+
print("[OtaKit] Skipping resume check: checkInterval has not elapsed")
|
|
453
|
+
return .noUpdate
|
|
454
|
+
}
|
|
441
455
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
456
|
+
let latest = try await fetchLatest(channel: targetChannel)
|
|
457
|
+
guard let manifest = latest else {
|
|
458
|
+
if respectInterval {
|
|
459
|
+
recordCheckTimestamp()
|
|
445
460
|
}
|
|
446
|
-
return
|
|
461
|
+
return .noUpdate
|
|
447
462
|
}
|
|
448
463
|
|
|
449
|
-
|
|
450
|
-
throw NSError(
|
|
451
|
-
domain: "OtaKit",
|
|
452
|
-
code: 1,
|
|
453
|
-
userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
|
|
454
|
-
)
|
|
455
|
-
}
|
|
464
|
+
let resolution = try classifyLatestManifest(manifest, targetChannel: targetChannel)
|
|
456
465
|
|
|
457
|
-
if
|
|
458
|
-
|
|
459
|
-
notifyListeners("noUpdateAvailable", data: [:])
|
|
460
|
-
}
|
|
461
|
-
return nil
|
|
466
|
+
if respectInterval {
|
|
467
|
+
recordCheckTimestamp()
|
|
462
468
|
}
|
|
469
|
+
return resolution
|
|
470
|
+
}
|
|
463
471
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
472
|
+
private func downloadLatest(
|
|
473
|
+
respectInterval: Bool,
|
|
474
|
+
channel: String?
|
|
475
|
+
) async throws -> DownloadResolution {
|
|
476
|
+
let targetChannel = resolveTargetChannel(channel)
|
|
477
|
+
let result = try await checkLatest(respectInterval: respectInterval, channel: channel)
|
|
478
|
+
switch result {
|
|
479
|
+
case .noUpdate:
|
|
480
|
+
return .noUpdate
|
|
481
|
+
case let .alreadyStaged(_, bundle):
|
|
482
|
+
return .staged(bundle)
|
|
483
|
+
case let .updateAvailable(manifest):
|
|
484
|
+
do {
|
|
485
|
+
let bundle = try await downloadLatestManifest(
|
|
486
|
+
manifest,
|
|
487
|
+
targetChannel: targetChannel
|
|
488
|
+
)
|
|
489
|
+
return .staged(bundle)
|
|
490
|
+
} catch let error as NSError where isExpiredURLError(error) {
|
|
491
|
+
guard let refreshed = try await fetchLatest(channel: targetChannel) else {
|
|
492
|
+
return .noUpdate
|
|
493
|
+
}
|
|
468
494
|
|
|
469
|
-
|
|
470
|
-
|
|
495
|
+
switch try classifyLatestManifest(refreshed, targetChannel: targetChannel) {
|
|
496
|
+
case .noUpdate:
|
|
497
|
+
return .noUpdate
|
|
498
|
+
case let .alreadyStaged(_, bundle):
|
|
499
|
+
return .staged(bundle)
|
|
500
|
+
case let .updateAvailable(retryManifest):
|
|
501
|
+
let bundle = try await downloadLatestManifest(
|
|
502
|
+
retryManifest,
|
|
503
|
+
targetChannel: targetChannel
|
|
504
|
+
)
|
|
505
|
+
return .staged(bundle)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
471
508
|
}
|
|
509
|
+
}
|
|
472
510
|
|
|
473
|
-
|
|
511
|
+
private func classifyLatestManifest(
|
|
512
|
+
_ manifest: LatestManifest,
|
|
513
|
+
targetChannel: String?
|
|
514
|
+
) throws -> CheckResolution {
|
|
515
|
+
guard isCompatibleRuntime(manifest.runtimeVersion) else {
|
|
474
516
|
throw NSError(
|
|
475
517
|
domain: "OtaKit",
|
|
476
518
|
code: 1,
|
|
477
|
-
userInfo: [NSLocalizedDescriptionKey: "
|
|
519
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
|
|
478
520
|
)
|
|
479
521
|
}
|
|
480
522
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
523
|
+
switch coordinator.classifyLatestManifest(
|
|
524
|
+
manifest,
|
|
525
|
+
targetChannel: targetChannel,
|
|
526
|
+
isStagedBundleUsable: isBundleUsable
|
|
527
|
+
) {
|
|
528
|
+
case .noUpdate:
|
|
529
|
+
return .noUpdate
|
|
530
|
+
case let .alreadyStaged(bundle):
|
|
531
|
+
return .alreadyStaged(latest: manifest, bundle: bundle)
|
|
532
|
+
case .updateAvailable:
|
|
533
|
+
return .updateAvailable(manifest)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private func checkResultDictionary(_ result: CheckResolution) -> [String: Any] {
|
|
538
|
+
switch result {
|
|
539
|
+
case .noUpdate:
|
|
540
|
+
return ["kind": "no_update"]
|
|
541
|
+
case let .alreadyStaged(latest, _):
|
|
542
|
+
return [
|
|
543
|
+
"kind": "already_staged",
|
|
544
|
+
"latest": manifestToDictionary(latest),
|
|
545
|
+
]
|
|
546
|
+
case let .updateAvailable(latest):
|
|
547
|
+
return [
|
|
548
|
+
"kind": "update_available",
|
|
549
|
+
"latest": manifestToDictionary(latest),
|
|
550
|
+
]
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private func downloadResultDictionary(_ result: DownloadResolution) -> [String: Any] {
|
|
555
|
+
switch result {
|
|
556
|
+
case .noUpdate:
|
|
557
|
+
return ["kind": "no_update"]
|
|
558
|
+
case let .staged(bundle):
|
|
559
|
+
return [
|
|
560
|
+
"kind": "staged",
|
|
561
|
+
"bundle": bundle.toDictionary(),
|
|
562
|
+
]
|
|
511
563
|
}
|
|
512
564
|
}
|
|
513
565
|
|
|
@@ -551,7 +603,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
551
603
|
}
|
|
552
604
|
}
|
|
553
605
|
|
|
554
|
-
notifyListeners("downloadStarted", data: ["version": version])
|
|
555
606
|
let zipURL = try await downloader.download(from: url)
|
|
556
607
|
|
|
557
608
|
let extractDirectory = fileManager.temporaryDirectory
|
|
@@ -577,8 +628,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
577
628
|
try zipUtils.extractSecurely(zipURL: zipURL, to: extractDirectory)
|
|
578
629
|
let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
|
|
579
630
|
|
|
580
|
-
let bundleId = buildBundleId(
|
|
581
|
-
|
|
631
|
+
let bundleId = buildBundleId(
|
|
632
|
+
version: version,
|
|
633
|
+
releaseId: releaseId,
|
|
634
|
+
sha256: expectedSha256
|
|
635
|
+
)
|
|
636
|
+
let destination = coordinator.bundleDirectory(for: bundleId)
|
|
582
637
|
|
|
583
638
|
if fileManager.fileExists(atPath: destination.path) {
|
|
584
639
|
try fileManager.removeItem(at: destination)
|
|
@@ -600,12 +655,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
600
655
|
releaseId: releaseId
|
|
601
656
|
)
|
|
602
657
|
|
|
603
|
-
let
|
|
604
|
-
|
|
605
|
-
store.setStagedBundleId(bundleId)
|
|
606
|
-
cleanupSupersededStagedBundle(previousStagedId: previousStagedId, replacementId: bundleId)
|
|
658
|
+
let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
|
|
659
|
+
coordinator.cleanupBundles(cleanupBundleIds)
|
|
607
660
|
|
|
608
|
-
notifyListeners("downloadComplete", data: info.toDictionary())
|
|
609
661
|
sendDeviceEvent(
|
|
610
662
|
action: .downloaded,
|
|
611
663
|
bundleVersion: version,
|
|
@@ -615,10 +667,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
615
667
|
)
|
|
616
668
|
return info
|
|
617
669
|
} catch {
|
|
618
|
-
notifyListeners("downloadFailed", data: [
|
|
619
|
-
"version": version,
|
|
620
|
-
"error": error.localizedDescription,
|
|
621
|
-
])
|
|
622
670
|
sendDeviceEvent(
|
|
623
671
|
action: .downloadError,
|
|
624
672
|
bundleVersion: version,
|
|
@@ -657,52 +705,39 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
657
705
|
)
|
|
658
706
|
}
|
|
659
707
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
guard
|
|
669
|
-
|
|
670
|
-
return store.getCurrentBundle()
|
|
708
|
+
@discardableResult
|
|
709
|
+
private func applyStaged(reloadAfterApply: Bool) throws -> Bool {
|
|
710
|
+
let preparation = coordinator.prepareApplyStaged(
|
|
711
|
+
isCompatibleRuntime: isCompatibleRuntime,
|
|
712
|
+
isBundleUsable: isBundleUsable
|
|
713
|
+
)
|
|
714
|
+
coordinator.cleanupBundles(preparation.cleanupBundleIds)
|
|
715
|
+
|
|
716
|
+
guard let activationPath = preparation.activationPath else {
|
|
717
|
+
return false
|
|
671
718
|
}
|
|
672
719
|
|
|
673
|
-
|
|
674
|
-
store.setStagedBundleId(nil)
|
|
675
|
-
return staged
|
|
676
|
-
}
|
|
720
|
+
try applyServerBasePathSynchronously(activationPath)
|
|
677
721
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return
|
|
681
|
-
}
|
|
682
|
-
guard var staged = store.getBundle(id: stagedId) else {
|
|
683
|
-
store.setStagedBundleId(nil)
|
|
684
|
-
return
|
|
685
|
-
}
|
|
686
|
-
guard isCompatibleRuntime(staged) else {
|
|
687
|
-
try? store.deleteBundle(id: staged.id)
|
|
688
|
-
return
|
|
722
|
+
if reloadAfterApply {
|
|
723
|
+
try reloadWebViewSynchronously()
|
|
689
724
|
}
|
|
690
725
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if staged.status == .pending {
|
|
695
|
-
store.markStatus(bundleId: staged.id, status: .trial)
|
|
696
|
-
staged = store.getBundle(id: staged.id) ?? staged
|
|
697
|
-
scheduleTrialTimeout(for: staged.id)
|
|
698
|
-
} else if staged.status == .trial {
|
|
699
|
-
scheduleTrialTimeout(for: staged.id)
|
|
726
|
+
cancelTrialTimeout()
|
|
727
|
+
if let trialBundleId = preparation.trialBundleId {
|
|
728
|
+
scheduleTrialTimeout(for: trialBundleId)
|
|
700
729
|
}
|
|
701
730
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
731
|
+
return true
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private func requireApplyStaged(reloadAfterApply: Bool) throws {
|
|
735
|
+
guard try applyStaged(reloadAfterApply: reloadAfterApply) else {
|
|
736
|
+
throw NSError(
|
|
737
|
+
domain: "OtaKit",
|
|
738
|
+
code: 1,
|
|
739
|
+
userInfo: [NSLocalizedDescriptionKey: "Expected a staged bundle to be ready for apply"]
|
|
740
|
+
)
|
|
706
741
|
}
|
|
707
742
|
}
|
|
708
743
|
|
|
@@ -713,12 +748,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
713
748
|
guard let self else {
|
|
714
749
|
return
|
|
715
750
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
return
|
|
719
|
-
}
|
|
720
|
-
if current.status == .trial {
|
|
721
|
-
self.rollbackCurrentBundle(reason: "notify_timeout")
|
|
751
|
+
if self.coordinator.isCurrentTrialBundle(bundleId) {
|
|
752
|
+
self.rollbackCurrentBundle(reason: "notify_timeout", shouldReload: true)
|
|
722
753
|
}
|
|
723
754
|
}
|
|
724
755
|
trialTimeoutWorkItem = workItem
|
|
@@ -734,124 +765,99 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
734
765
|
trialTimeoutWorkItem = nil
|
|
735
766
|
}
|
|
736
767
|
|
|
737
|
-
private func rollbackCurrentBundle(reason: String) {
|
|
768
|
+
private func rollbackCurrentBundle(reason: String, shouldReload: Bool) {
|
|
738
769
|
cancelTrialTimeout()
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
770
|
+
let preparation = coordinator.prepareRollback(
|
|
771
|
+
reason: reason,
|
|
772
|
+
isBundleUsable: isBundleUsable
|
|
773
|
+
)
|
|
774
|
+
guard preparation.didRollback else {
|
|
742
775
|
return
|
|
743
776
|
}
|
|
744
777
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
status: .error,
|
|
750
|
-
downloadedAt: current.downloadedAt,
|
|
751
|
-
sha256: current.sha256,
|
|
752
|
-
path: current.path,
|
|
753
|
-
channel: current.channel,
|
|
754
|
-
releaseId: current.releaseId
|
|
755
|
-
)
|
|
756
|
-
store.markStatus(bundleId: current.id, status: .error)
|
|
757
|
-
store.setFailedBundle(failed)
|
|
758
|
-
store.setStagedBundleId(nil)
|
|
759
|
-
|
|
760
|
-
sendDeviceEvent(
|
|
761
|
-
action: .rollback,
|
|
762
|
-
bundleVersion: current.version,
|
|
763
|
-
runtimeVersion: current.runtimeVersion,
|
|
764
|
-
channel: current.channel,
|
|
765
|
-
releaseId: current.releaseId,
|
|
766
|
-
detail: reason
|
|
767
|
-
)
|
|
778
|
+
coordinator.cleanupBundles(preparation.cleanupBundleIds)
|
|
779
|
+
if let eventPayload = preparation.eventPayload {
|
|
780
|
+
sendDeviceEvent(eventPayload)
|
|
781
|
+
}
|
|
768
782
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
"reason": reason,
|
|
777
|
-
])
|
|
778
|
-
} else if let fallbackPath = fallback.path {
|
|
779
|
-
store.setCurrentBundleId(fallback.id)
|
|
780
|
-
applyServerBasePath(fallbackPath)
|
|
781
|
-
notifyListeners("rollback", data: [
|
|
782
|
-
"from": failed.toDictionary(),
|
|
783
|
-
"to": fallback.toDictionary(),
|
|
784
|
-
"reason": reason,
|
|
785
|
-
])
|
|
786
|
-
} else {
|
|
787
|
-
store.setCurrentBundleId(nil)
|
|
788
|
-
applyServerBasePath(nil)
|
|
789
|
-
notifyListeners("rollback", data: [
|
|
790
|
-
"from": failed.toDictionary(),
|
|
791
|
-
"to": store.builtinBundle().toDictionary(),
|
|
792
|
-
"reason": reason,
|
|
793
|
-
])
|
|
783
|
+
do {
|
|
784
|
+
try applyServerBasePathSynchronously(preparation.activationPath)
|
|
785
|
+
if shouldReload {
|
|
786
|
+
try reloadWebViewSynchronously()
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
print("[OtaKit] rollback activation failed: \(error.localizedDescription)")
|
|
794
790
|
}
|
|
791
|
+
}
|
|
795
792
|
|
|
796
|
-
|
|
793
|
+
private func applyServerBasePathSynchronously(_ path: String?) throws {
|
|
794
|
+
try runOnMainSynchronously { [weak self] in
|
|
795
|
+
guard let self, let bridge = self.bridge else {
|
|
796
|
+
throw NSError(
|
|
797
|
+
domain: "OtaKit",
|
|
798
|
+
code: 1,
|
|
799
|
+
userInfo: [NSLocalizedDescriptionKey: "Bridge not available for activation"]
|
|
800
|
+
)
|
|
801
|
+
}
|
|
797
802
|
|
|
798
|
-
|
|
803
|
+
if let path, !path.isEmpty {
|
|
804
|
+
bridge.setServerBasePath(path)
|
|
805
|
+
} else {
|
|
806
|
+
let builtinPath = Bundle.main.resourceURL?
|
|
807
|
+
.appendingPathComponent("public", isDirectory: true).path ?? ""
|
|
808
|
+
bridge.setServerBasePath(builtinPath)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
799
811
|
}
|
|
800
812
|
|
|
801
|
-
private func
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
813
|
+
private func reloadWebViewSynchronously() throws {
|
|
814
|
+
try runOnMainSynchronously { [weak self] in
|
|
815
|
+
guard let webView = self?.bridge?.webView else {
|
|
816
|
+
throw NSError(
|
|
817
|
+
domain: "OtaKit",
|
|
818
|
+
code: 1,
|
|
819
|
+
userInfo: [NSLocalizedDescriptionKey: "WebView not available for reload"]
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
webView.reload()
|
|
809
823
|
}
|
|
824
|
+
}
|
|
810
825
|
|
|
811
|
-
|
|
812
|
-
if
|
|
826
|
+
private func runOnMainSynchronously(_ work: @escaping () throws -> Void) throws {
|
|
827
|
+
if Thread.isMainThread {
|
|
828
|
+
try work()
|
|
813
829
|
return
|
|
814
830
|
}
|
|
815
831
|
|
|
816
|
-
let
|
|
817
|
-
|
|
818
|
-
|
|
832
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
833
|
+
final class FailureBox {
|
|
834
|
+
var error: Error?
|
|
819
835
|
}
|
|
836
|
+
let failure = FailureBox()
|
|
820
837
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
self?.bridge?.setServerBasePath(path)
|
|
828
|
-
} else {
|
|
829
|
-
let builtinPath = Bundle.main.resourceURL?
|
|
830
|
-
.appendingPathComponent("public", isDirectory: true).path ?? ""
|
|
831
|
-
self?.bridge?.setServerBasePath(builtinPath)
|
|
838
|
+
DispatchQueue.main.async {
|
|
839
|
+
defer { semaphore.signal() }
|
|
840
|
+
do {
|
|
841
|
+
try work()
|
|
842
|
+
} catch {
|
|
843
|
+
failure.error = error
|
|
832
844
|
}
|
|
833
845
|
}
|
|
834
|
-
}
|
|
835
846
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
print("[OtaKit] WARNING: WebView not available for reload")
|
|
840
|
-
}
|
|
841
|
-
self?.bridge?.webView?.reload()
|
|
847
|
+
semaphore.wait()
|
|
848
|
+
if let error = failure.error {
|
|
849
|
+
throw error
|
|
842
850
|
}
|
|
843
851
|
}
|
|
844
852
|
|
|
845
853
|
private func manifestToDictionary(
|
|
846
|
-
_ latest: LatestManifest
|
|
847
|
-
downloaded: Bool = false
|
|
854
|
+
_ latest: LatestManifest
|
|
848
855
|
) -> [String: Any] {
|
|
849
856
|
var payload: [String: Any] = [
|
|
850
857
|
"version": latest.version,
|
|
851
858
|
"url": latest.url,
|
|
852
859
|
"sha256": latest.sha256,
|
|
853
860
|
"size": latest.size,
|
|
854
|
-
"downloaded": downloaded,
|
|
855
861
|
]
|
|
856
862
|
if let runtimeVersion = latest.runtimeVersion {
|
|
857
863
|
payload["runtimeVersion"] = runtimeVersion
|
|
@@ -860,72 +866,34 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
860
866
|
return payload
|
|
861
867
|
}
|
|
862
868
|
|
|
863
|
-
private func
|
|
864
|
-
|
|
869
|
+
private func downloadLatestManifest(
|
|
870
|
+
_ manifest: LatestManifest,
|
|
865
871
|
targetChannel: String?
|
|
866
|
-
) ->
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
targetChannel: String?
|
|
874
|
-
) -> Bool {
|
|
875
|
-
if trimToNil(bundle.channel) != targetChannel {
|
|
876
|
-
return false
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
if trimToNil(bundle.runtimeVersion) != trimToNil(latest.runtimeVersion) {
|
|
880
|
-
return false
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
if let bundleReleaseId = bundle.releaseId,
|
|
884
|
-
latest.releaseId == bundleReleaseId {
|
|
885
|
-
return true
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
if !latest.sha256.isEmpty,
|
|
889
|
-
let bundleSha = bundle.sha256,
|
|
890
|
-
!bundleSha.isEmpty,
|
|
891
|
-
latest.sha256 == bundleSha {
|
|
892
|
-
return true
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return latest.version == bundle.version
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
private func findMatchingStagedBundle(
|
|
899
|
-
latest: LatestManifest,
|
|
900
|
-
targetChannel: String?
|
|
901
|
-
) -> BundleInfo? {
|
|
902
|
-
guard let stagedId = store.getStagedBundleId() else {
|
|
903
|
-
return nil
|
|
904
|
-
}
|
|
905
|
-
guard let staged = store.getBundle(id: stagedId) else {
|
|
906
|
-
store.setStagedBundleId(nil)
|
|
907
|
-
return nil
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
if trimToNil(staged.channel) != targetChannel {
|
|
911
|
-
return nil
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
if trimToNil(staged.runtimeVersion) != trimToNil(latest.runtimeVersion) {
|
|
915
|
-
return nil
|
|
872
|
+
) async throws -> BundleInfo {
|
|
873
|
+
guard let url = URL(string: manifest.url) else {
|
|
874
|
+
throw NSError(
|
|
875
|
+
domain: "OtaKit",
|
|
876
|
+
code: 1,
|
|
877
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid download URL from manifest"]
|
|
878
|
+
)
|
|
916
879
|
}
|
|
917
880
|
|
|
918
|
-
return
|
|
881
|
+
return try await downloadAndStage(
|
|
882
|
+
url: url,
|
|
883
|
+
version: manifest.version,
|
|
884
|
+
expectedSha256: manifest.sha256,
|
|
885
|
+
expectedSize: manifest.size,
|
|
886
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
887
|
+
channel: targetChannel,
|
|
888
|
+
releaseId: manifest.releaseId
|
|
889
|
+
)
|
|
919
890
|
}
|
|
920
891
|
|
|
921
892
|
private func pruneIncompatibleBundles() {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if let failed = store.getFailedBundle(), !isCompatibleRuntime(failed) {
|
|
927
|
-
store.setFailedBundle(nil)
|
|
928
|
-
}
|
|
893
|
+
let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
|
|
894
|
+
isCompatibleRuntime: isCompatibleRuntime
|
|
895
|
+
)
|
|
896
|
+
coordinator.cleanupBundles(cleanupBundleIds)
|
|
929
897
|
}
|
|
930
898
|
|
|
931
899
|
private func isCompatibleRuntime(_ runtimeVersion: String?) -> Bool {
|
|
@@ -936,7 +904,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
936
904
|
isCompatibleRuntime(bundle.runtimeVersion)
|
|
937
905
|
}
|
|
938
906
|
|
|
939
|
-
private func buildBundleId(
|
|
907
|
+
private func buildBundleId(
|
|
908
|
+
version: String,
|
|
909
|
+
releaseId: String?,
|
|
910
|
+
sha256: String?
|
|
911
|
+
) -> String {
|
|
940
912
|
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
941
913
|
let allowed = CharacterSet(
|
|
942
914
|
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
|
|
@@ -965,7 +937,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
965
937
|
normalized = String(normalized.prefix(64))
|
|
966
938
|
}
|
|
967
939
|
|
|
968
|
-
let
|
|
940
|
+
let identitySource = trimToNil(releaseId) ?? trimToNil(sha256) ?? trimmed
|
|
941
|
+
let digest = SHA256.hash(data: Data(identitySource.utf8))
|
|
969
942
|
let suffix = digest.map { String(format: "%02x", $0) }.joined().prefix(12)
|
|
970
943
|
return "\(normalized)-\(suffix)"
|
|
971
944
|
}
|
|
@@ -978,18 +951,31 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
978
951
|
releaseId: String? = nil,
|
|
979
952
|
detail: String? = nil
|
|
980
953
|
) {
|
|
954
|
+
sendDeviceEvent(
|
|
955
|
+
UpdaterCoordinator.DeviceEventPayload(
|
|
956
|
+
action: action,
|
|
957
|
+
bundleVersion: bundleVersion,
|
|
958
|
+
runtimeVersion: runtimeVersion,
|
|
959
|
+
channel: channel,
|
|
960
|
+
releaseId: releaseId,
|
|
961
|
+
detail: detail
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private func sendDeviceEvent(_ payload: UpdaterCoordinator.DeviceEventPayload) {
|
|
981
967
|
guard let appId else {
|
|
982
968
|
return
|
|
983
969
|
}
|
|
984
|
-
guard let bundleVersion = trimToNil(bundleVersion) else {
|
|
970
|
+
guard let bundleVersion = trimToNil(payload.bundleVersion) else {
|
|
985
971
|
print("[OtaKit] Skipping device event without bundleVersion")
|
|
986
972
|
return
|
|
987
973
|
}
|
|
988
|
-
guard let releaseId = trimToNil(releaseId) else {
|
|
974
|
+
guard let releaseId = trimToNil(payload.releaseId) else {
|
|
989
975
|
print("[OtaKit] Skipping device event without releaseId")
|
|
990
976
|
return
|
|
991
977
|
}
|
|
992
|
-
let nativeBuild =
|
|
978
|
+
let nativeBuild = coordinator.nativeBuild.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
993
979
|
guard !nativeBuild.isEmpty else {
|
|
994
980
|
print("[OtaKit] Skipping device event without nativeBuild")
|
|
995
981
|
return
|
|
@@ -998,16 +984,39 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
998
984
|
ingestUrl: ingestUrl,
|
|
999
985
|
appId: appId,
|
|
1000
986
|
platform: "ios",
|
|
1001
|
-
action: action,
|
|
987
|
+
action: payload.action,
|
|
1002
988
|
bundleVersion: bundleVersion,
|
|
1003
|
-
channel: channel,
|
|
1004
|
-
runtimeVersion: trimToNil(runtimeVersion),
|
|
989
|
+
channel: payload.channel,
|
|
990
|
+
runtimeVersion: trimToNil(payload.runtimeVersion),
|
|
1005
991
|
releaseId: releaseId,
|
|
1006
992
|
nativeBuild: nativeBuild,
|
|
1007
|
-
detail: detail
|
|
993
|
+
detail: payload.detail
|
|
1008
994
|
)
|
|
1009
995
|
}
|
|
1010
996
|
|
|
997
|
+
private func isBundleUsable(_ bundle: BundleInfo) -> Bool {
|
|
998
|
+
guard !bundle.isBuiltin else {
|
|
999
|
+
return true
|
|
1000
|
+
}
|
|
1001
|
+
return isBundlePathUsable(bundle.path)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private func isBundlePathUsable(_ path: String?) -> Bool {
|
|
1005
|
+
guard let path = trimToNil(path) else {
|
|
1006
|
+
return false
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
var isDirectory: ObjCBool = false
|
|
1010
|
+
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
|
|
1011
|
+
isDirectory.boolValue else {
|
|
1012
|
+
return false
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let indexPath = URL(fileURLWithPath: path, isDirectory: true)
|
|
1016
|
+
.appendingPathComponent("index.html", isDirectory: false).path
|
|
1017
|
+
return fileManager.fileExists(atPath: indexPath)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1011
1020
|
private func resolveTargetChannel(_ channel: String?) -> String? {
|
|
1012
1021
|
if let channel = trimToNil(channel) {
|
|
1013
1022
|
return channel
|