@hot-updater/react-native 1.1.4 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +136 -14
  2. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +26 -0
  3. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +13 -0
  4. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +129 -1
  5. package/android/src/newarch/HotUpdaterModule.kt +39 -0
  6. package/android/src/oldarch/HotUpdaterModule.kt +40 -0
  7. package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
  8. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +7 -0
  9. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +147 -22
  10. package/ios/HotUpdater/Internal/HotUpdater.mm +18 -0
  11. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +31 -0
  12. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +14 -0
  13. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +37 -0
  14. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +52 -0
  15. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +124 -0
  16. package/lib/commonjs/checkForUpdate.js +101 -0
  17. package/lib/commonjs/checkForUpdate.js.map +1 -1
  18. package/lib/commonjs/native.js +22 -2
  19. package/lib/commonjs/native.js.map +1 -1
  20. package/lib/commonjs/sdkVersion.js +1 -1
  21. package/lib/commonjs/sdkVersion.js.map +1 -1
  22. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  23. package/lib/module/checkForUpdate.js +102 -1
  24. package/lib/module/checkForUpdate.js.map +1 -1
  25. package/lib/module/native.js +19 -0
  26. package/lib/module/native.js.map +1 -1
  27. package/lib/module/sdkVersion.js +1 -1
  28. package/lib/module/sdkVersion.js.map +1 -1
  29. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  30. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/native.d.ts +7 -0
  32. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  34. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -0
  36. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  37. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  38. package/lib/typescript/module/native.d.ts +7 -0
  39. package/lib/typescript/module/native.d.ts.map +1 -1
  40. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  41. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  42. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -0
  43. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  44. package/package.json +4 -4
  45. package/src/checkForUpdate.ts +133 -0
  46. package/src/native.ts +21 -0
  47. package/src/sdkVersion.ts +1 -1
  48. package/src/specs/NativeHotUpdater.ts +9 -0
@@ -344,6 +344,14 @@ public protocol BundleStorageService {
344
344
  * Restores the original bundle and clears downloaded bundle state.
345
345
  */
346
346
  func resetChannel() -> Result<Bool, Error>
347
+
348
+ /**
349
+ * Resets the channel only while the named Release selection is current,
350
+ * checked and reset in one critical section with the catalog checks, so
351
+ * a newer catalog accepted meanwhile keeps the channel. Success is false
352
+ * for a stale selection.
353
+ */
354
+ func resetChannelIfSelectionCurrent(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Result<Bool, Error>
347
355
  }
348
356
 
349
357
  public extension BundleStorageService {
@@ -356,10 +364,12 @@ public extension BundleStorageService {
356
364
  func getActiveUpdateState() -> [String: Any] { [:] }
357
365
  func isReleaseSelectionCurrent(catalogId _: String, scopeKey _: String, generation _: Int64, catalogHash _: String, channel _: String, selectionContextHash _: String) -> Bool { false }
358
366
  func commitReleaseSelection(_: PersistedSelection) -> Bool { false }
367
+ func resetChannelIfSelectionCurrent(catalogId _: String, scopeKey _: String, generation _: Int64, catalogHash _: String, channel _: String, selectionContextHash _: String) -> Result<Bool, Error> { .success(false) }
359
368
  }
360
369
 
361
370
  class BundleFileStorageService: BundleStorageService {
362
371
  private static let maximumSafeByteSize: Int64 = 9_007_199_254_740_991
372
+ private static let nilUUID = "00000000-0000-0000-0000-000000000000"
363
373
  /// Files downloaded at once when the archive is not used.
364
374
  static let parallelFileDownloads = 16
365
375
  /// What one request costs beyond the first `parallelFileDownloads`, in
@@ -564,10 +574,75 @@ class BundleFileStorageService: BundleStorageService {
564
574
  return (patchDirectory as NSString).appendingPathComponent("\(safeName).bsdiff")
565
575
  }
566
576
 
577
+ /// Copies, with no OTA bundle installed, the packaged file each built-in
578
+ /// patch was made from, when its bytes are the patch's base: store
579
+ /// processing may have changed them. Only these patches are priced and
580
+ /// applied; the others fall back to their files.
581
+ private func prepareBuiltInPatchBases(
582
+ downloads: [String: ChangedAssetDescriptor],
583
+ currentBundleId: String?,
584
+ tempDirectory: String
585
+ ) -> [String: String] {
586
+ guard currentBundleId == nil else { return [:] }
587
+ var bases: [String: String] = [:]
588
+ for (assetPath, descriptor) in downloads {
589
+ guard let patch = descriptor.patch,
590
+ isPatchForRunningBundle(patch, currentBundleId: nil) else {
591
+ continue
592
+ }
593
+ let basePath = patchBaseTempPath(
594
+ tempDirectory: tempDirectory,
595
+ assetPath: assetPath
596
+ )
597
+ if builtInAssetResolver.copyIfMatches(
598
+ assetPath: assetPath,
599
+ expectedHash: patch.baseFileHash,
600
+ destination: basePath
601
+ ) {
602
+ bases[assetPath] = basePath
603
+ } else {
604
+ try? fileSystem.removeItem(atPath: basePath)
605
+ }
606
+ }
607
+ return bases
608
+ }
609
+
610
+ /// Where the built-in bundle's file is copied to serve as a patch's base.
611
+ private func patchBaseTempPath(
612
+ tempDirectory: String,
613
+ assetPath: String
614
+ ) -> String {
615
+ let safeName = assetPath
616
+ .replacingOccurrences(of: "/", with: "__")
617
+ .replacingOccurrences(of: "\\", with: "__")
618
+ let patchDirectory = (tempDirectory as NSString).appendingPathComponent("patches")
619
+ _ = fileSystem.createDirectory(atPath: patchDirectory)
620
+ return (patchDirectory as NSString).appendingPathComponent("\(safeName).base")
621
+ }
622
+
567
623
  private func patchDownloadPath(assetPath: String) -> String {
568
624
  return "\(assetPath).bsdiff"
569
625
  }
570
626
 
627
+ /// Whether `patch` was made against the running bundle: the OTA bundle
628
+ /// `currentBundleId`, or with none, the built-in bundle, whose ID is
629
+ /// MIN_BUNDLE_ID once its native build is registered.
630
+ private func isPatchForRunningBundle(
631
+ _ patch: BsdiffPatchDescriptor,
632
+ currentBundleId: String?
633
+ ) -> Bool {
634
+ guard patch.algorithm == "bsdiff" else { return false }
635
+ if let currentBundleId {
636
+ return currentBundleId == patch.baseBundleId
637
+ }
638
+ // A Debug build reports the nil UUID, and has no built-in bundle.
639
+ guard let builtInBundleId = resolveBuiltInBundleId(),
640
+ builtInBundleId != Self.nilUUID else {
641
+ return false
642
+ }
643
+ return builtInBundleId == patch.baseBundleId
644
+ }
645
+
571
646
  private func downloadPath(
572
647
  assetPath: String,
573
648
  changedAsset: ChangedAssetDescriptor
@@ -609,6 +684,7 @@ class BundleFileStorageService: BundleStorageService {
609
684
  changedAsset: ChangedAssetDescriptor,
610
685
  currentBundleId: String?,
611
686
  currentBundleDir: String?,
687
+ builtInBasePath: String?,
612
688
  destinationPath: String,
613
689
  expectedHash: String,
614
690
  tempDirectory: String,
@@ -616,29 +692,34 @@ class BundleFileStorageService: BundleStorageService {
616
692
  progressHandler: @escaping (UpdateProgressPayload) -> Void
617
693
  ) -> Bool {
618
694
  guard let patch = changedAsset.patch,
619
- patch.algorithm == "bsdiff",
620
- currentBundleId == patch.baseBundleId,
621
- let currentBundleDir
695
+ isPatchForRunningBundle(patch, currentBundleId: currentBundleId)
622
696
  else {
623
697
  return false
624
698
  }
625
699
 
626
700
  let sourcePath: String
627
- do {
628
- sourcePath = try FileUtilities.fileURL(
629
- for: assetPath,
630
- destinationRoot: currentBundleDir
631
- ).path
632
- } catch {
633
- return false
634
- }
635
- guard self.fileSystem.fileExists(atPath: sourcePath),
636
- HashUtils.verifyHash(
637
- fileURL: URL(fileURLWithPath: sourcePath),
638
- expectedHash: patch.baseFileHash
639
- )
640
- else {
641
- return false
701
+ if currentBundleId == nil {
702
+ // Copied and checked before the plan priced the patch.
703
+ guard let builtInBasePath else { return false }
704
+ sourcePath = builtInBasePath
705
+ } else {
706
+ guard let currentBundleDir else { return false }
707
+ do {
708
+ sourcePath = try FileUtilities.fileURL(
709
+ for: assetPath,
710
+ destinationRoot: currentBundleDir
711
+ ).path
712
+ } catch {
713
+ return false
714
+ }
715
+ guard self.fileSystem.fileExists(atPath: sourcePath),
716
+ HashUtils.verifyHash(
717
+ fileURL: URL(fileURLWithPath: sourcePath),
718
+ expectedHash: patch.baseFileHash
719
+ )
720
+ else {
721
+ return false
722
+ }
642
723
  }
643
724
 
644
725
  let patchPath = patchTempPath(
@@ -648,6 +729,9 @@ class BundleFileStorageService: BundleStorageService {
648
729
 
649
730
  defer {
650
731
  try? self.fileSystem.removeItem(atPath: patchPath)
732
+ if currentBundleId == nil {
733
+ try? self.fileSystem.removeItem(atPath: sourcePath)
734
+ }
651
735
  if self.fileSystem.fileExists(atPath: destinationPath),
652
736
  !HashUtils.verifyHash(
653
737
  fileURL: URL(fileURLWithPath: destinationPath),
@@ -1165,7 +1249,9 @@ class BundleFileStorageService: BundleStorageService {
1165
1249
  archive: ParsedArchive?,
1166
1250
  archiveUrl: URL?,
1167
1251
  downloads: [String: ChangedAssetDescriptor],
1168
- assets: [String: ParsedManifestAsset]
1252
+ assets: [String: ParsedManifestAsset],
1253
+ currentBundleId: String?,
1254
+ builtInPatchBases: [String: String]
1169
1255
  ) -> Bool {
1170
1256
  guard downloads.count >= 2,
1171
1257
  archive != nil,
@@ -1181,7 +1267,11 @@ class BundleFileStorageService: BundleStorageService {
1181
1267
  return false
1182
1268
  }
1183
1269
  let plannedByteSize: Int64
1184
- if let patch = descriptor.patch {
1270
+ // Only a patch this device applies saves bytes.
1271
+ if let patch = descriptor.patch,
1272
+ currentBundleId == nil
1273
+ ? builtInPatchBases[path] != nil
1274
+ : isPatchForRunningBundle(patch, currentBundleId: currentBundleId) {
1185
1275
  hasOfferedPatch = true
1186
1276
  guard let patchByteSize = patch.byteSize,
1187
1277
  patchByteSize >= 0,
@@ -2228,6 +2318,7 @@ class BundleFileStorageService: BundleStorageService {
2228
2318
  changedAsset: ChangedAssetDescriptor,
2229
2319
  currentBundleId: String?,
2230
2320
  currentBundleDir: String?,
2321
+ builtInBasePath: String?,
2231
2322
  destinationPath: String,
2232
2323
  tempDirectory: String,
2233
2324
  progressFile: UpdateProgressPayload.DiffProgressFileSnapshot,
@@ -2240,6 +2331,7 @@ class BundleFileStorageService: BundleStorageService {
2240
2331
  changedAsset: changedAsset,
2241
2332
  currentBundleId: currentBundleId,
2242
2333
  currentBundleDir: currentBundleDir,
2334
+ builtInBasePath: builtInBasePath,
2243
2335
  destinationPath: destinationPath,
2244
2336
  expectedHash: expectedHash,
2245
2337
  tempDirectory: tempDirectory,
@@ -2374,7 +2466,7 @@ class BundleFileStorageService: BundleStorageService {
2374
2466
  // A patch for the running bundle was offered, but the original
2375
2467
  // file came instead; a patch for another base was never tried.
2376
2468
  let patchWasTried = changedAsset.patch.map {
2377
- $0.algorithm == "bsdiff" && $0.baseBundleId == currentBundleId
2469
+ isPatchForRunningBundle($0, currentBundleId: currentBundleId)
2378
2470
  } ?? false
2379
2471
  return .downloaded(patchFallback: patchWasTried)
2380
2472
  case .failure(let error):
@@ -2850,11 +2942,18 @@ class BundleFileStorageService: BundleStorageService {
2850
2942
  // The archive is chosen before any patch is tried, so only the
2851
2943
  // per-file install can fall back from a patch.
2852
2944
  var patchFallbacks = false
2945
+ let builtInPatchBases = prepareBuiltInPatchBases(
2946
+ downloads: downloads,
2947
+ currentBundleId: currentBundleId,
2948
+ tempDirectory: tempDirectory
2949
+ )
2853
2950
  if shouldUseArchive(
2854
2951
  archive: targetManifest.archive,
2855
2952
  archiveUrl: archiveUrl,
2856
2953
  downloads: downloads,
2857
- assets: targetManifest.assets
2954
+ assets: targetManifest.assets,
2955
+ currentBundleId: currentBundleId,
2956
+ builtInPatchBases: builtInPatchBases
2858
2957
  ), let archive = targetManifest.archive, let archiveUrl {
2859
2958
  do {
2860
2959
  diffFiles = try UpdateFailureError.during(.storage, resource: .archive) {
@@ -2908,6 +3007,7 @@ class BundleFileStorageService: BundleStorageService {
2908
3007
  let outcome = try self.downloadManifestAsset(
2909
3008
  assetPath: assetPath, expectedAsset: expectedAsset, changedAsset: descriptor,
2910
3009
  currentBundleId: currentBundleId, currentBundleDir: currentBundleDir,
3010
+ builtInBasePath: builtInPatchBases[assetPath],
2911
3011
  destinationPath: destinationPath, tempDirectory: workerTemp, progressFile: progressFile,
2912
3012
  progressHandler: { payload in
2913
3013
  progressLock.lock()
@@ -3216,6 +3316,31 @@ class BundleFileStorageService: BundleStorageService {
3216
3316
  return getBundleMetadataSnapshot(bundleId: bundleId)?.manifest ?? [:]
3217
3317
  }
3218
3318
 
3319
+ func resetChannelIfSelectionCurrent(
3320
+ catalogId: String,
3321
+ scopeKey: String,
3322
+ generation: Int64,
3323
+ catalogHash: String,
3324
+ channel: String,
3325
+ selectionContextHash: String
3326
+ ) -> Result<Bool, Error> {
3327
+ // The catalog checks take the same recursive lock, so none accepts a
3328
+ // newer catalog between this check and the reset.
3329
+ releaseStateLock.lock()
3330
+ defer { releaseStateLock.unlock() }
3331
+ guard isReleaseSelectionCurrent(
3332
+ catalogId: catalogId,
3333
+ scopeKey: scopeKey,
3334
+ generation: generation,
3335
+ catalogHash: catalogHash,
3336
+ channel: channel,
3337
+ selectionContextHash: selectionContextHash
3338
+ ) else {
3339
+ return .success(false)
3340
+ }
3341
+ return resetChannel()
3342
+ }
3343
+
3219
3344
  func resetChannel() -> Result<Bool, Error> {
3220
3345
  releaseStateLock.lock()
3221
3346
  defer { releaseStateLock.unlock() }
@@ -1213,6 +1213,15 @@ RCT_EXPORT_MODULE();
1213
1213
  rejecter:reject];
1214
1214
  }
1215
1215
 
1216
+ - (void)resetChannelIfSelectionCurrent:(NSDictionary *)params
1217
+ resolve:(RCTPromiseResolveBlock)resolve
1218
+ reject:(RCTPromiseRejectBlock)reject {
1219
+ [HotUpdaterNativeUpdates() resetChannelIfSelectionCurrentWithImpl:[self instanceImpl]
1220
+ params:params
1221
+ resolver:resolve
1222
+ rejecter:reject];
1223
+ }
1224
+
1216
1225
  - (facebook::react::ModuleConstants<JS::NativeHotUpdater::Constants::Builder>)constantsToExport {
1217
1226
  return [self getConstants];
1218
1227
  }
@@ -1394,6 +1403,15 @@ RCT_EXPORT_METHOD(resetChannel:(RCTPromiseResolveBlock)resolve
1394
1403
  rejecter:reject];
1395
1404
  }
1396
1405
 
1406
+ RCT_EXPORT_METHOD(resetChannelIfSelectionCurrent:(NSDictionary *)params
1407
+ resolve:(RCTPromiseResolveBlock)resolve
1408
+ reject:(RCTPromiseRejectBlock)reject) {
1409
+ [HotUpdaterNativeUpdates() resetChannelIfSelectionCurrentWithImpl:[self instanceImpl]
1410
+ params:params
1411
+ resolver:resolve
1412
+ rejecter:reject];
1413
+ }
1414
+
1397
1415
  - (NSDictionary *)constantsToExport {
1398
1416
  return [self _buildConstantsDictionary];
1399
1417
  }
@@ -1062,6 +1062,37 @@ private func hotUpdaterGetMinBundleId() -> String {
1062
1062
  }
1063
1063
  }
1064
1064
 
1065
+ /// Resets the channel while the Release selection `params` names is
1066
+ /// current, as `resetChannel` does; resolves false for a stale one.
1067
+ @objc
1068
+ public func resetChannelIfSelectionCurrent(
1069
+ _ params: [AnyHashable: Any]?,
1070
+ resolver: @escaping RCTPromiseResolveBlock,
1071
+ rejecter reject: @escaping RCTPromiseRejectBlock
1072
+ ) {
1073
+ guard let guardParams = ReleaseSelectionGuardParams(params) else {
1074
+ resolver(false)
1075
+ return
1076
+ }
1077
+ let result = launchPreparationLock.resetChannelIfSelectionCurrent(guardParams, in: bundleStorage) {
1078
+ do {
1079
+ try preferences.setItem(nil, forKey: Self.CHANNEL_STORAGE_KEY)
1080
+ } catch {
1081
+ NSLog("[HotUpdaterImpl] Failed to clear channel override: \(error)")
1082
+ }
1083
+ self.currentLaunchSelection = nil
1084
+ }
1085
+
1086
+ switch result {
1087
+ case .success(let success):
1088
+ resolver(success)
1089
+ case .failure(let error):
1090
+ let normalizedCode = HotUpdaterImpl.normalizeErrorCode(from: error)
1091
+ let nsError = error as NSError
1092
+ reject(normalizedCode, nsError.localizedDescription, nsError)
1093
+ }
1094
+ }
1095
+
1065
1096
  private func prepareLaunchIfNeeded(bundle: Bundle) -> LaunchSelection {
1066
1097
  launchPreparationLock.withLock { prepareLaunchLocked(bundle: bundle) }
1067
1098
  }
@@ -37,6 +37,20 @@ final class HotUpdaterNativeUpdatesAdapter: NSObject, HotUpdaterNativeUpdatesInt
37
37
  HotUpdaterNativeUpdateBridge.resetChannel(impl: impl as! HotUpdaterImpl, resolver: resolve, rejecter: reject)
38
38
  }
39
39
 
40
+ func resetChannelIfSelectionCurrent(
41
+ withImpl impl: HotUpdaterImplInterface,
42
+ params: [AnyHashable: Any]?,
43
+ resolver resolve: @escaping RCTPromiseResolveBlock,
44
+ rejecter reject: @escaping RCTPromiseRejectBlock
45
+ ) {
46
+ HotUpdaterNativeUpdateBridge.resetChannelIfSelectionCurrent(
47
+ impl: impl as! HotUpdaterImpl,
48
+ params: params,
49
+ resolver: resolve,
50
+ rejecter: reject
51
+ )
52
+ }
53
+
40
54
  func takeEvents(withImpl impl: HotUpdaterImplInterface) -> String? {
41
55
  HotUpdaterNativeUpdateBridge.takeEvents(impl: impl as! HotUpdaterImpl)
42
56
  }
@@ -80,6 +80,13 @@ final class HotUpdaterImplUpdateHost: NativeUpdateHost {
80
80
  return (value as? Bool) ?? false
81
81
  }
82
82
 
83
+ func resetChannelIfSelectionCurrent(_ guardParams: [String: Any]) async -> Bool {
84
+ let value = try? await promise { resolve, reject in
85
+ self.impl.resetChannelIfSelectionCurrent(guardParams, resolver: resolve, rejecter: reject)
86
+ }
87
+ return (value as? Bool) ?? false
88
+ }
89
+
83
90
  func releaseCatalogCache(_ partition: String) -> String? {
84
91
  impl.releaseCatalogCacheValue(partition)
85
92
  }
@@ -216,6 +223,36 @@ public final class HotUpdaterNativeUpdateBridge: NSObject {
216
223
  }
217
224
  }
218
225
 
226
+ /// `resetChannelIfSelectionCurrent` for JavaScript: waits for a native
227
+ /// check or staging as `resetChannel` does, then resets only while the
228
+ /// Release selection `params` names is current.
229
+ @objc(resetChannelIfSelectionCurrentWithImpl:params:resolver:rejecter:)
230
+ public static func resetChannelIfSelectionCurrent(
231
+ impl: HotUpdaterImpl,
232
+ params: [AnyHashable: Any]?,
233
+ resolver resolve: @escaping RCTPromiseResolveBlock,
234
+ rejecter reject: @escaping RCTPromiseRejectBlock
235
+ ) {
236
+ let checker = checker(impl: impl)
237
+ Task {
238
+ await checker.whenIdle {
239
+ await withCheckedContinuation { (done: CheckedContinuation<Void, Never>) in
240
+ impl.resetChannelIfSelectionCurrent(params, resolver: { value in
241
+ // The reset deleted staged bundles; their queued downloads go too.
242
+ if (value as? Bool) == true {
243
+ NativeUpdateEvents.remove(name: "onBundleDownloaded", from: HotUpdaterImplUpdateHost(impl: impl))
244
+ }
245
+ resolve(value)
246
+ done.resume()
247
+ }, rejecter: { code, message, error in
248
+ reject(code, message, error)
249
+ done.resume()
250
+ })
251
+ }
252
+ }
253
+ }
254
+ }
255
+
219
256
  /// The queued plugin events as a JSON array, cleared as they are taken.
220
257
  @objc(takeEventsWithImpl:)
221
258
  public static func takeEvents(impl: HotUpdaterImpl) -> String? {
@@ -30,6 +30,30 @@ final class LaunchPreparationLock {
30
30
  }
31
31
  }
32
32
 
33
+ /// Resets the channel in `storage` while the selection `guardParams` names
34
+ /// is current, as `resetChannel` does. Once it resets, `clearNextLaunch`
35
+ /// runs under the lock.
36
+ func resetChannelIfSelectionCurrent(
37
+ _ guardParams: ReleaseSelectionGuardParams,
38
+ in storage: BundleStorageService,
39
+ clearNextLaunch: () -> Void
40
+ ) -> Result<Bool, Error> {
41
+ withLock {
42
+ let result = storage.resetChannelIfSelectionCurrent(
43
+ catalogId: guardParams.catalogId,
44
+ scopeKey: guardParams.scopeKey,
45
+ generation: guardParams.generation,
46
+ catalogHash: guardParams.catalogHash,
47
+ channel: guardParams.channel,
48
+ selectionContextHash: guardParams.selectionContextHash
49
+ )
50
+ if case .success(true) = result {
51
+ clearNextLaunch()
52
+ }
53
+ return result
54
+ }
55
+ }
56
+
33
57
  /// Commits `selection` in `storage`. Committing anything but a bundle, such
34
58
  /// as the built-in bundle, deletes every downloaded bundle, so it runs
35
59
  /// under the lock as a channel reset does. Once it commits,
@@ -52,3 +76,31 @@ final class LaunchPreparationLock {
52
76
  }
53
77
  }
54
78
  }
79
+
80
+ /// The Release selection a guarded change names, as JavaScript sends it.
81
+ struct ReleaseSelectionGuardParams {
82
+ let catalogId: String
83
+ let scopeKey: String
84
+ let generation: Int64
85
+ let catalogHash: String
86
+ let channel: String
87
+ let selectionContextHash: String
88
+
89
+ init?(_ params: [AnyHashable: Any]?) {
90
+ guard let data = params as? [String: Any],
91
+ let catalogId = data["catalogId"] as? String,
92
+ let scopeKey = data["scopeKey"] as? String,
93
+ let generation = data["generation"] as? NSNumber,
94
+ let catalogHash = data["catalogHash"] as? String,
95
+ let channel = data["channel"] as? String,
96
+ let selectionContextHash = data["selectionContextHash"] as? String else {
97
+ return nil
98
+ }
99
+ self.catalogId = catalogId
100
+ self.scopeKey = scopeKey
101
+ self.generation = generation.int64Value
102
+ self.catalogHash = catalogHash
103
+ self.channel = channel
104
+ self.selectionContextHash = selectionContextHash
105
+ }
106
+ }
@@ -379,6 +379,9 @@ protocol NativeUpdateHost: NativeUpdateEventStorage {
379
379
  progress: @escaping (Double) -> Void
380
380
  ) async throws -> Any?
381
381
  func resetChannel() async -> Bool
382
+ /// Resets the channel only while the Release selection `guardParams`
383
+ /// names is current, in one step with the catalog checks.
384
+ func resetChannelIfSelectionCurrent(_ guardParams: [String: Any]) async -> Bool
382
385
  func releaseCatalogCache(_ partition: String) -> String?
383
386
  func setReleaseCatalogCache(_ partition: String, value: String) -> Bool
384
387
  func removeReleaseCatalogCache(_ partition: String)
@@ -955,6 +958,22 @@ final class NativeUpdateChecker {
955
958
  /// The target the last staging committed, for another retained result of
956
959
  /// the same target.
957
960
  private var lastCommittedTargetKey: String?
961
+ /// The last switched-channel leave that reset the device, for another
962
+ /// retained result of the same catalog. Kept apart from the committed
963
+ /// target, which a later staging replaces.
964
+ private var lastLeftTargetKeyValue: String?
965
+ private var lastLeftTargetKey: String? {
966
+ get {
967
+ committedTargetLock.lock()
968
+ defer { committedTargetLock.unlock() }
969
+ return lastLeftTargetKeyValue
970
+ }
971
+ set {
972
+ committedTargetLock.lock()
973
+ lastLeftTargetKeyValue = newValue
974
+ committedTargetLock.unlock()
975
+ }
976
+ }
958
977
 
959
978
  init(
960
979
  host: NativeUpdateHost,
@@ -1173,6 +1192,7 @@ final class NativeUpdateChecker {
1173
1192
  currentBundleId: currentBundleId,
1174
1193
  minimumReleaseId: minimumReleaseId,
1175
1194
  currentChannel: currentChannel,
1195
+ isSwitched: isSwitched,
1176
1196
  targetChannel: targetChannel,
1177
1197
  explicitChannel: explicitChannel,
1178
1198
  cohort: cohort,
@@ -1208,6 +1228,7 @@ final class NativeUpdateChecker {
1208
1228
  currentBundleId: String,
1209
1229
  minimumReleaseId: String,
1210
1230
  currentChannel: String,
1231
+ isSwitched: Bool,
1211
1232
  targetChannel: String,
1212
1233
  explicitChannel: String?,
1213
1234
  cohort: String,
@@ -1286,6 +1307,20 @@ final class NativeUpdateChecker {
1286
1307
  guard host.acceptReleaseCatalog(guardParams) else {
1287
1308
  throw NativeUpdateCheckError.invalidResponse("Rejected a stale or inconsistent Release catalog")
1288
1309
  }
1310
+ // The switched channel holds nothing for this build any more, as a
1311
+ // deleted channel does: the server answers its scope with an accepted
1312
+ // catalog without releases. A scope without a catalog proves nothing,
1313
+ // since a cache can still answer that for a channel deployed seconds ago.
1314
+ if isSwitched, targetChannel == currentChannel,
1315
+ catalog.releases.isEmpty, catalog.rollbackReleases.isEmpty {
1316
+ return leaveSwitchedChannel(
1317
+ options: options,
1318
+ currentBundleId: currentBundleId,
1319
+ minimumReleaseId: minimumReleaseId,
1320
+ channel: currentChannel,
1321
+ guardParams: guardParams
1322
+ )
1323
+ }
1289
1324
  let stableInTargetScope = stable.flatMap { stable in
1290
1325
  stable.catalogId.jsEquals(catalogId) && stable.scopeKey.jsEquals(scopeKey) ? stable : nil
1291
1326
  }
@@ -1499,6 +1534,95 @@ final class NativeUpdateChecker {
1499
1534
  )
1500
1535
  }
1501
1536
 
1537
+ /// Leaves a switched channel that holds nothing for this build, such as a
1538
+ /// deleted one, as checkForUpdate.ts does: a rollback to the built-in
1539
+ /// bundle whose staging also clears the runtime channel, so the next check
1540
+ /// reads the build's default channel. The check itself resets nothing,
1541
+ /// since the reset removes the running bundle's files.
1542
+ private func leaveSwitchedChannel(
1543
+ options: NativeCheckRequest,
1544
+ currentBundleId: String,
1545
+ minimumReleaseId: String,
1546
+ channel: String,
1547
+ guardParams: [String: Any]
1548
+ ) -> HotUpdaterUpdate {
1549
+ let runsBuiltIn = currentBundleId.jsEquals(minimumReleaseId)
1550
+ let errorScope = ErrorScope(
1551
+ channel: channel,
1552
+ bundleId: currentBundleId,
1553
+ updateStrategy: options.updateStrategy,
1554
+ targetBundleId: minimumReleaseId,
1555
+ targetReleaseId: nil
1556
+ )
1557
+ // With the catalog's freshness guard, as ordinary selections, so a
1558
+ // leave from an older catalog never joins a current one's staging.
1559
+ let targetKey = Self.jsonKey([
1560
+ "LEAVE_CHANNEL", channel, minimumReleaseId,
1561
+ guardParams["catalogId"] ?? NSNull(), guardParams["generation"] ?? NSNull(),
1562
+ guardParams["catalogHash"] ?? NSNull(), guardParams["selectionContextHash"] ?? NSNull(),
1563
+ ])
1564
+ let install = { [self] (_: @escaping (Double) -> Void) async throws -> Bool in
1565
+ do {
1566
+ // This leave, from this or another result of the same catalog,
1567
+ // already happened: calling it again changes nothing.
1568
+ if lastLeftTargetKey == targetKey, host.channel() == host.defaultChannel() {
1569
+ return true
1570
+ }
1571
+ // A device that left or changed the channel meanwhile keeps it.
1572
+ guard host.channel() == channel else {
1573
+ throw NativeUpdateCheckError.staleReleaseCatalog
1574
+ }
1575
+ // The staging holds the gate, as `resetChannel()` waits for;
1576
+ // native checks the catalog again in the same step as the
1577
+ // reset, so a newer one accepted meanwhile keeps the channel.
1578
+ guard await host.resetChannelIfSelectionCurrent(guardParams) else {
1579
+ throw NativeUpdateCheckError.staleReleaseCatalog
1580
+ }
1581
+ lastLeftTargetKey = targetKey
1582
+ NativeUpdateEvents.remove(name: "onBundleDownloaded", from: host)
1583
+ return true
1584
+ } catch {
1585
+ reportUpdateError(error, stage: "download", resource: "artifact", scope: errorScope)
1586
+ throw error
1587
+ }
1588
+ }
1589
+ let fromReleaseId = runningReleaseId(bundleId: currentBundleId, channel: channel)
1590
+ queueEvent("onUpdateCheck", [
1591
+ "status": "UPDATE_AVAILABLE",
1592
+ "channel": channel,
1593
+ "fromBundleId": currentBundleId,
1594
+ "fromReleaseId": fromReleaseId ?? NSNull(),
1595
+ "toBundleId": minimumReleaseId,
1596
+ "toReleaseId": NSNull(),
1597
+ "transitionKind": "USE_BUILTIN",
1598
+ "updateStatus": "ROLLBACK",
1599
+ "shouldForceUpdate": !runsBuiltIn,
1600
+ "updateStrategy": options.updateStrategy.name,
1601
+ ])
1602
+ return HotUpdaterUpdate(
1603
+ id: minimumReleaseId,
1604
+ releaseId: nil,
1605
+ status: .rollback,
1606
+ shouldForceUpdate: !runsBuiltIn,
1607
+ message: nil,
1608
+ rolloutCohortCount: 1000,
1609
+ targetCohorts: [],
1610
+ bundleId: minimumReleaseId,
1611
+ transitionKind: "USE_BUILTIN",
1612
+ runsCurrentBundle: runsBuiltIn,
1613
+ targetKey: targetKey,
1614
+ checker: self,
1615
+ install: install,
1616
+ // Current while the device is still switched to the channel and
1617
+ // the catalog is current, and once the staging left it.
1618
+ isSelectionCurrent: { [host] in
1619
+ let current = host.channel()
1620
+ return current == host.defaultChannel()
1621
+ || (current == channel && host.isReleaseSelectionCurrent(guardParams))
1622
+ }
1623
+ )
1624
+ }
1625
+
1502
1626
  // MARK: State
1503
1627
 
1504
1628
  private static func jsonKey(_ fields: [Any]) -> String {