react-native-update 10.55.1 → 10.56.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 (76) hide show
  1. package/README-CN.md +35 -0
  2. package/README.md +37 -0
  3. package/android/build.gradle +53 -27
  4. package/android/jni/Application.mk +6 -1
  5. package/android/jni/hpatch.c +59 -4
  6. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  7. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  8. package/android/lib/x86/librnupdate.so +0 -0
  9. package/android/lib/x86_64/librnupdate.so +0 -0
  10. package/android/proguard.pro +70 -14
  11. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +15 -8
  12. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +1 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +1 -2
  14. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +25 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -34
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +29 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +15 -0
  18. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +59 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +27 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +57 -16
  21. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +38 -46
  22. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +279 -183
  23. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +14 -0
  24. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +222 -215
  25. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +5 -0
  26. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +23 -25
  27. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +29 -29
  28. package/cpp/patch_core/archive_limits.h +6 -0
  29. package/cpp/patch_core/jni_util.h +6 -2
  30. package/cpp/patch_core/patch_core.cpp +237 -84
  31. package/cpp/patch_core/patch_core.h +11 -0
  32. package/cpp/patch_core/patch_core_android.cpp +49 -33
  33. package/cpp/patch_core/update_core_android.cpp +134 -103
  34. package/cpp/update_flow_core/flow_json.cpp +150 -17
  35. package/cpp/update_flow_core/flow_json.h +13 -1
  36. package/cpp/update_flow_core/update_flow_core.cpp +78 -8
  37. package/cpp/update_flow_core/update_flow_core.h +9 -0
  38. package/cpp/update_flow_core/update_flow_jni.cpp +44 -25
  39. package/harmony/hvigor-plugin.ts +168 -21
  40. package/harmony/pushy/src/README.md +74 -0
  41. package/harmony/pushy/src/main/cpp/CMakeLists.txt +13 -3
  42. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +11 -5
  43. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -18
  44. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +61 -5
  45. package/harmony/pushy/src/main/ets/DownloadTask.ts +255 -166
  46. package/harmony/pushy/src/main/ets/ErrorCodes.ts +75 -0
  47. package/harmony/pushy/src/main/ets/EventHub.ts +22 -20
  48. package/harmony/pushy/src/main/ets/InstallRecord.ts +79 -21
  49. package/harmony/pushy/src/main/ets/Logger.ts +42 -16
  50. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +98 -17
  51. package/harmony/pushy/src/main/ets/NativePatchCore.ts +10 -3
  52. package/harmony/pushy/src/main/ets/PathUtils.ts +9 -3
  53. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +139 -53
  54. package/harmony/pushy/src/main/ets/UpdateContext.ts +269 -159
  55. package/harmony/pushy/src/main/module.json5 +5 -1
  56. package/harmony/pushy.har +0 -0
  57. package/ios/PrivacyInfo.xcprivacy +79 -0
  58. package/ios/RCTPushy/RCTPushy.mm +399 -170
  59. package/ios/RCTPushy/RCTPushyDownloader.h +14 -0
  60. package/ios/RCTPushy/RCTPushyDownloader.mm +136 -11
  61. package/package.json +14 -24
  62. package/react-native-update.podspec +36 -3
  63. package/src/client.ts +236 -82
  64. package/src/context.ts +40 -27
  65. package/src/core.ts +41 -16
  66. package/src/endpoint.ts +2 -1
  67. package/src/error.ts +49 -11
  68. package/src/index.ts +14 -0
  69. package/src/locales/en.ts +1 -23
  70. package/src/locales/zh.ts +1 -22
  71. package/src/provider.tsx +159 -111
  72. package/src/type.ts +0 -2
  73. package/src/updateFlowCore.ts +10 -2
  74. package/src/utils.ts +61 -4
  75. package/android/src/main/java/cn/reactnative/modules/update/ReactNativeHostHandler.java +0 -13
  76. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +0 -45
@@ -30,13 +30,24 @@
30
30
 
31
31
  #import <React/RCTConvert.h>
32
32
  #import <React/RCTLog.h>
33
+ #import <objc/runtime.h>
33
34
  #import <os/lock.h>
34
35
 
35
36
  #include <atomic>
37
+ #include <sys/stat.h>
36
38
 
37
39
  static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY";
38
- static NSString *const paramPackageVersion = @"packageVersion";
39
- static NSString *const paramBuildTime = @"buildTime";
40
+ // Binary identity (SyncBinaryVersion input). Prefixed like every other key:
41
+ // the unprefixed names collided with generic host-app/SDK defaults, and a
42
+ // foreign value there made every launch look like a rebuilt binary and wipe
43
+ // the installed update.
44
+ static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION";
45
+ static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME";
46
+ // The unprefixed names earlier SDKs wrote. Read only as a fallback until the
47
+ // first state write migrates them (PushyStateFromDefaults /
48
+ // PushyApplyStateToDefaults).
49
+ static NSString *const legacyParamPackageVersion = @"packageVersion";
50
+ static NSString *const legacyParamBuildTime = @"buildTime";
40
51
  static NSString *const paramLastVersion = @"lastVersion";
41
52
  static NSString *const paramCurrentVersion = @"currentVersion";
42
53
  static NSString *const paramIsFirstTime = @"isFirstTime";
@@ -175,46 +186,32 @@ static NSMutableDictionary<NSString *, NSString *> *PushyArtifactDigests(void) {
175
186
 
176
187
  static NSError *PushyErrorWithCode(const char *code, NSString *message);
177
188
 
178
- static long long PushyFileSizeAtPath(NSString *path) {
179
- NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
180
- NSNumber *size = attributes[NSFileSize];
181
- return size == nil ? -1 : size.longLongValue;
182
- }
183
-
184
- // Free space on the volume holding `path` (walks up to an existing ancestor);
185
- // -1 when unknown.
186
- static long long PushyFreeDiskSpaceForPath(NSString *path) {
187
- NSFileManager *fileManager = [NSFileManager defaultManager];
188
- NSString *probe = path;
189
- while (probe.length > 1 && ![fileManager fileExistsAtPath:probe]) {
190
- probe = [probe stringByDeletingLastPathComponent];
191
- }
192
- NSDictionary *attributes = [fileManager attributesOfFileSystemForPath:probe error:nil];
193
- NSNumber *free = attributes[NSFileSystemFreeSize];
194
- return free == nil ? -1 : free.longLongValue;
195
- }
196
-
197
- // nil when the volume can take `bytesToWrite` plus the safety margin (or the
198
- // free space is unknown); an error otherwise.
189
+ // nil when the volume holding `path` can take `bytesToWrite` plus the safety
190
+ // margin (or the free space is unknown); a PATCH_FAILED error otherwise. The
191
+ // probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the
192
+ // same rule to the announced Content-Length before the first byte lands.
199
193
  static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
200
- long long free = PushyFreeDiskSpaceForPath(path);
201
- if (free < 0) {
194
+ NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite);
195
+ if (shortfall == nil) {
202
196
  return nil;
203
197
  }
204
- long long needed = MAX(0LL, bytesToWrite) + pushy::archive_limits::kFreeDiskMarginBytes;
205
- if (free < needed) {
206
- return PushyErrorWithCode(
207
- pushy::error_codes::kPatchFailed,
208
- [NSString stringWithFormat:@"insufficient disk space: need %lld bytes, have %lld",
209
- needed, free]);
210
- }
211
- return nil;
198
+ return PushyErrorWithCode(pushy::error_codes::kPatchFailed, shortfall);
212
199
  }
213
200
 
214
201
  // SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the
215
- // archive is walked: entry count, per-entry size, total size, compression
216
- // ratio. SSZipArchive silently skips an entry the delegate refuses, so the
217
- // first violation is recorded and turned into a hard failure afterwards.
202
+ // archive is walked (entry count, per-entry size, total size, compression
203
+ // ratio) plus two entry-shape rules Android's java.util.zip gets for free:
204
+ // - no symlinks: SSZipArchive materialises them with any target regardless
205
+ // of preserveAttributes, so `assets -> ../../Documents` followed by
206
+ // `assets/x` would write outside the version directory;
207
+ // - no ".pushy-" reserved names at any depth: the completion marker is
208
+ // written by this SDK only, after the whole install succeeded. An archive
209
+ // shipping its own would be trusted as a finished install even if the
210
+ // patch step that follows fails.
211
+ // A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the
212
+ // 2.x contract the podspec pins), and the completion handler turns the
213
+ // recorded violation into a hard PATCH_FAILED; the caller then discards the
214
+ // staging directory.
218
215
  @interface PushyUnzipGuard : NSObject <SSZipArchiveDelegate>
219
216
  @property (nonatomic, copy) NSString *violation;
220
217
  @property (nonatomic, assign) long long totalUncompressed;
@@ -236,6 +233,13 @@ static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
236
233
  if (self.violation != nil) {
237
234
  return NO;
238
235
  }
236
+ // Unix mode bits travel in the high 16 bits of the external attributes
237
+ // (the same field SSZipArchive keys its own symlink branch on).
238
+ if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) {
239
+ self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink",
240
+ (long)fileIndex];
241
+ return NO;
242
+ }
239
243
  long long size = (long long)fileInfo.uncompressed_size;
240
244
  long long compressed = (long long)fileInfo.compressed_size;
241
245
  if (size > pushy::archive_limits::kMaxEntryBytes) {
@@ -258,6 +262,21 @@ static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
258
262
  return YES;
259
263
  }
260
264
 
265
+ // The entry name is only exposed once the entry is on disk (shouldUnzip
266
+ // runs before the name is read). A reserved name is flagged here; the next
267
+ // shouldUnzip refuses (cancelling the unzip) or, for the last entry, the
268
+ // completion handler picks the violation up. Either way the caller drops
269
+ // the whole staging directory, so the written file never survives.
270
+ - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex
271
+ totalFiles:(NSInteger)totalFiles
272
+ archivePath:(NSString *)archivePath
273
+ unzippedFilePath:(NSString *)unzippedFilePath {
274
+ NSString *name = unzippedFilePath.lastPathComponent;
275
+ if (self.violation == nil && [name hasPrefix:@".pushy-"]) {
276
+ self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name];
277
+ }
278
+ }
279
+
261
280
  @end
262
281
 
263
282
  static NSError *PushyDownloadDeadlineExpiredError(void) {
@@ -274,10 +293,13 @@ typedef NS_ENUM(NSInteger, PushyType) {
274
293
  PushyTypeFullDownload = 1,
275
294
  PushyTypePatchFromPackage = 2,
276
295
  PushyTypePatchFromPpk = 3,
277
- //TASK_TYPE_PLAIN_DOWNLOAD=4?
278
296
  };
279
297
 
280
298
  static std::atomic<bool> ignoreRollback{false};
299
+ // Whether the host asked +bundleURL for the bundle to load (the
300
+ // isUsingBundleUrl constant, mirroring Android/Harmony). NO means the app
301
+ // runs a bundle this SDK never resolved, so updates cannot take effect.
302
+ static std::atomic<bool> pushyIsUsingBundleUrl{false};
281
303
  // Bumped by resetToPackagedBundle. The cold-start check runs for minutes and
282
304
  // may already hold a decision when the app resets to the packaged bundle; it
283
305
  // samples this counter and abandons activation (and its response cache) when
@@ -419,6 +441,11 @@ static void PushyFinishDownload(NSString *hash, NSError *error) {
419
441
  static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT;
420
442
 
421
443
  static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) {
444
+ #if DEBUG
445
+ // Not re-entrant: a nested acquisition deadlocks in release, so catch it
446
+ // where it is written.
447
+ os_unfair_lock_assert_not_owner(&pushyStateLock);
448
+ #endif
422
449
  os_unfair_lock_lock(&pushyStateLock);
423
450
  @try {
424
451
  block();
@@ -515,46 +542,87 @@ static NSError *PushyErrorWithCode(const char *code, NSString *message) {
515
542
  }];
516
543
  }
517
544
 
518
- static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json) {
519
- pushy::patch::PatchManifest manifest;
545
+ static BOOL PushyJsonIsAbsent(id value) {
546
+ return value == nil || [value isKindOfClass:[NSNull class]];
547
+ }
520
548
 
521
- NSDictionary *copies = json[@"copies"];
522
- // Content checksum per copy target ("copiesCrc", pdiff manifests from
523
- // CLI >= 2.21.2). The patch core verifies the copy source against it and
524
- // fails the patch on mismatch, so the JS strategy chain falls back to the
525
- // full package instead of copying drifted bytes from a rebuilt binary.
526
- NSDictionary *copiesCrc = json[@"copiesCrc"];
527
- if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
528
- copiesCrc = nil;
529
- }
530
- for (NSString *to in copies) {
531
- NSString *from = copies[to];
532
- if (from.length <= 0) {
533
- from = to;
549
+ // Builds the patch-core manifest from a parsed __diff.json. Every field is
550
+ // type-checked: this runs inside a GCD block on _fileQueue, where the
551
+ // unrecognized-selector exception a `copies` array or a numeric `from` would
552
+ // raise takes the whole app down (and trips the crash rescue) instead of
553
+ // failing the patch. NO with `reason` set for a malformed manifest.
554
+ static BOOL PushyPatchManifestFromJson(NSDictionary *json,
555
+ pushy::patch::PatchManifest *manifest,
556
+ NSString **reason) {
557
+ id copies = json[@"copies"];
558
+ if (!PushyJsonIsAbsent(copies)) {
559
+ if (![copies isKindOfClass:[NSDictionary class]]) {
560
+ *reason = @"patch manifest: copies is not an object";
561
+ return NO;
534
562
  }
535
- pushy::patch::CopyOperation operation;
536
- operation.from = PushyToStdString(from);
537
- operation.to = PushyToStdString(to);
538
- NSNumber *expectedCrc = copiesCrc[to];
539
- if ([expectedCrc isKindOfClass:[NSNumber class]]) {
540
- operation.has_expected_crc = true;
541
- operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
563
+ // Content checksum per copy target ("copiesCrc", pdiff manifests
564
+ // from CLI >= 2.21.2). The patch core verifies the copy source
565
+ // against it and fails the patch on mismatch, so the JS strategy
566
+ // chain falls back to the full package instead of copying drifted
567
+ // bytes from a rebuilt binary.
568
+ NSDictionary *copiesCrc = json[@"copiesCrc"];
569
+ if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
570
+ copiesCrc = nil;
571
+ }
572
+ for (id to in (NSDictionary *)copies) {
573
+ id from = ((NSDictionary *)copies)[to];
574
+ if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) {
575
+ *reason = @"patch manifest: copies entry is not a string";
576
+ return NO;
577
+ }
578
+ if ([from length] == 0) {
579
+ from = to;
580
+ }
581
+ pushy::patch::CopyOperation operation;
582
+ operation.from = PushyToStdString(from);
583
+ operation.to = PushyToStdString(to);
584
+ NSNumber *expectedCrc = copiesCrc[to];
585
+ if ([expectedCrc isKindOfClass:[NSNumber class]]) {
586
+ operation.has_expected_crc = true;
587
+ operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
588
+ }
589
+ manifest->copies.push_back(operation);
542
590
  }
543
- manifest.copies.push_back(operation);
544
591
  }
545
592
 
546
- NSDictionary *deletes = json[@"deletes"];
547
- for (NSString *path in deletes) {
548
- manifest.deletes.push_back(PushyToStdString(path));
593
+ // The CLI emits `deletes` as an object keyed by path (Android reads its
594
+ // keys); a plain array of paths is accepted as well, like Harmony.
595
+ id deletes = json[@"deletes"];
596
+ if (!PushyJsonIsAbsent(deletes)) {
597
+ if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) {
598
+ *reason = @"patch manifest: deletes is not an object or array";
599
+ return NO;
600
+ }
601
+ for (id path in deletes) {
602
+ if (![path isKindOfClass:[NSString class]]) {
603
+ *reason = @"patch manifest: deletes entry is not a string";
604
+ return NO;
605
+ }
606
+ manifest->deletes.push_back(PushyToStdString(path));
607
+ }
549
608
  }
609
+ return YES;
610
+ }
550
611
 
551
- return manifest;
612
+ // Binary identity under the prefixed key, or — until the first state write
613
+ // migrates it — the value an earlier SDK left under the unprefixed one, so an
614
+ // installed update survives the SDK upgrade instead of reading as a rebuilt
615
+ // binary.
616
+ static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) {
617
+ return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey];
552
618
  }
553
619
 
554
620
  static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) {
555
621
  pushy::state::State state;
556
- state.package_version = PushyToStdString([defaults stringForKey:paramPackageVersion]);
557
- state.build_time = PushyToStdString([defaults stringForKey:paramBuildTime]);
622
+ state.package_version = PushyToStdString(
623
+ PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion));
624
+ state.build_time = PushyToStdString(
625
+ PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime));
558
626
  NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo];
559
627
  if (pushyInfo != nil) {
560
628
  state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]);
@@ -570,6 +638,10 @@ static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) {
570
638
  static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) {
571
639
  PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version));
572
640
  PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time));
641
+ // One-time migration: once the prefixed keys hold the identity, the
642
+ // legacy ones are retired so a host-app write there is never read again.
643
+ [defaults removeObjectForKey:legacyParamPackageVersion];
644
+ [defaults removeObjectForKey:legacyParamBuildTime];
573
645
 
574
646
  BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok;
575
647
  if (hasPushyInfo) {
@@ -616,7 +688,7 @@ static void PushySwitchVersionLocked(NSString *hash) {
616
688
  - (void)performUpdate:(PushyType)type
617
689
  options:(NSDictionary *)options
618
690
  callback:(void (^)(NSError *error))callback;
619
- - (void)reloadBridgeWithReason:(NSString *)reason;
691
+ - (NSError *)reloadBridgeWithReason:(NSString *)reason;
620
692
  - (void)unzipDownloadedPackage:(NSString *)zipFilePath
621
693
  hash:(NSString *)hash
622
694
  type:(PushyType)type
@@ -762,10 +834,34 @@ static void PushyInstallCrashRescueHandler(void) {
762
834
  });
763
835
  }
764
836
 
837
+ // Single-flight for the digest in PushyBundleHashSync: the delayed cold-start
838
+ // round (utility queue) and JS getBundleHash (_fileQueue) can both miss the
839
+ // cache right after an install; the second caller waits for the first and
840
+ // then reads its cache entry instead of hashing the same multi-MB file twice.
841
+ static NSLock *PushyBundleHashLock(void) {
842
+ static NSLock *lock;
843
+ static dispatch_once_t once;
844
+ dispatch_once(&once, ^{
845
+ lock = [NSLock new];
846
+ });
847
+ return lock;
848
+ }
849
+
850
+ static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) {
851
+ NSString *cached = [defaults stringForKey:keyBundleHashCache];
852
+ if ([cached hasPrefix:cachedPrefix]) {
853
+ return [cached substringFromIndex:cachedPrefix.length];
854
+ }
855
+ return nil;
856
+ }
857
+
765
858
  // Shared by the getBundleHash RCT method and the native cold-start check.
766
- // Returns @"" when unknown; blocking (sha256 of the embedded bundle on first
767
- // call per install, cached afterwards) — call off the main thread.
768
- static NSString *PushyBundleHashSync(void) {
859
+ // Returns @"" when unknown. With computeIfMissing the sha256 of the embedded
860
+ // bundle is taken on a cache miss (once per install; blocking — call off the
861
+ // main thread). Without it only the cache is consulted: a crash rescue on
862
+ // the main thread has a 3.5s budget in total, and hashing a large bundle on
863
+ // a fresh install would spend all of it before the first request.
864
+ static NSString *PushyBundleHashSync(BOOL computeIfMissing) {
769
865
  NSString *path = [[RCTPushy binaryBundleURL] path];
770
866
  if (path == nil) {
771
867
  return @"";
@@ -781,19 +877,33 @@ static NSString *PushyBundleHashSync(void) {
781
877
  [attributes.fileModificationDate timeIntervalSince1970]];
782
878
 
783
879
  NSUserDefaults *defaults = PushyDefaults();
784
- NSString *cached = [defaults stringForKey:keyBundleHashCache];
785
880
  NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
786
- if ([cached hasPrefix:cachedPrefix]) {
787
- return [cached substringFromIndex:cachedPrefix.length];
881
+ NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix);
882
+ if (cached != nil) {
883
+ return cached;
884
+ }
885
+ if (!computeIfMissing) {
886
+ return @"";
788
887
  }
789
888
 
790
- NSString *hash = PushyFromStdString(
791
- pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
792
- if (hash.length > 0) {
793
- [defaults setObject:[cachedPrefix stringByAppendingString:hash]
794
- forKey:keyBundleHashCache];
889
+ NSLock *lock = PushyBundleHashLock();
890
+ [lock lock];
891
+ @try {
892
+ // The previous holder may have filled the cache while we waited.
893
+ cached = PushyCachedBundleHash(defaults, cachedPrefix);
894
+ if (cached != nil) {
895
+ return cached;
896
+ }
897
+ NSString *hash = PushyFromStdString(
898
+ pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
899
+ if (hash.length > 0) {
900
+ [defaults setObject:[cachedPrefix stringByAppendingString:hash]
901
+ forKey:keyBundleHashCache];
902
+ }
903
+ return hash;
904
+ } @finally {
905
+ [lock unlock];
795
906
  }
796
- return hash;
797
907
  }
798
908
 
799
909
  @implementation RCTPushy {
@@ -805,6 +915,7 @@ RCT_EXPORT_MODULE(RCTPushy);
805
915
 
806
916
  + (NSURL *)bundleURL
807
917
  {
918
+ pushyIsUsingBundleUrl = true;
808
919
  __block NSURL *resolvedURL = nil;
809
920
  __block NSString *launchRolledBackVersion = nil;
810
921
  @try {
@@ -914,6 +1025,7 @@ RCT_EXPORT_MODULE(RCTPushy);
914
1025
  ret[@"rolledBackVersion"] = [defaults objectForKey:keyRolledBackMarked];
915
1026
  ret[@"isFirstTime"] = [defaults objectForKey:keyFirstLoadMarked];
916
1027
  ret[@"uuid"] = [defaults objectForKey:keyUuid];
1028
+ ret[@"isUsingBundleUrl"] = @(pushyIsUsingBundleUrl.load());
917
1029
  // 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道),
918
1030
  // JS 随 checkUpdate 以 diffV 上报,服务端按能力门控下发
919
1031
  ret[@"supportedDiffVersion"] = @(pushy::hbc::kSupportedDiffVersion);
@@ -1112,14 +1224,22 @@ RCT_EXPORT_METHOD(reloadUpdate:(NSDictionary *)options
1112
1224
  return;
1113
1225
  }
1114
1226
 
1115
- [self reloadBridgeWithReason:@"pushy reloadUpdate"];
1227
+ NSError *reloadError = [self reloadBridgeWithReason:@"pushy reloadUpdate"];
1228
+ if (reloadError != nil) {
1229
+ PushyRejectError(reject, reloadError);
1230
+ return;
1231
+ }
1116
1232
  resolve(@true);
1117
1233
  }
1118
1234
 
1119
1235
  RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve
1120
1236
  rejecter:(RCTPromiseRejectBlock)reject)
1121
1237
  {
1122
- [self reloadBridgeWithReason:@"pushy restartApp"];
1238
+ NSError *reloadError = [self reloadBridgeWithReason:@"pushy restartApp"];
1239
+ if (reloadError != nil) {
1240
+ PushyRejectError(reject, reloadError);
1241
+ return;
1242
+ }
1123
1243
  resolve(@true);
1124
1244
  }
1125
1245
 
@@ -1138,7 +1258,7 @@ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
1138
1258
  resolve(@"");
1139
1259
  #else
1140
1260
  dispatch_async(_fileQueue, ^{
1141
- resolve(PushyBundleHashSync());
1261
+ resolve(PushyBundleHashSync(YES));
1142
1262
  });
1143
1263
  #endif
1144
1264
  }
@@ -1150,15 +1270,23 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
1150
1270
  resolve(@true);
1151
1271
  #else
1152
1272
 
1153
- PushyWithStateLock(^{
1154
- NSUserDefaults *defaults = PushyDefaults();
1155
- pushy::state::MarkSuccessResult result =
1156
- pushy::state::MarkSuccess(PushyStateFromDefaults(defaults));
1157
- if (!result.stale_version_to_delete.empty()) {
1158
- [defaults removeObjectForKey:PushyHashInfoKey(PushyFromStdString(result.stale_version_to_delete))];
1159
- }
1160
- PushyApplyStateToDefaults(defaults, result.state);
1161
- });
1273
+ // Failures surface as MARK_SUCCESS_FAILED like on Android, so per-code
1274
+ // telemetry is not blind to this transition on iOS.
1275
+ @try {
1276
+ PushyWithStateLock(^{
1277
+ NSUserDefaults *defaults = PushyDefaults();
1278
+ pushy::state::MarkSuccessResult result =
1279
+ pushy::state::MarkSuccess(PushyStateFromDefaults(defaults));
1280
+ if (!result.stale_version_to_delete.empty()) {
1281
+ [defaults removeObjectForKey:PushyHashInfoKey(PushyFromStdString(result.stale_version_to_delete))];
1282
+ }
1283
+ PushyApplyStateToDefaults(defaults, result.state);
1284
+ });
1285
+ } @catch (NSException *exception) {
1286
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kMarkSuccessFailed,
1287
+ exception.reason ?: @"markSuccess failed"));
1288
+ return;
1289
+ }
1162
1290
 
1163
1291
  [self clearInvalidFiles];
1164
1292
  resolve(@true);
@@ -1175,51 +1303,63 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1175
1303
  // asset loads). Only the client uuid survives — it identifies the install
1176
1304
  // for gray release bucketing and must not change on reset.
1177
1305
  __block NSString *keepVersion = nil;
1178
- PushyWithStateLock(^{
1179
- // Invalidate any in-flight cold-start round before clearing state, so
1180
- // a round that commits under this same lock afterwards always sees the
1181
- // new generation and drops its result.
1182
- pushyResetGeneration.fetch_add(1);
1183
- NSUserDefaults *defaults = PushyDefaults();
1184
- keepVersion = pushyLaunchVersion;
1185
-
1186
- // A default-constructed State is exactly the reset state (no current /
1187
- // last version, first_time=false, first_time_ok=true); keep the binary
1188
- // identity so the next launch does not re-trigger the package-updated
1189
- // sync path.
1190
- pushy::state::State state;
1191
- state.package_version = PushyToStdString([RCTPushy packageVersion]);
1192
- state.build_time = PushyToStdString([RCTPushy buildTime]);
1193
- PushyApplyStateToDefaults(defaults, state);
1306
+ @try {
1307
+ PushyWithStateLock(^{
1308
+ // Invalidate any in-flight cold-start round before clearing state, so
1309
+ // a round that commits under this same lock afterwards always sees the
1310
+ // new generation and drops its result.
1311
+ pushyResetGeneration.fetch_add(1);
1312
+ NSUserDefaults *defaults = PushyDefaults();
1313
+ keepVersion = pushyLaunchVersion;
1314
+
1315
+ // A default-constructed State is exactly the reset state (no current /
1316
+ // last version, first_time=false, first_time_ok=true); keep the binary
1317
+ // identity so the next launch does not re-trigger the package-updated
1318
+ // sync path.
1319
+ pushy::state::State state;
1320
+ state.package_version = PushyToStdString([RCTPushy packageVersion]);
1321
+ state.build_time = PushyToStdString([RCTPushy buildTime]);
1322
+ PushyApplyStateToDefaults(defaults, state);
1194
1323
 
1195
- for (NSString *key in [defaults dictionaryRepresentation].allKeys) {
1196
- if ([key hasPrefix:keyHashInfo]) {
1197
- [defaults removeObjectForKey:key];
1324
+ for (NSString *key in [defaults dictionaryRepresentation].allKeys) {
1325
+ if ([key hasPrefix:keyHashInfo]) {
1326
+ [defaults removeObjectForKey:key];
1327
+ }
1198
1328
  }
1199
- }
1200
- [defaults removeObjectForKey:keyFirstLoadMarked];
1201
- [defaults removeObjectForKey:KeyPackageUpdatedMarked];
1202
- // A cached response still advertises the version this reset just
1203
- // removed; dropping it stops the JS side from reusing that answer.
1204
- [defaults removeObjectForKey:keyNativeCheckCache];
1205
- ignoreRollback = false;
1206
- });
1329
+ [defaults removeObjectForKey:keyFirstLoadMarked];
1330
+ [defaults removeObjectForKey:KeyPackageUpdatedMarked];
1331
+ // A cached response still advertises the version this reset just
1332
+ // removed; dropping it stops the JS side from reusing that answer.
1333
+ [defaults removeObjectForKey:keyNativeCheckCache];
1334
+ ignoreRollback = false;
1335
+ });
1336
+ } @catch (NSException *exception) {
1337
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kResetFailed,
1338
+ exception.reason ?: @"reset failed"));
1339
+ return;
1340
+ }
1207
1341
 
1208
1342
  dispatch_async(_fileQueue, ^{
1209
1343
  // maxAgeDays=0: remove every downloaded entry except the running
1210
- // version's directory (cleaned up by the next regular cleanup).
1344
+ // version's directory (cleaned up by the next regular cleanup). The
1345
+ // state was just wiped, so that is the only name left to keep. The
1346
+ // promise settles here, after the cleanup, so a failed wipe reaches
1347
+ // JS as RESET_FAILED (Android parity) instead of a warning nobody
1348
+ // aggregates.
1211
1349
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
1212
1350
  PushyToStdString([RCTPushy downloadDir]),
1213
- PushyToStdString(keepVersion),
1214
- "",
1351
+ std::vector<std::string>{PushyToStdString(keepVersion)},
1215
1352
  0
1216
1353
  );
1217
1354
  if (!status.ok) {
1218
1355
  RCTLogWarn(@"Pushy reset cleanup error: %s", status.message.c_str());
1356
+ PushyRejectError(reject, PushyErrorWithCode(
1357
+ pushy::error_codes::kResetFailed,
1358
+ [NSString stringWithUTF8String:status.message.c_str()] ?: @"reset cleanup failed"));
1359
+ return;
1219
1360
  }
1361
+ resolve(@true);
1220
1362
  });
1221
-
1222
- resolve(@true);
1223
1363
  }
1224
1364
 
1225
1365
 
@@ -1260,16 +1400,35 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1260
1400
  }];
1261
1401
  }
1262
1402
 
1263
- - (void)reloadBridgeWithReason:(NSString *)reason
1403
+ // Triggers a JS reload; returns nil when one was scheduled, a RESTART_FAILED
1404
+ // error when this host cannot restart. React Native re-resolves the bundle
1405
+ // URL itself on reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp
1406
+ // re-asks sourceURLForBridge:), so +bundleURL is deliberately NOT called
1407
+ // here: it consumes one-shot launch state (first_time, the first-load mark,
1408
+ // ignoreRollback), and consuming it for a reload that never happens would
1409
+ // make the next cold start roll the freshly switched version back.
1410
+ - (NSError *)reloadBridgeWithReason:(NSString *)reason
1264
1411
  {
1412
+ #if PUSHY_HAS_RELOAD_COMMAND
1413
+ dispatch_async(dispatch_get_main_queue(), ^{
1414
+ RCTTriggerReloadCommandListeners(reason);
1415
+ });
1416
+ return nil;
1417
+ #else
1418
+ // No RCTReloadCommand in this React Native: only the legacy bridge can
1419
+ // reload. Under bridgeless the module's bridge is nil or an RCTBridgeProxy
1420
+ // whose -reload is a logged no-op (object_getClass avoids messaging the
1421
+ // proxy) — report that instead of resolving a restart that never happens.
1422
+ RCTBridge *bridge = self.bridge;
1423
+ if (bridge == nil || object_getClass(bridge) == NSClassFromString(@"RCTBridgeProxy")) {
1424
+ return PushyErrorWithCode(pushy::error_codes::kRestartFailed,
1425
+ @"no bridge available to reload (bridgeless host without RCTReloadCommand)");
1426
+ }
1265
1427
  dispatch_async(dispatch_get_main_queue(), ^{
1266
- #if PUSHY_HAS_RELOAD_COMMAND
1267
- RCTReloadCommandSetBundleURL([[self class] bundleURL]);
1268
- RCTTriggerReloadCommandListeners(reason);
1269
- #else
1270
- [self.bridge reload];
1271
- #endif
1428
+ [bridge reload];
1272
1429
  });
1430
+ return nil;
1431
+ #endif
1273
1432
  }
1274
1433
 
1275
1434
  - (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback
@@ -1451,7 +1610,14 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1451
1610
  callback(error);
1452
1611
  return;
1453
1612
  }
1454
- [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback];
1613
+ @try {
1614
+ [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback];
1615
+ } @catch (NSException *exception) {
1616
+ // An uncaught exception in a GCD block is fatal (and would
1617
+ // trip the crash rescue); a bad package is a PATCH_FAILED.
1618
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1619
+ exception.reason ?: @"patch failed"));
1620
+ }
1455
1621
  });
1456
1622
  }];
1457
1623
  }
@@ -1497,7 +1663,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1497
1663
  NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME];
1498
1664
 
1499
1665
  NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1500
- long long manifestBytes = PushyFileSizeAtPath(sourcePatch);
1666
+ long long manifestBytes = RCTPushyFileSize(sourcePatch);
1501
1667
  if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) {
1502
1668
  callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1503
1669
  [NSString stringWithFormat:@"patch manifest too large: %lld bytes", manifestBytes]));
@@ -1523,6 +1689,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1523
1689
  return;
1524
1690
  }
1525
1691
  NSDictionary *json = (NSDictionary *)jsonObject;
1692
+ pushy::patch::PatchManifest manifest;
1693
+ NSString *manifestReason = nil;
1694
+ if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) {
1695
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, manifestReason));
1696
+ return;
1697
+ }
1526
1698
 
1527
1699
  std::vector<std::string> entryNames;
1528
1700
  if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) {
@@ -1537,7 +1709,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1537
1709
  type == PushyTypePatchFromPackage
1538
1710
  ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage
1539
1711
  : pushy::archive_patch::ArchivePatchType::kPatchFromPpk,
1540
- PushyPatchManifestFromJson(json),
1712
+ manifest,
1541
1713
  entryNames,
1542
1714
  &plan
1543
1715
  );
@@ -1690,11 +1862,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1690
1862
  }
1691
1863
 
1692
1864
  // Resource caps before the first byte is extracted
1693
- // (cpp/patch_core/archive_limits.h). The central directory is not
1694
- // readable up front through SSZipArchive, so the disk check uses a
1695
- // 2x-archive heuristic; the delegate below enforces the exact caps
1696
- // entry by entry.
1697
- long long archiveBytes = PushyFileSizeAtPath(path);
1865
+ // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the
1866
+ // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:),
1867
+ // not the uncompressed total, so the disk check uses a 2x-archive
1868
+ // heuristic; the delegate below enforces the exact caps entry by
1869
+ // entry.
1870
+ long long archiveBytes = RCTPushyFileSize(path);
1698
1871
  NSError *preflight = nil;
1699
1872
  if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) {
1700
1873
  preflight = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
@@ -1704,7 +1877,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1704
1877
  }
1705
1878
  if (preflight != nil) {
1706
1879
  [fileManager removeItemAtPath:path error:nil];
1707
- [fileManager removeItemAtPath:[path stringByAppendingString:@".resume"] error:nil];
1880
+ [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(path) error:nil];
1708
1881
  if (completionHandler != nil) {
1709
1882
  completionHandler(preflight);
1710
1883
  }
@@ -1712,9 +1885,11 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1712
1885
  }
1713
1886
 
1714
1887
  PushyUnzipGuard *guard = [PushyUnzipGuard new];
1888
+ // preserveAttributes:NO — the archive's mode/mtime bits have no use
1889
+ // here and are one less thing a hostile package controls.
1715
1890
  [SSZipArchive unzipFileAtPath:path
1716
1891
  toDestination:destination
1717
- preserveAttributes:YES
1892
+ preserveAttributes:NO
1718
1893
  overwrite:YES
1719
1894
  nestedZipLevel:0
1720
1895
  password:nil
@@ -1726,8 +1901,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1726
1901
  // The resume sidecar dies with its archive (§11.4): whether the
1727
1902
  // unzip consumed it or classified it as poisoned, nothing must
1728
1903
  // survive to vouch for bytes that are gone.
1729
- [fileManager removeItemAtPath:[archivePath stringByAppendingString:@".resume"]
1730
- error:nil];
1904
+ [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(archivePath) error:nil];
1731
1905
  if (completionHandler == nil) {
1732
1906
  return;
1733
1907
  }
@@ -1737,16 +1911,6 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1737
1911
  unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, guard.violation);
1738
1912
  } else if (!succeeded && unzipError == nil) {
1739
1913
  unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"unzip failed");
1740
- } else if (succeeded && unzipError == nil
1741
- && [fileManager fileExistsAtPath:
1742
- [destination stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]]) {
1743
- // The completion marker is written by this SDK only, after the
1744
- // whole install succeeded. An archive that ships its own would
1745
- // be trusted as a finished install even if the patch step
1746
- // that follows fails — reject the package outright (the
1747
- // caller removes the half-unpacked directory).
1748
- unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1749
- @"archive contains reserved entry " VERSION_COMPLETE_FILE_NAME_LITERAL);
1750
1914
  } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) {
1751
1915
  // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has
1752
1916
  // no stable code; without one, downloadUpdate's fallback would
@@ -1766,14 +1930,23 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1766
1930
  // Snapshot the state under the lock, but run the (slow) filesystem
1767
1931
  // cleanup outside of it so state operations are not blocked.
1768
1932
  __block pushy::state::State state;
1933
+ __block NSString *launchVersion = nil;
1769
1934
  PushyWithStateLock(^{
1770
1935
  state = PushyStateFromDefaults(PushyDefaults());
1936
+ launchVersion = pushyLaunchVersion;
1771
1937
  });
1772
1938
  NSString *downloadDir = [RCTPushy downloadDir];
1939
+ // Keep the persisted current/last versions AND the version this
1940
+ // process booted from: two switches without a restart would otherwise
1941
+ // evict the running bundle while its on-demand assets are still
1942
+ // being served.
1773
1943
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
1774
1944
  PushyToStdString(downloadDir),
1775
- state.current_version,
1776
- state.last_version,
1945
+ std::vector<std::string>{
1946
+ state.current_version,
1947
+ state.last_version,
1948
+ PushyToStdString(launchVersion),
1949
+ },
1777
1950
  3
1778
1951
  );
1779
1952
  if (!status.ok) {
@@ -1785,15 +1958,14 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1785
1958
  - (NSString *)zipExtension:(PushyType)type
1786
1959
  {
1787
1960
  switch (type) {
1788
- case PushyTypeFullDownload:
1789
- return @".ppk";
1790
1961
  case PushyTypePatchFromPackage:
1791
1962
  return @".ipa.patch";
1792
1963
  case PushyTypePatchFromPpk:
1793
1964
  return @".ppk.patch";
1794
- default:
1795
- return @"";
1965
+ case PushyTypeFullDownload:
1966
+ break;
1796
1967
  }
1968
+ return @".ppk";
1797
1969
  }
1798
1970
 
1799
1971
  + (NSString *)downloadDir
@@ -1827,9 +1999,30 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1827
1999
  static NSString *buildTime;
1828
2000
  static dispatch_once_t onceToken;
1829
2001
  dispatch_once(&onceToken, ^{
1830
- NSString *buildTimePath = [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"];
1831
- buildTime = [[NSString stringWithContentsOfFile:buildTimePath encoding:NSUTF8StringEncoding error:nil]
1832
- stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]];
2002
+ // The podspec's s.resource lands in the app bundle for a static
2003
+ // library but inside this pod's own framework bundle under
2004
+ // use_frameworks!; look in both.
2005
+ NSString *stampPath =
2006
+ [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"]
2007
+ ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time"
2008
+ ofType:@"txt"];
2009
+ NSString *stamp = stampPath == nil ? nil
2010
+ : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil]
2011
+ stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
2012
+ if (stamp.length == 0) {
2013
+ // Never empty: with "" SyncBinaryVersion would miss a rebuilt
2014
+ // binary that kept its CFBundleShortVersionString, and a stale
2015
+ // hot update would outlive an App Store update. Every build
2016
+ // rewrites Info.plist, so its mtime (integer seconds) stands in.
2017
+ RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime",
2018
+ stampPath == nil ? @"not found in the main or framework bundle" : @"is empty");
2019
+ NSString *infoPlist =
2020
+ [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"];
2021
+ NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist
2022
+ error:nil].fileModificationDate;
2023
+ stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]];
2024
+ }
2025
+ buildTime = stamp;
1833
2026
  });
1834
2027
  return buildTime;
1835
2028
  #endif
@@ -1847,6 +2040,34 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1847
2040
 
1848
2041
  #pragma mark - native cold-start check orchestration
1849
2042
 
2043
+ // Refuses an https→http downgrade before the redirected request — and with
2044
+ // it the POST body (appKey, uuid, versions) — leaves the device. The shared
2045
+ // session would follow first and only the response could be rejected; ATS
2046
+ // blocks plaintext by default, this covers apps that opted out with
2047
+ // NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil
2048
+ // delivers the 3xx itself as the final response, which the status check in
2049
+ // PushyHttpRequest then treats as a failed endpoint.
2050
+ @interface PushyCheckRequestRedirectGuard : NSObject <NSURLSessionTaskDelegate>
2051
+ @end
2052
+
2053
+ @implementation PushyCheckRequestRedirectGuard
2054
+
2055
+ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
2056
+ willPerformHTTPRedirection:(NSHTTPURLResponse *)response
2057
+ newRequest:(NSURLRequest *)request
2058
+ completionHandler:(void (^)(NSURLRequest *))completionHandler
2059
+ {
2060
+ NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString];
2061
+ NSString *nextScheme = [request.URL.scheme lowercaseString];
2062
+ if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) {
2063
+ completionHandler(nil);
2064
+ return;
2065
+ }
2066
+ completionHandler(request);
2067
+ }
2068
+
2069
+ @end
2070
+
1850
2071
  // Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns
1851
2072
  // the response body on 2xx, nil on any failure. The semaphore timeout is a
1852
2073
  // backstop over the request's own timeoutInterval.
@@ -1866,25 +2087,30 @@ static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
1866
2087
  }
1867
2088
  dispatch_semaphore_t sem = dispatch_semaphore_create(0);
1868
2089
  __block NSString *result = nil;
1869
- NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
2090
+ // A delegate session (not the shared one) so the redirect guard sees the
2091
+ // 3xx before it is followed; the completion handler still receives the
2092
+ // final response.
2093
+ NSURLSession *session = [NSURLSession
2094
+ sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]
2095
+ delegate:[PushyCheckRequestRedirectGuard new]
2096
+ delegateQueue:nil];
2097
+ NSURLSessionDataTask *task = [session dataTaskWithRequest:request
1870
2098
  completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
1871
2099
  NSHTTPURLResponse *httpResponse =
1872
2100
  [response isKindOfClass:[NSHTTPURLResponse class]]
1873
2101
  ? (NSHTTPURLResponse *)response
1874
2102
  : nil;
1875
2103
  NSInteger status = httpResponse.statusCode;
1876
- // The shared session follows redirects; an https endpoint that
1877
- // ended up on plaintext http is a failed endpoint, same as the
1878
- // artifact download.
1879
- BOOL downgraded = [[url.scheme lowercaseString] isEqualToString:@"https"]
1880
- && [[httpResponse.URL.scheme lowercaseString] isEqualToString:@"http"];
1881
- if (error == nil && httpResponse != nil && !downgraded && status >= 200 &&
1882
- status < 300 && data != nil) {
2104
+ if (error == nil && httpResponse != nil && status >= 200 && status < 300
2105
+ && data != nil) {
1883
2106
  result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
1884
2107
  }
1885
2108
  dispatch_semaphore_signal(sem);
1886
2109
  }];
1887
2110
  [task resume];
2111
+ // The session retains its delegate until invalidated: let this one task
2112
+ // settle, then release everything.
2113
+ [session finishTasksAndInvalidate];
1888
2114
  if (dispatch_semaphore_wait(
1889
2115
  sem, dispatch_time(DISPATCH_TIME_NOW,
1890
2116
  (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) {
@@ -2151,8 +2377,11 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2151
2377
  input.Set("cInfo", cInfo);
2152
2378
  input.Set("supportedDiffVersion",
2153
2379
  flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion));
2380
+ // Cache-only while a crash is being held (§11.3 budget); computed (and
2381
+ // shared with JS getBundleHash) on the ordinary delayed round.
2154
2382
  input.Set("bundleHash",
2155
- flowjson::Value::String(PushyToStdString(PushyBundleHashSync())));
2383
+ flowjson::Value::String(PushyToStdString(
2384
+ PushyBundleHashSync(!pushyCrashRescueActive.load()))));
2156
2385
 
2157
2386
  std::string bodyJson =
2158
2387
  flowjson::Stringify(updateflow::BuildCheckRequestBody(input));