@otakit/capacitor-updater 2.3.2 → 3.0.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 +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import Capacitor
|
|
2
|
-
import CryptoKit
|
|
3
2
|
import Foundation
|
|
4
3
|
import UIKit
|
|
5
4
|
|
|
@@ -55,7 +54,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
55
54
|
private var runtimeVersion: String?
|
|
56
55
|
private var manifestKeys: [(kid: String, key: Data)] = []
|
|
57
56
|
private var bundleKeys: [(kid: String, key: Data)] = []
|
|
58
|
-
private var
|
|
57
|
+
private lazy var updateOwner = UpdateOwner(isAvailable: { [weak self] in self?.bridge?.webView != nil })
|
|
58
|
+
private let documentReadyBridge = DocumentReadyBridge()
|
|
59
|
+
private let trialDeadline = ForegroundDeadline()
|
|
59
60
|
private var checkIntervalMs: Int = 600_000
|
|
60
61
|
private var foregroundObserver: NSObjectProtocol?
|
|
61
62
|
private static let defaultIngestURL = "https://ingest.otakit.app/v1"
|
|
@@ -65,6 +66,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
65
66
|
private static let defaultRuntimeKey = "__default__"
|
|
66
67
|
|
|
67
68
|
public override func load() {
|
|
69
|
+
DeviceEventClient.resume()
|
|
70
|
+
trialDeadline.observe(
|
|
71
|
+
center: .default,
|
|
72
|
+
active: UIApplication.didBecomeActiveNotification,
|
|
73
|
+
inactive: UIApplication.willResignActiveNotification,
|
|
74
|
+
initiallyActive: UIApplication.shared.applicationState == .active
|
|
75
|
+
)
|
|
68
76
|
let envIngestUrl = ProcessInfo.processInfo.environment["OTAKIT_INGEST_URL"]
|
|
69
77
|
let envCdnUrl = ProcessInfo.processInfo.environment["OTAKIT_CDN_URL"]
|
|
70
78
|
ingestUrl = resolveIngestUrl(
|
|
@@ -87,22 +95,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
87
95
|
appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
|
|
88
96
|
checkIntervalMs = getConfig().getInt("checkInterval", 600_000)
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
if let rawKeys = rawKeysValue as? [[String: String]] {
|
|
92
|
-
manifestKeys = rawKeys.compactMap { entry in
|
|
93
|
-
guard let kid = entry["kid"],
|
|
94
|
-
let keyBase64 = entry["key"],
|
|
95
|
-
let keyData = Data(base64Encoded: keyBase64) else { return nil }
|
|
96
|
-
return (kid: kid, key: keyData)
|
|
97
|
-
}
|
|
98
|
-
if manifestKeys.isEmpty && !rawKeys.isEmpty {
|
|
99
|
-
print("[OtaKit] ERROR: manifestKeys configured but all entries are invalid. Manifest verification will reject all updates.")
|
|
100
|
-
manifestKeys = [(kid: "_invalid_", key: Data())]
|
|
101
|
-
}
|
|
102
|
-
} else if rawKeysValue != nil {
|
|
103
|
-
print("[OtaKit] ERROR: manifestKeys has wrong format (expected array of {kid, key}). Manifest verification will reject all updates.")
|
|
104
|
-
manifestKeys = [(kid: "_invalid_", key: Data())]
|
|
105
|
-
}
|
|
98
|
+
manifestKeys = ManifestKeyConfig.parse(getConfig().getConfigJSON())
|
|
106
99
|
|
|
107
100
|
if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedManifestURL(cdnUrl) {
|
|
108
101
|
manifestKeys = HostedManifestKeys.defaults
|
|
@@ -124,40 +117,48 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
124
117
|
print("[OtaKit] ERROR: bundleKeys has wrong format (expected array of {kid, key}). Encrypted bundles cannot be decrypted.")
|
|
125
118
|
}
|
|
126
119
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
isBundleUsable: isBundleUsable
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
let startup: UpdaterCoordinator.StartupPreparation
|
|
121
|
+
do {
|
|
122
|
+
try pruneIncompatibleBundles()
|
|
123
|
+
startup = try coordinator.normalizeStartupState(isBundleUsable: isBundleUsable)
|
|
124
|
+
} catch {
|
|
125
|
+
print("[OtaKit] Cannot persist startup recovery: \(error.localizedDescription)")
|
|
126
|
+
try? applyServerBasePathSynchronously(nil)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
cleanupInBackground(startup.cleanupBundleIds)
|
|
133
130
|
|
|
134
131
|
do {
|
|
132
|
+
if let trial = startup.trial { try installReadinessBridge(for: trial) }
|
|
135
133
|
try applyServerBasePathSynchronously(startup.activationPath)
|
|
136
134
|
} catch {
|
|
137
135
|
print("[OtaKit] startup activation failed: \(error.localizedDescription)")
|
|
136
|
+
if let trial = startup.trial {
|
|
137
|
+
rollbackCurrentBundle(expectedTrial: trial, reason: "activation_failed", shouldReload: true)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
if let eventPayload = startup.eventPayload {
|
|
141
143
|
sendDeviceEvent(eventPayload)
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
if let
|
|
145
|
-
scheduleTrialTimeout(for:
|
|
146
|
+
if let trial = startup.trial {
|
|
147
|
+
scheduleTrialTimeout(for: trial)
|
|
146
148
|
} else {
|
|
147
149
|
cancelTrialTimeout()
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
dispatchColdStart()
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
foregroundObserver = NotificationCenter.default.addObserver(
|
|
154
|
+
foregroundObserver = NotificationCenter.default.addObserver(
|
|
154
155
|
forName: UIApplication.willEnterForegroundNotification,
|
|
155
156
|
object: nil,
|
|
156
157
|
queue: .main
|
|
157
158
|
) { [weak self] _ in
|
|
158
|
-
|
|
159
|
+
DeviceEventClient.resume()
|
|
160
|
+
if self?.resumePolicy != .off { self?.handleAppWillEnterForeground() }
|
|
159
161
|
}
|
|
160
|
-
}
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
deinit {
|
|
@@ -322,7 +323,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
322
323
|
Task {
|
|
323
324
|
defer { coordinator.endOperation() }
|
|
324
325
|
do {
|
|
326
|
+
try ensureOwnerActive()
|
|
325
327
|
try await operation()
|
|
328
|
+
} catch is CancellationError {
|
|
329
|
+
// The owning bridge went away; this is not a failed update attempt.
|
|
326
330
|
} catch {
|
|
327
331
|
print("[OtaKit] \(label) failed: \(error.localizedDescription)")
|
|
328
332
|
}
|
|
@@ -445,17 +449,23 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
445
449
|
}
|
|
446
450
|
|
|
447
451
|
@objc func notifyAppReady(_ call: CAPPluginCall) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
452
|
+
DispatchQueue.main.async { [weak self] in
|
|
453
|
+
guard let self else { call.reject("Updater is unavailable"); return }
|
|
454
|
+
do {
|
|
455
|
+
let preparation = try self.updateOwner.run {
|
|
456
|
+
try self.coordinator.prepareNotifyAppReady(activationId: call.getString("_otakitActivationId"))
|
|
457
|
+
}
|
|
458
|
+
self.cleanupInBackground(preparation.cleanupBundleIds)
|
|
459
|
+
if let eventPayload = preparation.eventPayload {
|
|
460
|
+
self.cancelTrialTimeout()
|
|
461
|
+
self.sendDeviceEvent(eventPayload)
|
|
462
|
+
self.emitEvent("updateApplied", ["bundle": self.store.getCurrentBundle().toDictionary()])
|
|
463
|
+
}
|
|
464
|
+
call.resolve()
|
|
465
|
+
} catch {
|
|
466
|
+
call.reject("Could not persist update readiness: \(error.localizedDescription)")
|
|
467
|
+
}
|
|
456
468
|
}
|
|
457
|
-
|
|
458
|
-
call.resolve()
|
|
459
469
|
}
|
|
460
470
|
|
|
461
471
|
@objc func getLastFailure(_ call: CAPPluginCall) {
|
|
@@ -519,22 +529,27 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
519
529
|
throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
|
|
520
530
|
}
|
|
521
531
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
+
let checkId = "check-\(UUID().uuidString)"
|
|
533
|
+
return try await CheckFailure.observe({
|
|
534
|
+
let latest = try await ManifestClient.fetchLatest(
|
|
535
|
+
cdnUrl: self.cdnUrl, appId: appId, channel: channel,
|
|
536
|
+
runtimeVersion: self.runtimeVersion, allowInsecureUrls: self.allowInsecureUrls,
|
|
537
|
+
manifestKeys: self.manifestKeys.map { ManifestKey(kid: $0.kid, derData: $0.key) }
|
|
538
|
+
)
|
|
539
|
+
try self.ensureOwnerActive()
|
|
540
|
+
return latest
|
|
541
|
+
}, report: { failure in
|
|
542
|
+
try self.ensureOwnerActive()
|
|
543
|
+
self.sendDeviceEvent(action: .checkError, runtimeVersion: self.runtimeVersion, channel: channel,
|
|
544
|
+
detail: failure.detail, attemptId: checkId, phase: failure.phase)
|
|
545
|
+
})
|
|
532
546
|
}
|
|
533
547
|
|
|
534
548
|
private func checkLatest(
|
|
535
549
|
respectInterval: Bool,
|
|
536
550
|
channel: String?
|
|
537
551
|
) async throws -> CheckResolution {
|
|
552
|
+
try ensureOwnerActive()
|
|
538
553
|
let targetChannel = resolveTargetChannel(channel)
|
|
539
554
|
if respectInterval, shouldSkipCheckInterval() {
|
|
540
555
|
print("[OtaKit] Skipping resume check: checkInterval has not elapsed")
|
|
@@ -579,7 +594,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
579
594
|
targetChannel: targetChannel
|
|
580
595
|
)
|
|
581
596
|
return .staged(bundle, forceImmediate: manifest.forceImmediate)
|
|
582
|
-
} catch
|
|
597
|
+
} catch where DownloadRetry.isExpiredURLFailure(error) {
|
|
583
598
|
guard let refreshed = try await fetchLatest(channel: targetChannel) else {
|
|
584
599
|
return .noUpdate
|
|
585
600
|
}
|
|
@@ -605,6 +620,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
605
620
|
targetChannel: String?
|
|
606
621
|
) throws -> CheckResolution {
|
|
607
622
|
guard isCompatibleRuntime(manifest.runtimeVersion) else {
|
|
623
|
+
try ensureOwnerActive()
|
|
624
|
+
sendDeviceEvent(action: .checkError, bundleVersion: manifest.version, runtimeVersion: runtimeVersion,
|
|
625
|
+
channel: targetChannel, releaseId: manifest.releaseId, detail: "runtime_mismatch",
|
|
626
|
+
attemptId: "check-\(UUID().uuidString)", phase: "check")
|
|
608
627
|
throw NSError(
|
|
609
628
|
domain: "OtaKit",
|
|
610
629
|
code: 1,
|
|
@@ -664,15 +683,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
664
683
|
return false
|
|
665
684
|
}
|
|
666
685
|
|
|
667
|
-
private func isExpiredURLError(_ error: NSError) -> Bool {
|
|
668
|
-
// HTTP 403 or 410 typically indicates an expired presigned URL
|
|
669
|
-
if error.domain == "Downloader" && (error.code == 403 || error.code == 410) {
|
|
670
|
-
return true
|
|
671
|
-
}
|
|
672
|
-
let desc = error.localizedDescription.lowercased()
|
|
673
|
-
return desc.contains("403") || desc.contains("410")
|
|
674
|
-
}
|
|
675
|
-
|
|
676
686
|
private func downloadAndStage(
|
|
677
687
|
url: URL,
|
|
678
688
|
version: String,
|
|
@@ -683,6 +693,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
683
693
|
releaseId: String? = nil,
|
|
684
694
|
encryption: ManifestEncryption? = nil
|
|
685
695
|
) async throws -> BundleInfo {
|
|
696
|
+
try ensureOwnerActive()
|
|
697
|
+
let installationId = BundleStore.newInstallationId()
|
|
698
|
+
var phase = "admission"
|
|
686
699
|
// Check disk space before downloading
|
|
687
700
|
if let size = expectedSize {
|
|
688
701
|
// zip + extracted + buffer; encrypted bundles keep an extra decrypted
|
|
@@ -702,7 +715,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
702
715
|
runtimeVersion: runtimeVersion,
|
|
703
716
|
channel: channel,
|
|
704
717
|
releaseId: releaseId,
|
|
705
|
-
detail: "insufficient_disk_space"
|
|
718
|
+
detail: "insufficient_disk_space", attemptId: installationId, phase: phase
|
|
706
719
|
)
|
|
707
720
|
emitEvent(
|
|
708
721
|
"downloadFailed",
|
|
@@ -718,19 +731,21 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
718
731
|
}
|
|
719
732
|
}
|
|
720
733
|
|
|
734
|
+
phase = "transfer"
|
|
721
735
|
let zipURL: URL
|
|
722
736
|
do {
|
|
723
737
|
zipURL = try await downloader.download(from: url)
|
|
724
738
|
} catch {
|
|
725
739
|
// Network failures must report like every other download-path failure
|
|
726
740
|
// (Android's downloadZip already sits inside its try block).
|
|
741
|
+
if error is CancellationError { throw error }
|
|
727
742
|
sendDeviceEvent(
|
|
728
743
|
action: .downloadError,
|
|
729
744
|
bundleVersion: version,
|
|
730
745
|
runtimeVersion: runtimeVersion,
|
|
731
746
|
channel: channel,
|
|
732
747
|
releaseId: releaseId,
|
|
733
|
-
detail: error.localizedDescription
|
|
748
|
+
detail: error.localizedDescription, attemptId: installationId, phase: phase
|
|
734
749
|
)
|
|
735
750
|
emitEvent(
|
|
736
751
|
"downloadFailed",
|
|
@@ -750,6 +765,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
750
765
|
let decryptedZipURL = fileManager.temporaryDirectory
|
|
751
766
|
.appendingPathComponent("otakit-decrypted-\(UUID().uuidString).zip")
|
|
752
767
|
defer {
|
|
768
|
+
coordinator.cleanupBundles([installationId])
|
|
753
769
|
try? fileManager.removeItem(at: zipURL)
|
|
754
770
|
try? fileManager.removeItem(at: decryptedZipURL)
|
|
755
771
|
try? fileManager.removeItem(at: extractDirectory)
|
|
@@ -758,20 +774,17 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
758
774
|
do {
|
|
759
775
|
// The manifest sha256 covers the downloaded object as-is — the
|
|
760
776
|
// ciphertext when the bundle is encrypted.
|
|
761
|
-
|
|
777
|
+
try ensureOwnerActive()
|
|
778
|
+
phase = "integrity"
|
|
779
|
+
try HashUtils.verifyDownload(
|
|
762
780
|
fileURL: zipURL,
|
|
763
|
-
expectedSha256: expectedSha256
|
|
781
|
+
expectedSha256: expectedSha256,
|
|
782
|
+
expectedBytes: expectedSize.flatMap { $0 > 0 ? $0 : nil }, kind: "bundle"
|
|
764
783
|
)
|
|
765
|
-
guard valid else {
|
|
766
|
-
throw NSError(
|
|
767
|
-
domain: "OtaKit",
|
|
768
|
-
code: 1,
|
|
769
|
-
userInfo: [NSLocalizedDescriptionKey: "Downloaded bundle hash mismatch"]
|
|
770
|
-
)
|
|
771
|
-
}
|
|
772
784
|
|
|
773
785
|
var zipToExtract = zipURL
|
|
774
786
|
if let encryption {
|
|
787
|
+
phase = "decrypt"
|
|
775
788
|
guard let bundleKey = bundleKeys.first(where: { $0.kid == encryption.kid }) else {
|
|
776
789
|
throw BundleCryptoError.noMatchingKey(encryption.kid)
|
|
777
790
|
}
|
|
@@ -789,19 +802,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
789
802
|
zipToExtract = decryptedZipURL
|
|
790
803
|
}
|
|
791
804
|
|
|
805
|
+
phase = "extract"
|
|
792
806
|
try zipUtils.extractSecurely(zipURL: zipToExtract, to: extractDirectory)
|
|
793
807
|
let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
|
|
794
808
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
releaseId: releaseId,
|
|
798
|
-
sha256: expectedSha256
|
|
799
|
-
)
|
|
809
|
+
phase = "install"
|
|
810
|
+
let bundleId = installationId
|
|
800
811
|
let destination = coordinator.bundleDirectory(for: bundleId)
|
|
801
812
|
|
|
802
|
-
if
|
|
803
|
-
try fileManager.removeItem(at: destination)
|
|
804
|
-
}
|
|
813
|
+
// moveItem fails if the destination exists; never replace an installation.
|
|
805
814
|
|
|
806
815
|
if bundleRoot.path != destination.path {
|
|
807
816
|
try fileManager.moveItem(at: bundleRoot, to: destination)
|
|
@@ -819,7 +828,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
819
828
|
releaseId: releaseId
|
|
820
829
|
)
|
|
821
830
|
|
|
822
|
-
|
|
831
|
+
phase = "stage"
|
|
832
|
+
let cleanupBundleIds = try stageOwnedBundle(info)
|
|
823
833
|
coordinator.cleanupBundles(cleanupBundleIds)
|
|
824
834
|
|
|
825
835
|
sendDeviceEvent(
|
|
@@ -827,18 +837,19 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
827
837
|
bundleVersion: version,
|
|
828
838
|
runtimeVersion: runtimeVersion,
|
|
829
839
|
channel: channel,
|
|
830
|
-
releaseId: releaseId
|
|
840
|
+
releaseId: releaseId, attemptId: installationId, phase: phase
|
|
831
841
|
)
|
|
832
842
|
emitEvent("updateStaged", ["bundle": info.toDictionary()])
|
|
833
843
|
return info
|
|
834
844
|
} catch {
|
|
845
|
+
if error is CancellationError { throw error }
|
|
835
846
|
sendDeviceEvent(
|
|
836
847
|
action: .downloadError,
|
|
837
848
|
bundleVersion: version,
|
|
838
849
|
runtimeVersion: runtimeVersion,
|
|
839
850
|
channel: channel,
|
|
840
851
|
releaseId: releaseId,
|
|
841
|
-
detail: error.localizedDescription
|
|
852
|
+
detail: error.localizedDescription, attemptId: installationId, phase: phase
|
|
842
853
|
)
|
|
843
854
|
emitEvent(
|
|
844
855
|
"downloadFailed",
|
|
@@ -882,25 +893,59 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
882
893
|
|
|
883
894
|
@discardableResult
|
|
884
895
|
private func applyStaged(reloadAfterApply: Bool) throws -> Bool {
|
|
885
|
-
|
|
896
|
+
try runOnMainSynchronously { [self] in
|
|
897
|
+
try updateOwner.run { try applyStagedOnMain(reloadAfterApply: reloadAfterApply) }
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private func stageOwnedBundle(_ bundle: BundleInfo) throws -> [String] {
|
|
902
|
+
try runOnMainSynchronously { [self] in
|
|
903
|
+
try updateOwner.run { try coordinator.stageDownloadedBundle(bundle) }
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private func ensureOwnerActive() throws {
|
|
908
|
+
try runOnMainSynchronously { [self] in try updateOwner.run {} }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private func cleanupInBackground(_ ids: [String]) {
|
|
912
|
+
guard !ids.isEmpty else { return }
|
|
913
|
+
let coordinator = self.coordinator
|
|
914
|
+
DispatchQueue.global(qos: .utility).async { coordinator.cleanupBundles(ids) }
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private func installReadinessBridge(for trial: UpdaterCoordinator.Trial) throws {
|
|
918
|
+
guard let webView = bridge?.webView else {
|
|
919
|
+
throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "WebView unavailable"])
|
|
920
|
+
}
|
|
921
|
+
try documentReadyBridge.install(on: webView, activationId: trial.activationId)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private func applyStagedOnMain(reloadAfterApply: Bool) throws -> Bool {
|
|
925
|
+
let preparation = try coordinator.prepareApplyStaged(
|
|
886
926
|
isCompatibleRuntime: isCompatibleRuntime,
|
|
887
927
|
isBundleUsable: isBundleUsable
|
|
888
928
|
)
|
|
889
|
-
|
|
929
|
+
cleanupInBackground(preparation.cleanupBundleIds)
|
|
890
930
|
|
|
891
931
|
guard let activationPath = preparation.activationPath else {
|
|
892
932
|
return false
|
|
893
933
|
}
|
|
894
934
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
try reloadWebViewSynchronously()
|
|
935
|
+
do {
|
|
936
|
+
if let trial = preparation.trial { try installReadinessBridge(for: trial) }
|
|
937
|
+
try applyServerBasePathSynchronously(activationPath)
|
|
938
|
+
if reloadAfterApply { try reloadWebViewSynchronously() }
|
|
939
|
+
} catch {
|
|
940
|
+
if let trial = preparation.trial {
|
|
941
|
+
rollbackCurrentBundle(expectedTrial: trial, reason: "activation_failed", shouldReload: true)
|
|
942
|
+
}
|
|
943
|
+
throw error
|
|
899
944
|
}
|
|
900
945
|
|
|
901
946
|
cancelTrialTimeout()
|
|
902
|
-
if let
|
|
903
|
-
scheduleTrialTimeout(for:
|
|
947
|
+
if let trial = preparation.trial {
|
|
948
|
+
scheduleTrialTimeout(for: trial)
|
|
904
949
|
}
|
|
905
950
|
|
|
906
951
|
return true
|
|
@@ -916,41 +961,38 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
916
961
|
}
|
|
917
962
|
}
|
|
918
963
|
|
|
919
|
-
private func scheduleTrialTimeout(for
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
let workItem = DispatchWorkItem { [weak self] in
|
|
923
|
-
guard let self else {
|
|
924
|
-
return
|
|
925
|
-
}
|
|
926
|
-
if self.coordinator.isCurrentTrialBundle(bundleId) {
|
|
927
|
-
self.rollbackCurrentBundle(reason: "notify_timeout", shouldReload: true)
|
|
928
|
-
}
|
|
964
|
+
private func scheduleTrialTimeout(for trial: UpdaterCoordinator.Trial) {
|
|
965
|
+
trialDeadline.start(timeout: Double(appReadyTimeoutMs) / 1000) { [weak self] in
|
|
966
|
+
self?.rollbackCurrentBundle(expectedTrial: trial, reason: "notify_timeout", shouldReload: true)
|
|
929
967
|
}
|
|
930
|
-
trialTimeoutWorkItem = workItem
|
|
931
|
-
|
|
932
|
-
DispatchQueue.main.asyncAfter(
|
|
933
|
-
deadline: .now() + .milliseconds(appReadyTimeoutMs),
|
|
934
|
-
execute: workItem
|
|
935
|
-
)
|
|
936
968
|
}
|
|
937
969
|
|
|
938
|
-
private func cancelTrialTimeout() {
|
|
939
|
-
trialTimeoutWorkItem?.cancel()
|
|
940
|
-
trialTimeoutWorkItem = nil
|
|
941
|
-
}
|
|
970
|
+
private func cancelTrialTimeout() { trialDeadline.cancel() }
|
|
942
971
|
|
|
943
|
-
private func rollbackCurrentBundle(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
972
|
+
private func rollbackCurrentBundle(
|
|
973
|
+
expectedTrial: UpdaterCoordinator.Trial, reason: String, shouldReload: Bool
|
|
974
|
+
) {
|
|
975
|
+
let preparation: UpdaterCoordinator.RollbackPreparation
|
|
976
|
+
do {
|
|
977
|
+
preparation = try updateOwner.run {
|
|
978
|
+
try coordinator.prepareRollback(
|
|
979
|
+
expectedTrial: expectedTrial, reason: reason, isBundleUsable: isBundleUsable
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
} catch is CancellationError {
|
|
983
|
+
cancelTrialTimeout()
|
|
984
|
+
return
|
|
985
|
+
} catch {
|
|
986
|
+
print("[OtaKit] Cannot persist rollback: \(error.localizedDescription)")
|
|
987
|
+
scheduleTrialTimeout(for: expectedTrial)
|
|
988
|
+
return
|
|
989
|
+
}
|
|
949
990
|
guard preparation.didRollback else {
|
|
950
991
|
return
|
|
951
992
|
}
|
|
993
|
+
cancelTrialTimeout()
|
|
952
994
|
|
|
953
|
-
|
|
995
|
+
cleanupInBackground(preparation.cleanupBundleIds)
|
|
954
996
|
if let eventPayload = preparation.eventPayload {
|
|
955
997
|
sendDeviceEvent(eventPayload)
|
|
956
998
|
emitEvent(
|
|
@@ -1008,31 +1050,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1008
1050
|
}
|
|
1009
1051
|
}
|
|
1010
1052
|
|
|
1011
|
-
private func runOnMainSynchronously(_ work:
|
|
1012
|
-
if Thread.isMainThread {
|
|
1013
|
-
|
|
1014
|
-
return
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
let semaphore = DispatchSemaphore(value: 0)
|
|
1018
|
-
final class FailureBox {
|
|
1019
|
-
var error: Error?
|
|
1020
|
-
}
|
|
1021
|
-
let failure = FailureBox()
|
|
1022
|
-
|
|
1023
|
-
DispatchQueue.main.async {
|
|
1024
|
-
defer { semaphore.signal() }
|
|
1025
|
-
do {
|
|
1026
|
-
try work()
|
|
1027
|
-
} catch {
|
|
1028
|
-
failure.error = error
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
semaphore.wait()
|
|
1033
|
-
if let error = failure.error {
|
|
1034
|
-
throw error
|
|
1035
|
-
}
|
|
1053
|
+
private func runOnMainSynchronously<T>(_ work: () throws -> T) rethrows -> T {
|
|
1054
|
+
if Thread.isMainThread { return try work() }
|
|
1055
|
+
return try DispatchQueue.main.sync(execute: work)
|
|
1036
1056
|
}
|
|
1037
1057
|
|
|
1038
1058
|
private func manifestToDictionary(
|
|
@@ -1091,6 +1111,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1091
1111
|
manifest: LatestManifest,
|
|
1092
1112
|
targetChannel: String?
|
|
1093
1113
|
) async throws -> BundleInfo {
|
|
1114
|
+
try ensureOwnerActive()
|
|
1115
|
+
let installationId = BundleStore.newInstallationId()
|
|
1116
|
+
var phase = "admission"
|
|
1094
1117
|
// Same conservative disk-space guard as the zip path.
|
|
1095
1118
|
let requiredSpace = Int64(Double(manifest.size) * 2.5)
|
|
1096
1119
|
if getFreeDiskSpace() < requiredSpace {
|
|
@@ -1100,7 +1123,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1100
1123
|
runtimeVersion: manifest.runtimeVersion,
|
|
1101
1124
|
channel: targetChannel,
|
|
1102
1125
|
releaseId: manifest.releaseId,
|
|
1103
|
-
detail: "insufficient_disk_space"
|
|
1126
|
+
detail: "insufficient_disk_space", attemptId: installationId, phase: phase
|
|
1104
1127
|
)
|
|
1105
1128
|
emitEvent(
|
|
1106
1129
|
"downloadFailed",
|
|
@@ -1122,10 +1145,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1122
1145
|
let assembleDirectory = fileManager.temporaryDirectory
|
|
1123
1146
|
.appendingPathComponent("otakit-assemble-\(UUID().uuidString)", isDirectory: true)
|
|
1124
1147
|
defer {
|
|
1148
|
+
coordinator.cleanupBundles([installationId])
|
|
1125
1149
|
try? fileManager.removeItem(at: assembleDirectory)
|
|
1126
1150
|
}
|
|
1127
1151
|
|
|
1128
1152
|
do {
|
|
1153
|
+
phase = "delta"
|
|
1129
1154
|
guard let files = manifest.files, !files.isEmpty else {
|
|
1130
1155
|
throw NSError(
|
|
1131
1156
|
domain: "OtaKit",
|
|
@@ -1150,15 +1175,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1150
1175
|
|
|
1151
1176
|
try await assembler.assemble(entries: files, into: assembleDirectory)
|
|
1152
1177
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
releaseId: manifest.releaseId,
|
|
1156
|
-
sha256: manifest.sha256
|
|
1157
|
-
)
|
|
1178
|
+
phase = "install"
|
|
1179
|
+
let bundleId = installationId
|
|
1158
1180
|
let destination = coordinator.bundleDirectory(for: bundleId)
|
|
1159
|
-
if
|
|
1160
|
-
try fileManager.removeItem(at: destination)
|
|
1161
|
-
}
|
|
1181
|
+
// moveItem fails if the destination exists; never replace an installation.
|
|
1162
1182
|
try fileManager.moveItem(at: assembleDirectory, to: destination)
|
|
1163
1183
|
|
|
1164
1184
|
// Record this bundle's content hashes for cache pruning.
|
|
@@ -1182,7 +1202,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1182
1202
|
releaseId: manifest.releaseId
|
|
1183
1203
|
)
|
|
1184
1204
|
|
|
1185
|
-
|
|
1205
|
+
phase = "stage"
|
|
1206
|
+
let cleanupBundleIds = try stageOwnedBundle(info)
|
|
1186
1207
|
coordinator.cleanupBundles(cleanupBundleIds)
|
|
1187
1208
|
|
|
1188
1209
|
pruneDeltaCache(assembler: assembler)
|
|
@@ -1192,18 +1213,19 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1192
1213
|
bundleVersion: manifest.version,
|
|
1193
1214
|
runtimeVersion: manifest.runtimeVersion,
|
|
1194
1215
|
channel: targetChannel,
|
|
1195
|
-
releaseId: manifest.releaseId
|
|
1216
|
+
releaseId: manifest.releaseId, attemptId: installationId, phase: phase
|
|
1196
1217
|
)
|
|
1197
1218
|
emitEvent("updateStaged", ["bundle": info.toDictionary()])
|
|
1198
1219
|
return info
|
|
1199
1220
|
} catch {
|
|
1221
|
+
if error is CancellationError { throw error }
|
|
1200
1222
|
sendDeviceEvent(
|
|
1201
1223
|
action: .downloadError,
|
|
1202
1224
|
bundleVersion: manifest.version,
|
|
1203
1225
|
runtimeVersion: manifest.runtimeVersion,
|
|
1204
1226
|
channel: targetChannel,
|
|
1205
1227
|
releaseId: manifest.releaseId,
|
|
1206
|
-
detail: error.localizedDescription
|
|
1228
|
+
detail: error.localizedDescription, attemptId: installationId, phase: phase
|
|
1207
1229
|
)
|
|
1208
1230
|
emitEvent(
|
|
1209
1231
|
"downloadFailed",
|
|
@@ -1240,8 +1262,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1240
1262
|
assembler.pruneCache(referencedHashes: referenced)
|
|
1241
1263
|
}
|
|
1242
1264
|
|
|
1243
|
-
private func pruneIncompatibleBundles() {
|
|
1244
|
-
let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
|
|
1265
|
+
private func pruneIncompatibleBundles() throws {
|
|
1266
|
+
let cleanupBundleIds = try coordinator.pruneIncompatibleBundles(
|
|
1245
1267
|
isCompatibleRuntime: isCompatibleRuntime
|
|
1246
1268
|
)
|
|
1247
1269
|
coordinator.cleanupBundles(cleanupBundleIds)
|
|
@@ -1255,45 +1277,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1255
1277
|
isCompatibleRuntime(bundle.runtimeVersion)
|
|
1256
1278
|
}
|
|
1257
1279
|
|
|
1258
|
-
private func buildBundleId(
|
|
1259
|
-
version: String,
|
|
1260
|
-
releaseId: String?,
|
|
1261
|
-
sha256: String?
|
|
1262
|
-
) -> String {
|
|
1263
|
-
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1264
|
-
let allowed = CharacterSet(
|
|
1265
|
-
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
|
|
1266
|
-
)
|
|
1267
|
-
|
|
1268
|
-
var normalizedScalars = String.UnicodeScalarView()
|
|
1269
|
-
for scalar in trimmed.unicodeScalars {
|
|
1270
|
-
if allowed.contains(scalar) {
|
|
1271
|
-
normalizedScalars.append(scalar)
|
|
1272
|
-
} else {
|
|
1273
|
-
normalizedScalars.append("-")
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
var normalized = String(normalizedScalars).replacingOccurrences(
|
|
1278
|
-
of: "-{2,}",
|
|
1279
|
-
with: "-",
|
|
1280
|
-
options: .regularExpression
|
|
1281
|
-
)
|
|
1282
|
-
normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
|
|
1283
|
-
|
|
1284
|
-
if normalized.isEmpty {
|
|
1285
|
-
normalized = "bundle"
|
|
1286
|
-
}
|
|
1287
|
-
if normalized.count > 64 {
|
|
1288
|
-
normalized = String(normalized.prefix(64))
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
let identitySource = trimToNil(releaseId) ?? trimToNil(sha256) ?? trimmed
|
|
1292
|
-
let digest = SHA256.hash(data: Data(identitySource.utf8))
|
|
1293
|
-
let suffix = digest.map { String(format: "%02x", $0) }.joined().prefix(12)
|
|
1294
|
-
return "\(normalized)-\(suffix)"
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
1280
|
/// Emit a JS lifecycle event. `notifyListeners` marshals to the bridge
|
|
1298
1281
|
/// safely from any thread, so no manual dispatch is needed.
|
|
1299
1282
|
private func emitEvent(_ name: String, _ data: [String: Any]) {
|
|
@@ -1347,7 +1330,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1347
1330
|
runtimeVersion: String? = nil,
|
|
1348
1331
|
channel: String? = nil,
|
|
1349
1332
|
releaseId: String? = nil,
|
|
1350
|
-
detail: String? = nil
|
|
1333
|
+
detail: String? = nil,
|
|
1334
|
+
attemptId: String? = nil,
|
|
1335
|
+
phase: String? = nil
|
|
1351
1336
|
) {
|
|
1352
1337
|
sendDeviceEvent(
|
|
1353
1338
|
UpdaterCoordinator.DeviceEventPayload(
|
|
@@ -1356,7 +1341,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1356
1341
|
runtimeVersion: runtimeVersion,
|
|
1357
1342
|
channel: channel,
|
|
1358
1343
|
releaseId: releaseId,
|
|
1359
|
-
detail: detail
|
|
1344
|
+
detail: detail,
|
|
1345
|
+
attemptId: attemptId,
|
|
1346
|
+
phase: phase
|
|
1360
1347
|
)
|
|
1361
1348
|
)
|
|
1362
1349
|
}
|
|
@@ -1365,11 +1352,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1365
1352
|
guard let appId else {
|
|
1366
1353
|
return
|
|
1367
1354
|
}
|
|
1368
|
-
|
|
1355
|
+
let bundleVersion = trimToNil(payload.bundleVersion)
|
|
1356
|
+
guard payload.action == .checkError || bundleVersion != nil else {
|
|
1369
1357
|
print("[OtaKit] Skipping device event without bundleVersion")
|
|
1370
1358
|
return
|
|
1371
1359
|
}
|
|
1372
|
-
|
|
1360
|
+
let releaseId = trimToNil(payload.releaseId)
|
|
1361
|
+
guard payload.action == .checkError || releaseId != nil else {
|
|
1373
1362
|
print("[OtaKit] Skipping device event without releaseId")
|
|
1374
1363
|
return
|
|
1375
1364
|
}
|
|
@@ -1383,15 +1372,30 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1383
1372
|
appId: appId,
|
|
1384
1373
|
platform: "ios",
|
|
1385
1374
|
action: payload.action,
|
|
1386
|
-
bundleVersion: bundleVersion,
|
|
1375
|
+
bundleVersion: bundleVersion ?? "",
|
|
1387
1376
|
channel: payload.channel,
|
|
1388
1377
|
runtimeVersion: trimToNil(payload.runtimeVersion),
|
|
1389
|
-
releaseId: releaseId,
|
|
1378
|
+
releaseId: releaseId ?? "",
|
|
1390
1379
|
nativeBuild: nativeBuild,
|
|
1391
|
-
detail: payload.detail
|
|
1380
|
+
detail: payload.detail,
|
|
1381
|
+
attemptId: payload.attemptId,
|
|
1382
|
+
phase: payload.phase,
|
|
1383
|
+
lifecycle: nativeEventLifecycle()
|
|
1392
1384
|
)
|
|
1393
1385
|
}
|
|
1394
1386
|
|
|
1387
|
+
private func nativeEventLifecycle() -> String {
|
|
1388
|
+
let read = { () -> String in
|
|
1389
|
+
switch UIApplication.shared.applicationState {
|
|
1390
|
+
case .active: return "foreground"
|
|
1391
|
+
case .background: return "background"
|
|
1392
|
+
case .inactive: return "inactive"
|
|
1393
|
+
@unknown default: return "unknown"
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1395
1399
|
private func isBundleUsable(_ bundle: BundleInfo) -> Bool {
|
|
1396
1400
|
guard !bundle.isBuiltin else {
|
|
1397
1401
|
return true
|