react-native-update 10.55.0 → 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 +406 -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 +244 -86
  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
@@ -14,8 +14,15 @@
14
14
 
15
15
  #import <UIKit/UIKit.h>
16
16
 
17
+ // use_frameworks! 构建里 React 头文件只有 <React/…> 框架形式可见,引号形式的
18
+ // __has_include 会探测失败;漏掉这里会静默编入 [self.bridge reload] 兜底,而
19
+ // bridgeless 下 bridge 是 nil/代理,reload 变成无声 no-op(切版本后不重启)。
17
20
  #if __has_include("RCTReloadCommand.h")
18
21
  #import "RCTReloadCommand.h"
22
+ #define PUSHY_HAS_RELOAD_COMMAND 1
23
+ #elif __has_include(<React/RCTReloadCommand.h>)
24
+ #import <React/RCTReloadCommand.h>
25
+ #define PUSHY_HAS_RELOAD_COMMAND 1
19
26
  #endif
20
27
  #ifdef RCT_NEW_ARCH_ENABLED
21
28
  #import "RCTPushySpec.h"
@@ -23,13 +30,24 @@
23
30
 
24
31
  #import <React/RCTConvert.h>
25
32
  #import <React/RCTLog.h>
33
+ #import <objc/runtime.h>
26
34
  #import <os/lock.h>
27
35
 
28
36
  #include <atomic>
37
+ #include <sys/stat.h>
29
38
 
30
39
  static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY";
31
- static NSString *const paramPackageVersion = @"packageVersion";
32
- 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";
33
51
  static NSString *const paramLastVersion = @"lastVersion";
34
52
  static NSString *const paramCurrentVersion = @"currentVersion";
35
53
  static NSString *const paramIsFirstTime = @"isFirstTime";
@@ -168,46 +186,32 @@ static NSMutableDictionary<NSString *, NSString *> *PushyArtifactDigests(void) {
168
186
 
169
187
  static NSError *PushyErrorWithCode(const char *code, NSString *message);
170
188
 
171
- static long long PushyFileSizeAtPath(NSString *path) {
172
- NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
173
- NSNumber *size = attributes[NSFileSize];
174
- return size == nil ? -1 : size.longLongValue;
175
- }
176
-
177
- // Free space on the volume holding `path` (walks up to an existing ancestor);
178
- // -1 when unknown.
179
- static long long PushyFreeDiskSpaceForPath(NSString *path) {
180
- NSFileManager *fileManager = [NSFileManager defaultManager];
181
- NSString *probe = path;
182
- while (probe.length > 1 && ![fileManager fileExistsAtPath:probe]) {
183
- probe = [probe stringByDeletingLastPathComponent];
184
- }
185
- NSDictionary *attributes = [fileManager attributesOfFileSystemForPath:probe error:nil];
186
- NSNumber *free = attributes[NSFileSystemFreeSize];
187
- return free == nil ? -1 : free.longLongValue;
188
- }
189
-
190
- // nil when the volume can take `bytesToWrite` plus the safety margin (or the
191
- // 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.
192
193
  static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
193
- long long free = PushyFreeDiskSpaceForPath(path);
194
- if (free < 0) {
194
+ NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite);
195
+ if (shortfall == nil) {
195
196
  return nil;
196
197
  }
197
- long long needed = MAX(0LL, bytesToWrite) + pushy::archive_limits::kFreeDiskMarginBytes;
198
- if (free < needed) {
199
- return PushyErrorWithCode(
200
- pushy::error_codes::kPatchFailed,
201
- [NSString stringWithFormat:@"insufficient disk space: need %lld bytes, have %lld",
202
- needed, free]);
203
- }
204
- return nil;
198
+ return PushyErrorWithCode(pushy::error_codes::kPatchFailed, shortfall);
205
199
  }
206
200
 
207
201
  // SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the
208
- // archive is walked: entry count, per-entry size, total size, compression
209
- // ratio. SSZipArchive silently skips an entry the delegate refuses, so the
210
- // 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.
211
215
  @interface PushyUnzipGuard : NSObject <SSZipArchiveDelegate>
212
216
  @property (nonatomic, copy) NSString *violation;
213
217
  @property (nonatomic, assign) long long totalUncompressed;
@@ -229,6 +233,13 @@ static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
229
233
  if (self.violation != nil) {
230
234
  return NO;
231
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
+ }
232
243
  long long size = (long long)fileInfo.uncompressed_size;
233
244
  long long compressed = (long long)fileInfo.compressed_size;
234
245
  if (size > pushy::archive_limits::kMaxEntryBytes) {
@@ -251,6 +262,21 @@ static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
251
262
  return YES;
252
263
  }
253
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
+
254
280
  @end
255
281
 
256
282
  static NSError *PushyDownloadDeadlineExpiredError(void) {
@@ -267,10 +293,13 @@ typedef NS_ENUM(NSInteger, PushyType) {
267
293
  PushyTypeFullDownload = 1,
268
294
  PushyTypePatchFromPackage = 2,
269
295
  PushyTypePatchFromPpk = 3,
270
- //TASK_TYPE_PLAIN_DOWNLOAD=4?
271
296
  };
272
297
 
273
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};
274
303
  // Bumped by resetToPackagedBundle. The cold-start check runs for minutes and
275
304
  // may already hold a decision when the app resets to the packaged bundle; it
276
305
  // samples this counter and abandons activation (and its response cache) when
@@ -412,6 +441,11 @@ static void PushyFinishDownload(NSString *hash, NSError *error) {
412
441
  static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT;
413
442
 
414
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
415
449
  os_unfair_lock_lock(&pushyStateLock);
416
450
  @try {
417
451
  block();
@@ -508,46 +542,87 @@ static NSError *PushyErrorWithCode(const char *code, NSString *message) {
508
542
  }];
509
543
  }
510
544
 
511
- static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json) {
512
- pushy::patch::PatchManifest manifest;
545
+ static BOOL PushyJsonIsAbsent(id value) {
546
+ return value == nil || [value isKindOfClass:[NSNull class]];
547
+ }
513
548
 
514
- NSDictionary *copies = json[@"copies"];
515
- // Content checksum per copy target ("copiesCrc", pdiff manifests from
516
- // CLI >= 2.21.2). The patch core verifies the copy source against it and
517
- // fails the patch on mismatch, so the JS strategy chain falls back to the
518
- // full package instead of copying drifted bytes from a rebuilt binary.
519
- NSDictionary *copiesCrc = json[@"copiesCrc"];
520
- if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
521
- copiesCrc = nil;
522
- }
523
- for (NSString *to in copies) {
524
- NSString *from = copies[to];
525
- if (from.length <= 0) {
526
- 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;
527
562
  }
528
- pushy::patch::CopyOperation operation;
529
- operation.from = PushyToStdString(from);
530
- operation.to = PushyToStdString(to);
531
- NSNumber *expectedCrc = copiesCrc[to];
532
- if ([expectedCrc isKindOfClass:[NSNumber class]]) {
533
- operation.has_expected_crc = true;
534
- 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);
535
590
  }
536
- manifest.copies.push_back(operation);
537
591
  }
538
592
 
539
- NSDictionary *deletes = json[@"deletes"];
540
- for (NSString *path in deletes) {
541
- 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
+ }
542
608
  }
609
+ return YES;
610
+ }
543
611
 
544
- 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];
545
618
  }
546
619
 
547
620
  static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) {
548
621
  pushy::state::State state;
549
- state.package_version = PushyToStdString([defaults stringForKey:paramPackageVersion]);
550
- 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));
551
626
  NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo];
552
627
  if (pushyInfo != nil) {
553
628
  state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]);
@@ -563,6 +638,10 @@ static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) {
563
638
  static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) {
564
639
  PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version));
565
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];
566
645
 
567
646
  BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok;
568
647
  if (hasPushyInfo) {
@@ -609,7 +688,7 @@ static void PushySwitchVersionLocked(NSString *hash) {
609
688
  - (void)performUpdate:(PushyType)type
610
689
  options:(NSDictionary *)options
611
690
  callback:(void (^)(NSError *error))callback;
612
- - (void)reloadBridgeWithReason:(NSString *)reason;
691
+ - (NSError *)reloadBridgeWithReason:(NSString *)reason;
613
692
  - (void)unzipDownloadedPackage:(NSString *)zipFilePath
614
693
  hash:(NSString *)hash
615
694
  type:(PushyType)type
@@ -755,10 +834,34 @@ static void PushyInstallCrashRescueHandler(void) {
755
834
  });
756
835
  }
757
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
+
758
858
  // Shared by the getBundleHash RCT method and the native cold-start check.
759
- // Returns @"" when unknown; blocking (sha256 of the embedded bundle on first
760
- // call per install, cached afterwards) — call off the main thread.
761
- 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) {
762
865
  NSString *path = [[RCTPushy binaryBundleURL] path];
763
866
  if (path == nil) {
764
867
  return @"";
@@ -774,19 +877,33 @@ static NSString *PushyBundleHashSync(void) {
774
877
  [attributes.fileModificationDate timeIntervalSince1970]];
775
878
 
776
879
  NSUserDefaults *defaults = PushyDefaults();
777
- NSString *cached = [defaults stringForKey:keyBundleHashCache];
778
880
  NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
779
- if ([cached hasPrefix:cachedPrefix]) {
780
- return [cached substringFromIndex:cachedPrefix.length];
881
+ NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix);
882
+ if (cached != nil) {
883
+ return cached;
884
+ }
885
+ if (!computeIfMissing) {
886
+ return @"";
781
887
  }
782
888
 
783
- NSString *hash = PushyFromStdString(
784
- pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
785
- if (hash.length > 0) {
786
- [defaults setObject:[cachedPrefix stringByAppendingString:hash]
787
- 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];
788
906
  }
789
- return hash;
790
907
  }
791
908
 
792
909
  @implementation RCTPushy {
@@ -798,6 +915,7 @@ RCT_EXPORT_MODULE(RCTPushy);
798
915
 
799
916
  + (NSURL *)bundleURL
800
917
  {
918
+ pushyIsUsingBundleUrl = true;
801
919
  __block NSURL *resolvedURL = nil;
802
920
  __block NSString *launchRolledBackVersion = nil;
803
921
  @try {
@@ -907,6 +1025,7 @@ RCT_EXPORT_MODULE(RCTPushy);
907
1025
  ret[@"rolledBackVersion"] = [defaults objectForKey:keyRolledBackMarked];
908
1026
  ret[@"isFirstTime"] = [defaults objectForKey:keyFirstLoadMarked];
909
1027
  ret[@"uuid"] = [defaults objectForKey:keyUuid];
1028
+ ret[@"isUsingBundleUrl"] = @(pushyIsUsingBundleUrl.load());
910
1029
  // 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道),
911
1030
  // JS 随 checkUpdate 以 diffV 上报,服务端按能力门控下发
912
1031
  ret[@"supportedDiffVersion"] = @(pushy::hbc::kSupportedDiffVersion);
@@ -1105,14 +1224,22 @@ RCT_EXPORT_METHOD(reloadUpdate:(NSDictionary *)options
1105
1224
  return;
1106
1225
  }
1107
1226
 
1108
- [self reloadBridgeWithReason:@"pushy reloadUpdate"];
1227
+ NSError *reloadError = [self reloadBridgeWithReason:@"pushy reloadUpdate"];
1228
+ if (reloadError != nil) {
1229
+ PushyRejectError(reject, reloadError);
1230
+ return;
1231
+ }
1109
1232
  resolve(@true);
1110
1233
  }
1111
1234
 
1112
1235
  RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve
1113
1236
  rejecter:(RCTPromiseRejectBlock)reject)
1114
1237
  {
1115
- [self reloadBridgeWithReason:@"pushy restartApp"];
1238
+ NSError *reloadError = [self reloadBridgeWithReason:@"pushy restartApp"];
1239
+ if (reloadError != nil) {
1240
+ PushyRejectError(reject, reloadError);
1241
+ return;
1242
+ }
1116
1243
  resolve(@true);
1117
1244
  }
1118
1245
 
@@ -1131,7 +1258,7 @@ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
1131
1258
  resolve(@"");
1132
1259
  #else
1133
1260
  dispatch_async(_fileQueue, ^{
1134
- resolve(PushyBundleHashSync());
1261
+ resolve(PushyBundleHashSync(YES));
1135
1262
  });
1136
1263
  #endif
1137
1264
  }
@@ -1143,15 +1270,23 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
1143
1270
  resolve(@true);
1144
1271
  #else
1145
1272
 
1146
- PushyWithStateLock(^{
1147
- NSUserDefaults *defaults = PushyDefaults();
1148
- pushy::state::MarkSuccessResult result =
1149
- pushy::state::MarkSuccess(PushyStateFromDefaults(defaults));
1150
- if (!result.stale_version_to_delete.empty()) {
1151
- [defaults removeObjectForKey:PushyHashInfoKey(PushyFromStdString(result.stale_version_to_delete))];
1152
- }
1153
- PushyApplyStateToDefaults(defaults, result.state);
1154
- });
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
+ }
1155
1290
 
1156
1291
  [self clearInvalidFiles];
1157
1292
  resolve(@true);
@@ -1168,51 +1303,63 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1168
1303
  // asset loads). Only the client uuid survives — it identifies the install
1169
1304
  // for gray release bucketing and must not change on reset.
1170
1305
  __block NSString *keepVersion = nil;
1171
- PushyWithStateLock(^{
1172
- // Invalidate any in-flight cold-start round before clearing state, so
1173
- // a round that commits under this same lock afterwards always sees the
1174
- // new generation and drops its result.
1175
- pushyResetGeneration.fetch_add(1);
1176
- NSUserDefaults *defaults = PushyDefaults();
1177
- keepVersion = pushyLaunchVersion;
1178
-
1179
- // A default-constructed State is exactly the reset state (no current /
1180
- // last version, first_time=false, first_time_ok=true); keep the binary
1181
- // identity so the next launch does not re-trigger the package-updated
1182
- // sync path.
1183
- pushy::state::State state;
1184
- state.package_version = PushyToStdString([RCTPushy packageVersion]);
1185
- state.build_time = PushyToStdString([RCTPushy buildTime]);
1186
- 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);
1187
1323
 
1188
- for (NSString *key in [defaults dictionaryRepresentation].allKeys) {
1189
- if ([key hasPrefix:keyHashInfo]) {
1190
- [defaults removeObjectForKey:key];
1324
+ for (NSString *key in [defaults dictionaryRepresentation].allKeys) {
1325
+ if ([key hasPrefix:keyHashInfo]) {
1326
+ [defaults removeObjectForKey:key];
1327
+ }
1191
1328
  }
1192
- }
1193
- [defaults removeObjectForKey:keyFirstLoadMarked];
1194
- [defaults removeObjectForKey:KeyPackageUpdatedMarked];
1195
- // A cached response still advertises the version this reset just
1196
- // removed; dropping it stops the JS side from reusing that answer.
1197
- [defaults removeObjectForKey:keyNativeCheckCache];
1198
- ignoreRollback = false;
1199
- });
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
+ }
1200
1341
 
1201
1342
  dispatch_async(_fileQueue, ^{
1202
1343
  // maxAgeDays=0: remove every downloaded entry except the running
1203
- // 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.
1204
1349
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
1205
1350
  PushyToStdString([RCTPushy downloadDir]),
1206
- PushyToStdString(keepVersion),
1207
- "",
1351
+ std::vector<std::string>{PushyToStdString(keepVersion)},
1208
1352
  0
1209
1353
  );
1210
1354
  if (!status.ok) {
1211
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;
1212
1360
  }
1361
+ resolve(@true);
1213
1362
  });
1214
-
1215
- resolve(@true);
1216
1363
  }
1217
1364
 
1218
1365
 
@@ -1253,16 +1400,35 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1253
1400
  }];
1254
1401
  }
1255
1402
 
1256
- - (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
1257
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
+ }
1258
1427
  dispatch_async(dispatch_get_main_queue(), ^{
1259
- #if __has_include("RCTReloadCommand.h")
1260
- RCTReloadCommandSetBundleURL([[self class] bundleURL]);
1261
- RCTTriggerReloadCommandListeners(reason);
1262
- #else
1263
- [self.bridge reload];
1264
- #endif
1428
+ [bridge reload];
1265
1429
  });
1430
+ return nil;
1431
+ #endif
1266
1432
  }
1267
1433
 
1268
1434
  - (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback
@@ -1444,7 +1610,14 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1444
1610
  callback(error);
1445
1611
  return;
1446
1612
  }
1447
- [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
+ }
1448
1621
  });
1449
1622
  }];
1450
1623
  }
@@ -1490,7 +1663,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1490
1663
  NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME];
1491
1664
 
1492
1665
  NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1493
- long long manifestBytes = PushyFileSizeAtPath(sourcePatch);
1666
+ long long manifestBytes = RCTPushyFileSize(sourcePatch);
1494
1667
  if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) {
1495
1668
  callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1496
1669
  [NSString stringWithFormat:@"patch manifest too large: %lld bytes", manifestBytes]));
@@ -1516,6 +1689,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1516
1689
  return;
1517
1690
  }
1518
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
+ }
1519
1698
 
1520
1699
  std::vector<std::string> entryNames;
1521
1700
  if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) {
@@ -1530,7 +1709,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1530
1709
  type == PushyTypePatchFromPackage
1531
1710
  ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage
1532
1711
  : pushy::archive_patch::ArchivePatchType::kPatchFromPpk,
1533
- PushyPatchManifestFromJson(json),
1712
+ manifest,
1534
1713
  entryNames,
1535
1714
  &plan
1536
1715
  );
@@ -1683,11 +1862,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1683
1862
  }
1684
1863
 
1685
1864
  // Resource caps before the first byte is extracted
1686
- // (cpp/patch_core/archive_limits.h). The central directory is not
1687
- // readable up front through SSZipArchive, so the disk check uses a
1688
- // 2x-archive heuristic; the delegate below enforces the exact caps
1689
- // entry by entry.
1690
- 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);
1691
1871
  NSError *preflight = nil;
1692
1872
  if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) {
1693
1873
  preflight = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
@@ -1697,7 +1877,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1697
1877
  }
1698
1878
  if (preflight != nil) {
1699
1879
  [fileManager removeItemAtPath:path error:nil];
1700
- [fileManager removeItemAtPath:[path stringByAppendingString:@".resume"] error:nil];
1880
+ [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(path) error:nil];
1701
1881
  if (completionHandler != nil) {
1702
1882
  completionHandler(preflight);
1703
1883
  }
@@ -1705,9 +1885,11 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1705
1885
  }
1706
1886
 
1707
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.
1708
1890
  [SSZipArchive unzipFileAtPath:path
1709
1891
  toDestination:destination
1710
- preserveAttributes:YES
1892
+ preserveAttributes:NO
1711
1893
  overwrite:YES
1712
1894
  nestedZipLevel:0
1713
1895
  password:nil
@@ -1719,8 +1901,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1719
1901
  // The resume sidecar dies with its archive (§11.4): whether the
1720
1902
  // unzip consumed it or classified it as poisoned, nothing must
1721
1903
  // survive to vouch for bytes that are gone.
1722
- [fileManager removeItemAtPath:[archivePath stringByAppendingString:@".resume"]
1723
- error:nil];
1904
+ [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(archivePath) error:nil];
1724
1905
  if (completionHandler == nil) {
1725
1906
  return;
1726
1907
  }
@@ -1730,16 +1911,6 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1730
1911
  unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, guard.violation);
1731
1912
  } else if (!succeeded && unzipError == nil) {
1732
1913
  unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"unzip failed");
1733
- } else if (succeeded && unzipError == nil
1734
- && [fileManager fileExistsAtPath:
1735
- [destination stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]]) {
1736
- // The completion marker is written by this SDK only, after the
1737
- // whole install succeeded. An archive that ships its own would
1738
- // be trusted as a finished install even if the patch step
1739
- // that follows fails — reject the package outright (the
1740
- // caller removes the half-unpacked directory).
1741
- unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1742
- @"archive contains reserved entry " VERSION_COMPLETE_FILE_NAME_LITERAL);
1743
1914
  } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) {
1744
1915
  // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has
1745
1916
  // no stable code; without one, downloadUpdate's fallback would
@@ -1759,14 +1930,23 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1759
1930
  // Snapshot the state under the lock, but run the (slow) filesystem
1760
1931
  // cleanup outside of it so state operations are not blocked.
1761
1932
  __block pushy::state::State state;
1933
+ __block NSString *launchVersion = nil;
1762
1934
  PushyWithStateLock(^{
1763
1935
  state = PushyStateFromDefaults(PushyDefaults());
1936
+ launchVersion = pushyLaunchVersion;
1764
1937
  });
1765
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.
1766
1943
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
1767
1944
  PushyToStdString(downloadDir),
1768
- state.current_version,
1769
- state.last_version,
1945
+ std::vector<std::string>{
1946
+ state.current_version,
1947
+ state.last_version,
1948
+ PushyToStdString(launchVersion),
1949
+ },
1770
1950
  3
1771
1951
  );
1772
1952
  if (!status.ok) {
@@ -1778,15 +1958,14 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1778
1958
  - (NSString *)zipExtension:(PushyType)type
1779
1959
  {
1780
1960
  switch (type) {
1781
- case PushyTypeFullDownload:
1782
- return @".ppk";
1783
1961
  case PushyTypePatchFromPackage:
1784
1962
  return @".ipa.patch";
1785
1963
  case PushyTypePatchFromPpk:
1786
1964
  return @".ppk.patch";
1787
- default:
1788
- return @"";
1965
+ case PushyTypeFullDownload:
1966
+ break;
1789
1967
  }
1968
+ return @".ppk";
1790
1969
  }
1791
1970
 
1792
1971
  + (NSString *)downloadDir
@@ -1820,9 +1999,30 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1820
1999
  static NSString *buildTime;
1821
2000
  static dispatch_once_t onceToken;
1822
2001
  dispatch_once(&onceToken, ^{
1823
- NSString *buildTimePath = [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"];
1824
- buildTime = [[NSString stringWithContentsOfFile:buildTimePath encoding:NSUTF8StringEncoding error:nil]
1825
- 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;
1826
2026
  });
1827
2027
  return buildTime;
1828
2028
  #endif
@@ -1840,6 +2040,34 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1840
2040
 
1841
2041
  #pragma mark - native cold-start check orchestration
1842
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
+
1843
2071
  // Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns
1844
2072
  // the response body on 2xx, nil on any failure. The semaphore timeout is a
1845
2073
  // backstop over the request's own timeoutInterval.
@@ -1859,25 +2087,30 @@ static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
1859
2087
  }
1860
2088
  dispatch_semaphore_t sem = dispatch_semaphore_create(0);
1861
2089
  __block NSString *result = nil;
1862
- 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
1863
2098
  completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
1864
2099
  NSHTTPURLResponse *httpResponse =
1865
2100
  [response isKindOfClass:[NSHTTPURLResponse class]]
1866
2101
  ? (NSHTTPURLResponse *)response
1867
2102
  : nil;
1868
2103
  NSInteger status = httpResponse.statusCode;
1869
- // The shared session follows redirects; an https endpoint that
1870
- // ended up on plaintext http is a failed endpoint, same as the
1871
- // artifact download.
1872
- BOOL downgraded = [[url.scheme lowercaseString] isEqualToString:@"https"]
1873
- && [[httpResponse.URL.scheme lowercaseString] isEqualToString:@"http"];
1874
- if (error == nil && httpResponse != nil && !downgraded && status >= 200 &&
1875
- status < 300 && data != nil) {
2104
+ if (error == nil && httpResponse != nil && status >= 200 && status < 300
2105
+ && data != nil) {
1876
2106
  result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
1877
2107
  }
1878
2108
  dispatch_semaphore_signal(sem);
1879
2109
  }];
1880
2110
  [task resume];
2111
+ // The session retains its delegate until invalidated: let this one task
2112
+ // settle, then release everything.
2113
+ [session finishTasksAndInvalidate];
1881
2114
  if (dispatch_semaphore_wait(
1882
2115
  sem, dispatch_time(DISPATCH_TIME_NOW,
1883
2116
  (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) {
@@ -2144,8 +2377,11 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2144
2377
  input.Set("cInfo", cInfo);
2145
2378
  input.Set("supportedDiffVersion",
2146
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.
2147
2382
  input.Set("bundleHash",
2148
- flowjson::Value::String(PushyToStdString(PushyBundleHashSync())));
2383
+ flowjson::Value::String(PushyToStdString(
2384
+ PushyBundleHashSync(!pushyCrashRescueActive.load()))));
2149
2385
 
2150
2386
  std::string bodyJson =
2151
2387
  flowjson::Stringify(updateflow::BuildCheckRequestBody(input));