@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.
Files changed (57) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
  22. package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
  23. package/dist/esm/definitions.d.ts +3 -3
  24. package/dist/esm/definitions.d.ts.map +1 -1
  25. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  26. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  27. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  28. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  29. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  30. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  31. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  32. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  33. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  34. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  35. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  36. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  37. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  38. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  40. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  41. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  44. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  45. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  46. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  47. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  48. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  50. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  51. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  52. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  53. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  54. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  56. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  57. 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 trialTimeoutWorkItem: DispatchWorkItem?
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
- let rawKeysValue = getConfig().getArray("manifestKeys")
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
- pruneIncompatibleBundles()
128
-
129
- let startup = coordinator.normalizeStartupState(
130
- isBundleUsable: isBundleUsable
131
- )
132
- coordinator.cleanupBundles(startup.cleanupBundleIds)
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 trialBundleId = startup.trialBundleId {
145
- scheduleTrialTimeout(for: trialBundleId)
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
- if resumePolicy != .off {
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
- self?.handleAppWillEnterForeground()
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
- cancelTrialTimeout()
449
- let preparation = coordinator.prepareNotifyAppReady()
450
- coordinator.cleanupBundles(preparation.cleanupBundleIds)
451
- if let eventPayload = preparation.eventPayload {
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()])
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
- return try await ManifestClient.fetchLatest(
523
- cdnUrl: cdnUrl,
524
- appId: appId,
525
- channel: channel,
526
- runtimeVersion: runtimeVersion,
527
- allowInsecureUrls: allowInsecureUrls,
528
- manifestKeys: manifestKeys.map {
529
- ManifestKey(kid: $0.kid, derData: $0.key)
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 let error as NSError where isExpiredURLError(error) {
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
- let valid = try HashUtils.verify(
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
- let bundleId = buildBundleId(
796
- version: version,
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 fileManager.fileExists(atPath: destination.path) {
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
- let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
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
- let preparation = coordinator.prepareApplyStaged(
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
- coordinator.cleanupBundles(preparation.cleanupBundleIds)
929
+ cleanupInBackground(preparation.cleanupBundleIds)
890
930
 
891
931
  guard let activationPath = preparation.activationPath else {
892
932
  return false
893
933
  }
894
934
 
895
- try applyServerBasePathSynchronously(activationPath)
896
-
897
- if reloadAfterApply {
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 trialBundleId = preparation.trialBundleId {
903
- scheduleTrialTimeout(for: trialBundleId)
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 bundleId: String) {
920
- cancelTrialTimeout()
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(reason: String, shouldReload: Bool) {
944
- cancelTrialTimeout()
945
- let preparation = coordinator.prepareRollback(
946
- reason: reason,
947
- isBundleUsable: isBundleUsable
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
- coordinator.cleanupBundles(preparation.cleanupBundleIds)
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: @escaping () throws -> Void) throws {
1012
- if Thread.isMainThread {
1013
- try work()
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
- let bundleId = buildBundleId(
1154
- version: manifest.version,
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 fileManager.fileExists(atPath: destination.path) {
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
- let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
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
- guard let bundleVersion = trimToNil(payload.bundleVersion) else {
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
- guard let releaseId = trimToNil(payload.releaseId) else {
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