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