@otakit/capacitor-updater 2.1.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -4
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +81 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +26 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +416 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +16 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +136 -4
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +43 -0
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +457 -20
- package/dist/esm/definitions.d.ts +104 -4
- 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 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +7 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +25 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +31 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +31 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +91 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +30 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +316 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +98 -6
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +29 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +422 -21
- package/package.json +1 -1
|
@@ -20,7 +20,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
20
20
|
|
|
21
21
|
private enum DownloadResolution {
|
|
22
22
|
case noUpdate
|
|
23
|
-
case staged(BundleInfo)
|
|
23
|
+
case staged(BundleInfo, forceImmediate: Bool)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
public let identifier = "UpdaterPlugin"
|
|
@@ -33,6 +33,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
33
33
|
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
|
|
34
34
|
CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
|
|
35
35
|
CAPPluginMethod(name: "getLastFailure", returnType: CAPPluginReturnPromise),
|
|
36
|
+
CAPPluginMethod(name: "setChannel", returnType: CAPPluginReturnPromise),
|
|
37
|
+
CAPPluginMethod(name: "getChannel", returnType: CAPPluginReturnPromise),
|
|
36
38
|
]
|
|
37
39
|
|
|
38
40
|
private let store = BundleStore()
|
|
@@ -52,6 +54,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
52
54
|
private var channel: String?
|
|
53
55
|
private var runtimeVersion: String?
|
|
54
56
|
private var manifestKeys: [(kid: String, key: Data)] = []
|
|
57
|
+
private var bundleKeys: [(kid: String, key: Data)] = []
|
|
55
58
|
private var trialTimeoutWorkItem: DispatchWorkItem?
|
|
56
59
|
private var checkIntervalMs: Int = 600_000
|
|
57
60
|
private var foregroundObserver: NSObjectProtocol?
|
|
@@ -105,6 +108,22 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
105
108
|
manifestKeys = HostedManifestKeys.defaults
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
let rawBundleKeysValue = getConfig().getArray("bundleKeys")
|
|
112
|
+
if let rawBundleKeys = rawBundleKeysValue as? [[String: String]] {
|
|
113
|
+
bundleKeys = rawBundleKeys.compactMap { entry in
|
|
114
|
+
guard let kid = entry["kid"],
|
|
115
|
+
let keyBase64 = entry["key"],
|
|
116
|
+
let keyData = Data(base64Encoded: keyBase64),
|
|
117
|
+
keyData.count == 32 else { return nil }
|
|
118
|
+
return (kid: kid, key: keyData)
|
|
119
|
+
}
|
|
120
|
+
if bundleKeys.isEmpty && !rawBundleKeys.isEmpty {
|
|
121
|
+
print("[OtaKit] ERROR: bundleKeys configured but all entries are invalid. Encrypted bundles cannot be decrypted.")
|
|
122
|
+
}
|
|
123
|
+
} else if rawBundleKeysValue != nil {
|
|
124
|
+
print("[OtaKit] ERROR: bundleKeys has wrong format (expected array of {kid, key}). Encrypted bundles cannot be decrypted.")
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
pruneIncompatibleBundles()
|
|
109
128
|
|
|
110
129
|
let startup = coordinator.normalizeStartupState(
|
|
@@ -196,13 +215,19 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
196
215
|
return
|
|
197
216
|
}
|
|
198
217
|
executeAutomaticUpdate(label: "runtime apply-staged fallback") { [self] in
|
|
199
|
-
|
|
218
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
200
219
|
resolveCurrentRuntimeKey()
|
|
220
|
+
if isForcedStaged(result) {
|
|
221
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
222
|
+
}
|
|
201
223
|
}
|
|
202
224
|
case .shadow:
|
|
203
225
|
executeAutomaticUpdate(label: "runtime shadow") { [self] in
|
|
204
|
-
|
|
226
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
205
227
|
resolveCurrentRuntimeKey()
|
|
228
|
+
if isForcedStaged(result) {
|
|
229
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
230
|
+
}
|
|
206
231
|
}
|
|
207
232
|
case .immediate:
|
|
208
233
|
executeAutomaticUpdate(label: "runtime immediate") { [self] in
|
|
@@ -232,11 +257,17 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
232
257
|
return
|
|
233
258
|
}
|
|
234
259
|
executeAutomaticUpdate(label: "launch apply-staged fallback") { [self] in
|
|
235
|
-
|
|
260
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
261
|
+
if isForcedStaged(result) {
|
|
262
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
263
|
+
}
|
|
236
264
|
}
|
|
237
265
|
case .shadow:
|
|
238
266
|
executeAutomaticUpdate(label: "launch shadow") { [self] in
|
|
239
|
-
|
|
267
|
+
let result = try await downloadLatest(respectInterval: false, channel: nil)
|
|
268
|
+
if isForcedStaged(result) {
|
|
269
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
270
|
+
}
|
|
240
271
|
}
|
|
241
272
|
case .immediate:
|
|
242
273
|
executeAutomaticUpdate(label: "launch immediate") { [self] in
|
|
@@ -257,11 +288,17 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
257
288
|
if try applyStaged(reloadAfterApply: true) {
|
|
258
289
|
return
|
|
259
290
|
}
|
|
260
|
-
|
|
291
|
+
let result = try await downloadLatest(respectInterval: true, channel: nil)
|
|
292
|
+
if isForcedStaged(result) {
|
|
293
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
294
|
+
}
|
|
261
295
|
}
|
|
262
296
|
case .shadow:
|
|
263
297
|
executeAutomaticUpdate(label: "resume shadow") { [self] in
|
|
264
|
-
|
|
298
|
+
let result = try await downloadLatest(respectInterval: true, channel: nil)
|
|
299
|
+
if isForcedStaged(result) {
|
|
300
|
+
try requireApplyStaged(reloadAfterApply: true)
|
|
301
|
+
}
|
|
265
302
|
}
|
|
266
303
|
case .immediate:
|
|
267
304
|
executeAutomaticUpdate(label: "resume immediate") { [self] in
|
|
@@ -413,6 +450,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
413
450
|
coordinator.cleanupBundles(preparation.cleanupBundleIds)
|
|
414
451
|
if let eventPayload = preparation.eventPayload {
|
|
415
452
|
sendDeviceEvent(eventPayload)
|
|
453
|
+
// eventPayload is non-nil only on a genuine trial -> success transition,
|
|
454
|
+
// so repeat notifyAppReady() calls never double-emit.
|
|
455
|
+
emitEvent("updateApplied", ["bundle": store.getCurrentBundle().toDictionary()])
|
|
416
456
|
}
|
|
417
457
|
|
|
418
458
|
call.resolve()
|
|
@@ -426,6 +466,54 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
426
466
|
call.resolve(failed.toDictionary())
|
|
427
467
|
}
|
|
428
468
|
|
|
469
|
+
@objc func setChannel(_ call: CAPPluginCall) {
|
|
470
|
+
let raw = call.options["channel"]
|
|
471
|
+
if raw == nil || raw is NSNull {
|
|
472
|
+
store.setOverrideChannel(nil)
|
|
473
|
+
call.resolve()
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
guard let name = raw as? String else {
|
|
477
|
+
call.reject("channel must be a string or null")
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
guard isValidChannelName(name) else {
|
|
481
|
+
call.reject(
|
|
482
|
+
"Invalid channel name '\(name)': use 1-64 letters, numbers, '.', '_' or '-' (reserved names: base, default)"
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
store.setOverrideChannel(name)
|
|
487
|
+
call.resolve()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@objc func getChannel(_ call: CAPPluginCall) {
|
|
491
|
+
if let override = store.getOverrideChannel() {
|
|
492
|
+
call.resolve(["channel": override, "source": "override"])
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
call.resolve([
|
|
496
|
+
"channel": channel ?? NSNull(),
|
|
497
|
+
"source": "config",
|
|
498
|
+
])
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/// Mirrors the server's isValidChannelName (console/lib/validation.ts):
|
|
502
|
+
/// charset regex plus reserved names. The channel is interpolated into the
|
|
503
|
+
/// manifest CDN path, so anything outside this charset (or a ".." sequence)
|
|
504
|
+
/// is rejected before it is persisted or used.
|
|
505
|
+
private func isValidChannelName(_ name: String) -> Bool {
|
|
506
|
+
// \A/\z anchor the whole input: ICU's ^/$ would accept a trailing
|
|
507
|
+
// line terminator (e.g. "beta\n"), diverging from Android/server.
|
|
508
|
+
guard name.range(of: "\\A[A-Za-z0-9._-]{1,64}\\z", options: .regularExpression) != nil else {
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
if name.contains("..") || name == "." {
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
return !["base", "default"].contains(name.lowercased())
|
|
515
|
+
}
|
|
516
|
+
|
|
429
517
|
private func fetchLatest(channel: String?) async throws -> LatestManifest? {
|
|
430
518
|
guard let appId else {
|
|
431
519
|
throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
|
|
@@ -463,6 +551,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
463
551
|
|
|
464
552
|
let resolution = try classifyLatestManifest(manifest, targetChannel: targetChannel)
|
|
465
553
|
|
|
554
|
+
if case let .updateAvailable(available) = resolution {
|
|
555
|
+
emitEvent("updateAvailable", manifestToDictionary(available))
|
|
556
|
+
}
|
|
557
|
+
|
|
466
558
|
if respectInterval {
|
|
467
559
|
recordCheckTimestamp()
|
|
468
560
|
}
|
|
@@ -478,15 +570,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
478
570
|
switch result {
|
|
479
571
|
case .noUpdate:
|
|
480
572
|
return .noUpdate
|
|
481
|
-
case let .alreadyStaged(
|
|
482
|
-
return .staged(bundle)
|
|
573
|
+
case let .alreadyStaged(latest, bundle):
|
|
574
|
+
return .staged(bundle, forceImmediate: latest.forceImmediate)
|
|
483
575
|
case let .updateAvailable(manifest):
|
|
484
576
|
do {
|
|
485
577
|
let bundle = try await downloadLatestManifest(
|
|
486
578
|
manifest,
|
|
487
579
|
targetChannel: targetChannel
|
|
488
580
|
)
|
|
489
|
-
return .staged(bundle)
|
|
581
|
+
return .staged(bundle, forceImmediate: manifest.forceImmediate)
|
|
490
582
|
} catch let error as NSError where isExpiredURLError(error) {
|
|
491
583
|
guard let refreshed = try await fetchLatest(channel: targetChannel) else {
|
|
492
584
|
return .noUpdate
|
|
@@ -495,14 +587,14 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
495
587
|
switch try classifyLatestManifest(refreshed, targetChannel: targetChannel) {
|
|
496
588
|
case .noUpdate:
|
|
497
589
|
return .noUpdate
|
|
498
|
-
case let .alreadyStaged(
|
|
499
|
-
return .staged(bundle)
|
|
590
|
+
case let .alreadyStaged(latest, bundle):
|
|
591
|
+
return .staged(bundle, forceImmediate: latest.forceImmediate)
|
|
500
592
|
case let .updateAvailable(retryManifest):
|
|
501
593
|
let bundle = try await downloadLatestManifest(
|
|
502
594
|
retryManifest,
|
|
503
595
|
targetChannel: targetChannel
|
|
504
596
|
)
|
|
505
|
-
return .staged(bundle)
|
|
597
|
+
return .staged(bundle, forceImmediate: retryManifest.forceImmediate)
|
|
506
598
|
}
|
|
507
599
|
}
|
|
508
600
|
}
|
|
@@ -555,7 +647,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
555
647
|
switch result {
|
|
556
648
|
case .noUpdate:
|
|
557
649
|
return ["kind": "no_update"]
|
|
558
|
-
case let .staged(bundle):
|
|
650
|
+
case let .staged(bundle, _):
|
|
559
651
|
return [
|
|
560
652
|
"kind": "staged",
|
|
561
653
|
"bundle": bundle.toDictionary(),
|
|
@@ -563,6 +655,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
563
655
|
}
|
|
564
656
|
}
|
|
565
657
|
|
|
658
|
+
/// True when an automatic flow staged a bundle whose release is marked
|
|
659
|
+
/// force-immediate — the flow escalates to apply + reload.
|
|
660
|
+
private func isForcedStaged(_ result: DownloadResolution) -> Bool {
|
|
661
|
+
if case let .staged(_, forceImmediate) = result {
|
|
662
|
+
return forceImmediate
|
|
663
|
+
}
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
|
|
566
667
|
private func isExpiredURLError(_ error: NSError) -> Bool {
|
|
567
668
|
// HTTP 403 or 410 typically indicates an expired presigned URL
|
|
568
669
|
if error.domain == "Downloader" && (error.code == 403 || error.code == 410) {
|
|
@@ -579,11 +680,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
579
680
|
expectedSize: Int? = nil,
|
|
580
681
|
runtimeVersion: String? = nil,
|
|
581
682
|
channel: String? = nil,
|
|
582
|
-
releaseId: String? = nil
|
|
683
|
+
releaseId: String? = nil,
|
|
684
|
+
encryption: ManifestEncryption? = nil
|
|
583
685
|
) async throws -> BundleInfo {
|
|
584
686
|
// Check disk space before downloading
|
|
585
687
|
if let size = expectedSize {
|
|
586
|
-
|
|
688
|
+
// zip + extracted + buffer; encrypted bundles keep an extra decrypted
|
|
689
|
+
// zip copy on disk between decrypt and extract.
|
|
690
|
+
let multiplier = encryption != nil ? 3.5 : 2.5
|
|
691
|
+
let requiredSpace = Int64(Double(size) * multiplier)
|
|
587
692
|
let availableSpace = getFreeDiskSpace()
|
|
588
693
|
if availableSpace < requiredSpace {
|
|
589
694
|
let error = NSError(
|
|
@@ -599,20 +704,60 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
599
704
|
releaseId: releaseId,
|
|
600
705
|
detail: "insufficient_disk_space"
|
|
601
706
|
)
|
|
707
|
+
emitEvent(
|
|
708
|
+
"downloadFailed",
|
|
709
|
+
failureEventData(
|
|
710
|
+
version: version,
|
|
711
|
+
runtimeVersion: runtimeVersion,
|
|
712
|
+
channel: channel,
|
|
713
|
+
releaseId: releaseId,
|
|
714
|
+
reason: "insufficient_disk_space"
|
|
715
|
+
)
|
|
716
|
+
)
|
|
602
717
|
throw error
|
|
603
718
|
}
|
|
604
719
|
}
|
|
605
720
|
|
|
606
|
-
let zipURL
|
|
721
|
+
let zipURL: URL
|
|
722
|
+
do {
|
|
723
|
+
zipURL = try await downloader.download(from: url)
|
|
724
|
+
} catch {
|
|
725
|
+
// Network failures must report like every other download-path failure
|
|
726
|
+
// (Android's downloadZip already sits inside its try block).
|
|
727
|
+
sendDeviceEvent(
|
|
728
|
+
action: .downloadError,
|
|
729
|
+
bundleVersion: version,
|
|
730
|
+
runtimeVersion: runtimeVersion,
|
|
731
|
+
channel: channel,
|
|
732
|
+
releaseId: releaseId,
|
|
733
|
+
detail: error.localizedDescription
|
|
734
|
+
)
|
|
735
|
+
emitEvent(
|
|
736
|
+
"downloadFailed",
|
|
737
|
+
failureEventData(
|
|
738
|
+
version: version,
|
|
739
|
+
runtimeVersion: runtimeVersion,
|
|
740
|
+
channel: channel,
|
|
741
|
+
releaseId: releaseId,
|
|
742
|
+
reason: failureReason(from: error)
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
throw error
|
|
746
|
+
}
|
|
607
747
|
|
|
608
748
|
let extractDirectory = fileManager.temporaryDirectory
|
|
609
749
|
.appendingPathComponent("otakit-extract-\(UUID().uuidString)", isDirectory: true)
|
|
750
|
+
let decryptedZipURL = fileManager.temporaryDirectory
|
|
751
|
+
.appendingPathComponent("otakit-decrypted-\(UUID().uuidString).zip")
|
|
610
752
|
defer {
|
|
611
753
|
try? fileManager.removeItem(at: zipURL)
|
|
754
|
+
try? fileManager.removeItem(at: decryptedZipURL)
|
|
612
755
|
try? fileManager.removeItem(at: extractDirectory)
|
|
613
756
|
}
|
|
614
757
|
|
|
615
758
|
do {
|
|
759
|
+
// The manifest sha256 covers the downloaded object as-is — the
|
|
760
|
+
// ciphertext when the bundle is encrypted.
|
|
616
761
|
let valid = try HashUtils.verify(
|
|
617
762
|
fileURL: zipURL,
|
|
618
763
|
expectedSha256: expectedSha256
|
|
@@ -625,7 +770,26 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
625
770
|
)
|
|
626
771
|
}
|
|
627
772
|
|
|
628
|
-
|
|
773
|
+
var zipToExtract = zipURL
|
|
774
|
+
if let encryption {
|
|
775
|
+
guard let bundleKey = bundleKeys.first(where: { $0.kid == encryption.kid }) else {
|
|
776
|
+
throw BundleCryptoError.noMatchingKey(encryption.kid)
|
|
777
|
+
}
|
|
778
|
+
let dek = try BundleCrypto.unwrapDek(
|
|
779
|
+
kek: bundleKey.key,
|
|
780
|
+
wrapNonceB64: encryption.wrapNonce,
|
|
781
|
+
wrappedDekB64: encryption.wrappedDek
|
|
782
|
+
)
|
|
783
|
+
try BundleCrypto.decryptFile(
|
|
784
|
+
dek: dek,
|
|
785
|
+
nonceB64: encryption.nonce,
|
|
786
|
+
input: zipURL,
|
|
787
|
+
output: decryptedZipURL
|
|
788
|
+
)
|
|
789
|
+
zipToExtract = decryptedZipURL
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
try zipUtils.extractSecurely(zipURL: zipToExtract, to: extractDirectory)
|
|
629
793
|
let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
|
|
630
794
|
|
|
631
795
|
let bundleId = buildBundleId(
|
|
@@ -665,6 +829,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
665
829
|
channel: channel,
|
|
666
830
|
releaseId: releaseId
|
|
667
831
|
)
|
|
832
|
+
emitEvent("updateStaged", ["bundle": info.toDictionary()])
|
|
668
833
|
return info
|
|
669
834
|
} catch {
|
|
670
835
|
sendDeviceEvent(
|
|
@@ -675,6 +840,16 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
675
840
|
releaseId: releaseId,
|
|
676
841
|
detail: error.localizedDescription
|
|
677
842
|
)
|
|
843
|
+
emitEvent(
|
|
844
|
+
"downloadFailed",
|
|
845
|
+
failureEventData(
|
|
846
|
+
version: version,
|
|
847
|
+
runtimeVersion: runtimeVersion,
|
|
848
|
+
channel: channel,
|
|
849
|
+
releaseId: releaseId,
|
|
850
|
+
reason: failureReason(from: error)
|
|
851
|
+
)
|
|
852
|
+
)
|
|
678
853
|
throw error
|
|
679
854
|
}
|
|
680
855
|
}
|
|
@@ -778,6 +953,16 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
778
953
|
coordinator.cleanupBundles(preparation.cleanupBundleIds)
|
|
779
954
|
if let eventPayload = preparation.eventPayload {
|
|
780
955
|
sendDeviceEvent(eventPayload)
|
|
956
|
+
emitEvent(
|
|
957
|
+
"rollback",
|
|
958
|
+
failureEventData(
|
|
959
|
+
version: eventPayload.bundleVersion ?? "",
|
|
960
|
+
runtimeVersion: eventPayload.runtimeVersion,
|
|
961
|
+
channel: eventPayload.channel,
|
|
962
|
+
releaseId: eventPayload.releaseId,
|
|
963
|
+
reason: reason
|
|
964
|
+
)
|
|
965
|
+
)
|
|
781
966
|
}
|
|
782
967
|
|
|
783
968
|
do {
|
|
@@ -855,14 +1040,18 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
855
1040
|
) -> [String: Any] {
|
|
856
1041
|
var payload: [String: Any] = [
|
|
857
1042
|
"version": latest.version,
|
|
858
|
-
"url": latest.url,
|
|
859
1043
|
"sha256": latest.sha256,
|
|
860
1044
|
"size": latest.size,
|
|
1045
|
+
"strategy": latest.strategy,
|
|
861
1046
|
]
|
|
1047
|
+
if let url = latest.url {
|
|
1048
|
+
payload["url"] = url
|
|
1049
|
+
}
|
|
862
1050
|
if let runtimeVersion = latest.runtimeVersion {
|
|
863
1051
|
payload["runtimeVersion"] = runtimeVersion
|
|
864
1052
|
}
|
|
865
1053
|
payload["releaseId"] = latest.releaseId
|
|
1054
|
+
payload["forceImmediate"] = latest.forceImmediate
|
|
866
1055
|
return payload
|
|
867
1056
|
}
|
|
868
1057
|
|
|
@@ -870,7 +1059,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
870
1059
|
_ manifest: LatestManifest,
|
|
871
1060
|
targetChannel: String?
|
|
872
1061
|
) async throws -> BundleInfo {
|
|
873
|
-
|
|
1062
|
+
if manifest.strategy == "deltas" {
|
|
1063
|
+
return try await assembleAndStage(manifest: manifest, targetChannel: targetChannel)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
guard let urlString = manifest.url, let url = URL(string: urlString) else {
|
|
874
1067
|
throw NSError(
|
|
875
1068
|
domain: "OtaKit",
|
|
876
1069
|
code: 1,
|
|
@@ -885,10 +1078,168 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
885
1078
|
expectedSize: manifest.size,
|
|
886
1079
|
runtimeVersion: manifest.runtimeVersion,
|
|
887
1080
|
channel: targetChannel,
|
|
888
|
-
releaseId: manifest.releaseId
|
|
1081
|
+
releaseId: manifest.releaseId,
|
|
1082
|
+
encryption: manifest.encryption
|
|
889
1083
|
)
|
|
890
1084
|
}
|
|
891
1085
|
|
|
1086
|
+
private static let bundleFileListName = "otakit_files.json"
|
|
1087
|
+
|
|
1088
|
+
/// Deltas strategy: fill content-cache misses and assemble the bundle from
|
|
1089
|
+
/// the cache, then hand it to the same staging path the zip flow uses.
|
|
1090
|
+
private func assembleAndStage(
|
|
1091
|
+
manifest: LatestManifest,
|
|
1092
|
+
targetChannel: String?
|
|
1093
|
+
) async throws -> BundleInfo {
|
|
1094
|
+
// Same conservative disk-space guard as the zip path.
|
|
1095
|
+
let requiredSpace = Int64(Double(manifest.size) * 2.5)
|
|
1096
|
+
if getFreeDiskSpace() < requiredSpace {
|
|
1097
|
+
sendDeviceEvent(
|
|
1098
|
+
action: .downloadError,
|
|
1099
|
+
bundleVersion: manifest.version,
|
|
1100
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1101
|
+
channel: targetChannel,
|
|
1102
|
+
releaseId: manifest.releaseId,
|
|
1103
|
+
detail: "insufficient_disk_space"
|
|
1104
|
+
)
|
|
1105
|
+
emitEvent(
|
|
1106
|
+
"downloadFailed",
|
|
1107
|
+
failureEventData(
|
|
1108
|
+
version: manifest.version,
|
|
1109
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1110
|
+
channel: targetChannel,
|
|
1111
|
+
releaseId: manifest.releaseId,
|
|
1112
|
+
reason: "insufficient_disk_space"
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
throw NSError(
|
|
1116
|
+
domain: "OtaKit",
|
|
1117
|
+
code: 1,
|
|
1118
|
+
userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
let assembleDirectory = fileManager.temporaryDirectory
|
|
1123
|
+
.appendingPathComponent("otakit-assemble-\(UUID().uuidString)", isDirectory: true)
|
|
1124
|
+
defer {
|
|
1125
|
+
try? fileManager.removeItem(at: assembleDirectory)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
do {
|
|
1129
|
+
guard let files = manifest.files, !files.isEmpty else {
|
|
1130
|
+
throw NSError(
|
|
1131
|
+
domain: "OtaKit",
|
|
1132
|
+
code: 1,
|
|
1133
|
+
userInfo: [NSLocalizedDescriptionKey: "Delta manifest is missing its file list"]
|
|
1134
|
+
)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
let assembler = DeltaAssembler(
|
|
1138
|
+
cacheDirectory: store.filesCacheDirectory,
|
|
1139
|
+
downloader: downloader
|
|
1140
|
+
)
|
|
1141
|
+
try assembler.validate(files, expectedFilesHash: manifest.sha256)
|
|
1142
|
+
|
|
1143
|
+
if let builtinDirectory = Bundle.main.resourceURL?
|
|
1144
|
+
.appendingPathComponent("public", isDirectory: true) {
|
|
1145
|
+
assembler.seedFromBuiltinIfNeeded(
|
|
1146
|
+
builtinDirectory: builtinDirectory,
|
|
1147
|
+
nativeBuild: coordinator.nativeBuild
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
try await assembler.assemble(entries: files, into: assembleDirectory)
|
|
1152
|
+
|
|
1153
|
+
let bundleId = buildBundleId(
|
|
1154
|
+
version: manifest.version,
|
|
1155
|
+
releaseId: manifest.releaseId,
|
|
1156
|
+
sha256: manifest.sha256
|
|
1157
|
+
)
|
|
1158
|
+
let destination = coordinator.bundleDirectory(for: bundleId)
|
|
1159
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1160
|
+
try fileManager.removeItem(at: destination)
|
|
1161
|
+
}
|
|
1162
|
+
try fileManager.moveItem(at: assembleDirectory, to: destination)
|
|
1163
|
+
|
|
1164
|
+
// Record this bundle's content hashes for cache pruning.
|
|
1165
|
+
let hashes = files.map { $0.sha256.lowercased() }
|
|
1166
|
+
if let data = try? JSONSerialization.data(withJSONObject: hashes) {
|
|
1167
|
+
try? data.write(
|
|
1168
|
+
to: destination.appendingPathComponent(UpdaterPlugin.bundleFileListName),
|
|
1169
|
+
options: .atomic
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
let info = BundleInfo(
|
|
1174
|
+
id: bundleId,
|
|
1175
|
+
version: manifest.version,
|
|
1176
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1177
|
+
status: .pending,
|
|
1178
|
+
downloadedAt: Date(),
|
|
1179
|
+
sha256: manifest.sha256,
|
|
1180
|
+
path: destination.path,
|
|
1181
|
+
channel: targetChannel,
|
|
1182
|
+
releaseId: manifest.releaseId
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
|
|
1186
|
+
coordinator.cleanupBundles(cleanupBundleIds)
|
|
1187
|
+
|
|
1188
|
+
pruneDeltaCache(assembler: assembler)
|
|
1189
|
+
|
|
1190
|
+
sendDeviceEvent(
|
|
1191
|
+
action: .downloaded,
|
|
1192
|
+
bundleVersion: manifest.version,
|
|
1193
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1194
|
+
channel: targetChannel,
|
|
1195
|
+
releaseId: manifest.releaseId
|
|
1196
|
+
)
|
|
1197
|
+
emitEvent("updateStaged", ["bundle": info.toDictionary()])
|
|
1198
|
+
return info
|
|
1199
|
+
} catch {
|
|
1200
|
+
sendDeviceEvent(
|
|
1201
|
+
action: .downloadError,
|
|
1202
|
+
bundleVersion: manifest.version,
|
|
1203
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1204
|
+
channel: targetChannel,
|
|
1205
|
+
releaseId: manifest.releaseId,
|
|
1206
|
+
detail: error.localizedDescription
|
|
1207
|
+
)
|
|
1208
|
+
emitEvent(
|
|
1209
|
+
"downloadFailed",
|
|
1210
|
+
failureEventData(
|
|
1211
|
+
version: manifest.version,
|
|
1212
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1213
|
+
channel: targetChannel,
|
|
1214
|
+
releaseId: manifest.releaseId,
|
|
1215
|
+
reason: failureReason(from: error)
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
throw error
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/// Keep only cache entries referenced by live bundles (plus the builtin seed).
|
|
1223
|
+
private func pruneDeltaCache(assembler: DeltaAssembler) {
|
|
1224
|
+
var referenced = Set<String>()
|
|
1225
|
+
let liveBundleIds = [
|
|
1226
|
+
store.getCurrentBundleId(),
|
|
1227
|
+
store.getFallbackBundleId(),
|
|
1228
|
+
store.getStagedBundleId(),
|
|
1229
|
+
]
|
|
1230
|
+
for bundleId in liveBundleIds {
|
|
1231
|
+
guard let bundleId else { continue }
|
|
1232
|
+
let listURL = store.bundleDirectory(for: bundleId)
|
|
1233
|
+
.appendingPathComponent(UpdaterPlugin.bundleFileListName)
|
|
1234
|
+
guard let data = try? Data(contentsOf: listURL),
|
|
1235
|
+
let hashes = try? JSONSerialization.jsonObject(with: data) as? [String] else {
|
|
1236
|
+
continue
|
|
1237
|
+
}
|
|
1238
|
+
referenced.formUnion(hashes.map { $0.lowercased() })
|
|
1239
|
+
}
|
|
1240
|
+
assembler.pruneCache(referencedHashes: referenced)
|
|
1241
|
+
}
|
|
1242
|
+
|
|
892
1243
|
private func pruneIncompatibleBundles() {
|
|
893
1244
|
let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
|
|
894
1245
|
isCompatibleRuntime: isCompatibleRuntime
|
|
@@ -943,6 +1294,53 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
943
1294
|
return "\(normalized)-\(suffix)"
|
|
944
1295
|
}
|
|
945
1296
|
|
|
1297
|
+
/// Emit a JS lifecycle event. `notifyListeners` marshals to the bridge
|
|
1298
|
+
/// safely from any thread, so no manual dispatch is needed.
|
|
1299
|
+
private func emitEvent(_ name: String, _ data: [String: Any]) {
|
|
1300
|
+
notifyListeners(name, data: data)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private func failureEventData(
|
|
1304
|
+
version: String,
|
|
1305
|
+
runtimeVersion: String?,
|
|
1306
|
+
channel: String?,
|
|
1307
|
+
releaseId: String?,
|
|
1308
|
+
reason: String
|
|
1309
|
+
) -> [String: Any] {
|
|
1310
|
+
var data: [String: Any] = [
|
|
1311
|
+
"version": version,
|
|
1312
|
+
"reason": reason,
|
|
1313
|
+
]
|
|
1314
|
+
if let runtimeVersion = trimToNil(runtimeVersion) {
|
|
1315
|
+
data["runtimeVersion"] = runtimeVersion
|
|
1316
|
+
}
|
|
1317
|
+
if let channel = trimToNil(channel) {
|
|
1318
|
+
data["channel"] = channel
|
|
1319
|
+
}
|
|
1320
|
+
if let releaseId = trimToNil(releaseId) {
|
|
1321
|
+
data["releaseId"] = releaseId
|
|
1322
|
+
}
|
|
1323
|
+
return data
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/// Map a native error to a stable event reason string.
|
|
1327
|
+
private func failureReason(from error: Error) -> String {
|
|
1328
|
+
if error is ZipUtilsError {
|
|
1329
|
+
return "extract_failed"
|
|
1330
|
+
}
|
|
1331
|
+
let description = error.localizedDescription.lowercased()
|
|
1332
|
+
if description.contains("hash mismatch") {
|
|
1333
|
+
return "hash_mismatch"
|
|
1334
|
+
}
|
|
1335
|
+
if description.contains("disk space") {
|
|
1336
|
+
return "insufficient_disk_space"
|
|
1337
|
+
}
|
|
1338
|
+
if description.contains("index.html") {
|
|
1339
|
+
return "invalid_bundle"
|
|
1340
|
+
}
|
|
1341
|
+
return "download_failed"
|
|
1342
|
+
}
|
|
1343
|
+
|
|
946
1344
|
private func sendDeviceEvent(
|
|
947
1345
|
action: DeviceEventAction,
|
|
948
1346
|
bundleVersion: String? = nil,
|
|
@@ -1021,6 +1419,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1021
1419
|
if let channel = trimToNil(channel) {
|
|
1022
1420
|
return channel
|
|
1023
1421
|
}
|
|
1422
|
+
if let override = store.getOverrideChannel() {
|
|
1423
|
+
return override
|
|
1424
|
+
}
|
|
1024
1425
|
return self.channel
|
|
1025
1426
|
}
|
|
1026
1427
|
|