@otakit/capacitor-updater 2.1.2 → 2.2.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.
@@ -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
- _ = try await downloadLatest(respectInterval: false, channel: nil)
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
- _ = try await downloadLatest(respectInterval: false, channel: nil)
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
- _ = try await downloadLatest(respectInterval: false, channel: nil)
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
- _ = try await downloadLatest(respectInterval: false, channel: nil)
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
- _ = try await downloadLatest(respectInterval: true, channel: nil)
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
- _ = try await downloadLatest(respectInterval: true, channel: nil)
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(_, bundle):
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(_, bundle):
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
- let requiredSpace = Int64(Double(size) * 2.5) // zip + extracted + buffer
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 = try await downloader.download(from: url)
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
- try zipUtils.extractSecurely(zipURL: zipURL, to: extractDirectory)
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 {
@@ -863,6 +1048,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
863
1048
  payload["runtimeVersion"] = runtimeVersion
864
1049
  }
865
1050
  payload["releaseId"] = latest.releaseId
1051
+ payload["forceImmediate"] = latest.forceImmediate
866
1052
  return payload
867
1053
  }
868
1054
 
@@ -885,7 +1071,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
885
1071
  expectedSize: manifest.size,
886
1072
  runtimeVersion: manifest.runtimeVersion,
887
1073
  channel: targetChannel,
888
- releaseId: manifest.releaseId
1074
+ releaseId: manifest.releaseId,
1075
+ encryption: manifest.encryption
889
1076
  )
890
1077
  }
891
1078
 
@@ -943,6 +1130,53 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
943
1130
  return "\(normalized)-\(suffix)"
944
1131
  }
945
1132
 
1133
+ /// Emit a JS lifecycle event. `notifyListeners` marshals to the bridge
1134
+ /// safely from any thread, so no manual dispatch is needed.
1135
+ private func emitEvent(_ name: String, _ data: [String: Any]) {
1136
+ notifyListeners(name, data: data)
1137
+ }
1138
+
1139
+ private func failureEventData(
1140
+ version: String,
1141
+ runtimeVersion: String?,
1142
+ channel: String?,
1143
+ releaseId: String?,
1144
+ reason: String
1145
+ ) -> [String: Any] {
1146
+ var data: [String: Any] = [
1147
+ "version": version,
1148
+ "reason": reason,
1149
+ ]
1150
+ if let runtimeVersion = trimToNil(runtimeVersion) {
1151
+ data["runtimeVersion"] = runtimeVersion
1152
+ }
1153
+ if let channel = trimToNil(channel) {
1154
+ data["channel"] = channel
1155
+ }
1156
+ if let releaseId = trimToNil(releaseId) {
1157
+ data["releaseId"] = releaseId
1158
+ }
1159
+ return data
1160
+ }
1161
+
1162
+ /// Map a native error to a stable event reason string.
1163
+ private func failureReason(from error: Error) -> String {
1164
+ if error is ZipUtilsError {
1165
+ return "extract_failed"
1166
+ }
1167
+ let description = error.localizedDescription.lowercased()
1168
+ if description.contains("hash mismatch") {
1169
+ return "hash_mismatch"
1170
+ }
1171
+ if description.contains("disk space") {
1172
+ return "insufficient_disk_space"
1173
+ }
1174
+ if description.contains("index.html") {
1175
+ return "invalid_bundle"
1176
+ }
1177
+ return "download_failed"
1178
+ }
1179
+
946
1180
  private func sendDeviceEvent(
947
1181
  action: DeviceEventAction,
948
1182
  bundleVersion: String? = nil,
@@ -1021,6 +1255,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1021
1255
  if let channel = trimToNil(channel) {
1022
1256
  return channel
1023
1257
  }
1258
+ if let override = store.getOverrideChannel() {
1259
+ return override
1260
+ }
1024
1261
  return self.channel
1025
1262
  }
1026
1263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",