react-native-update 10.53.0 → 10.54.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 (106) hide show
  1. package/README-CN.md +31 -31
  2. package/README.md +31 -31
  3. package/android/jni/HDiffPatch/.travis.yml +33 -0
  4. package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  5. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  6. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  7. package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  8. package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  9. package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  10. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  11. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  12. package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  13. package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  14. package/android/jni/lzma/DOC/7zC.txt +187 -0
  15. package/android/jni/lzma/DOC/7zFormat.txt +469 -0
  16. package/android/jni/lzma/DOC/Methods.txt +173 -0
  17. package/android/jni/lzma/DOC/installer.txt +166 -0
  18. package/android/jni/lzma/DOC/lzma-history.txt +446 -0
  19. package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
  20. package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
  21. package/android/jni/lzma/DOC/lzma.txt +328 -0
  22. package/android/jni/lzma/bin/7zS2.sfx +0 -0
  23. package/android/jni/lzma/bin/7zS2con.sfx +0 -0
  24. package/android/jni/lzma/bin/7zSD.sfx +0 -0
  25. package/android/jni/lzma/bin/7zdec.exe +0 -0
  26. package/android/jni/lzma/bin/7zr.exe +0 -0
  27. package/android/jni/lzma/bin/installer/config.txt +5 -0
  28. package/android/jni/lzma/bin/installer/cr.bat +5 -0
  29. package/android/jni/lzma/bin/lzma.exe +0 -0
  30. package/android/jni/lzma/bin/x64/7zr.exe +0 -0
  31. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  32. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  33. package/android/lib/x86/librnupdate.so +0 -0
  34. package/android/lib/x86_64/librnupdate.so +0 -0
  35. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +62 -30
  36. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +44 -0
  37. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +124 -40
  38. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +125 -0
  39. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +43 -11
  40. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +2 -0
  41. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +90 -0
  42. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +79 -19
  43. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +21 -0
  44. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -17
  45. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +1 -0
  46. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  47. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  48. package/cpp/patch_core/archive_limits.h +38 -0
  49. package/cpp/patch_core/install_record.h +36 -0
  50. package/cpp/update_flow_core/flow_json.h +1 -0
  51. package/cpp/update_flow_core/update_flow_core.cpp +41 -7
  52. package/cpp/update_flow_core/update_flow_core.h +9 -0
  53. package/cpp/update_flow_core/update_flow_jni.cpp +12 -0
  54. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +2 -0
  55. package/harmony/pushy/src/main/cpp/pushy.cpp +37 -0
  56. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +83 -0
  57. package/harmony/pushy/src/main/ets/DownloadTask.ts +126 -30
  58. package/harmony/pushy/src/main/ets/InstallRecord.ts +129 -0
  59. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +24 -2
  60. package/harmony/pushy/src/main/ets/NativePatchCore.ts +5 -0
  61. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +12 -0
  62. package/harmony/pushy/src/main/ets/UpdateContext.ts +78 -15
  63. package/harmony/pushy.har +0 -0
  64. package/ios/RCTPushy/RCTPushy.mm +384 -25
  65. package/ios/RCTPushy/RCTPushyDownloader.mm +32 -0
  66. package/package.json +25 -2
  67. package/src/NativePushy.ts +13 -0
  68. package/src/client.ts +160 -43
  69. package/src/core.ts +17 -3
  70. package/src/error.ts +3 -0
  71. package/src/index.ts +8 -0
  72. package/src/locales/en.ts +3 -0
  73. package/src/locales/zh.ts +2 -0
  74. package/src/metadata.ts +166 -0
  75. package/src/provider.tsx +52 -17
  76. package/src/type.ts +22 -0
  77. package/src/updateFlowCore.ts +95 -4
  78. package/src/utils.ts +31 -19
  79. package/.gitmodules +0 -6
  80. package/BUNDLEHASH_MIGRATION.md +0 -355
  81. package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
  82. package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
  83. package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
  84. package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
  85. package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
  86. package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
  87. package/biome.json +0 -59
  88. package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
  89. package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
  90. package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
  91. package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
  92. package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
  93. package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
  94. package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
  95. package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
  96. package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
  97. package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
  98. package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
  99. package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
  100. package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
  101. package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
  102. package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
  103. package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
  104. package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
  105. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
  106. package/scripts/check-expo-version.js +0 -92
@@ -1,9 +1,11 @@
1
1
  #import "RCTPushy.h"
2
2
  #import "RCTPushyDownloader.h"
3
3
  #import "ZipArchive.h"
4
+ #include "../../cpp/patch_core/archive_limits.h"
4
5
  #include "../../cpp/patch_core/archive_patch_core.h"
5
6
  #include "../../cpp/patch_core/digest.h"
6
7
  #include "../../cpp/patch_core/hbc_transform_wire.h"
8
+ #include "../../cpp/patch_core/install_record.h"
7
9
  #include "../../cpp/patch_core/error_codes.h"
8
10
  #include "../../cpp/patch_core/patch_core.h"
9
11
  #include "../../cpp/patch_core/state_core.h"
@@ -57,7 +59,8 @@ static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
57
59
  static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs";
58
60
  static NSString * const SOURCE_PATCH_NAME = @"__diff.json";
59
61
  static NSString * const BUNDLE_PATCH_NAME = @"index.bundlejs.patch";
60
- static NSString * const VERSION_COMPLETE_FILE_NAME = @".pushy-complete";
62
+ #define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete"
63
+ static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL;
61
64
 
62
65
  // error def — messages are human-readable; the stable cross-platform codes
63
66
  // live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey.
@@ -79,13 +82,177 @@ static NSTimeInterval PushyMonotonicNow(void) {
79
82
  return [NSProcessInfo processInfo].systemUptime;
80
83
  }
81
84
 
82
- static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir) {
85
+ static std::string PushyToStdString(NSString *value);
86
+
87
+ static NSString *PushyStagingDirForVersionDir(NSString *versionDir) {
88
+ return [versionDir stringByAppendingString:@(pushy::install_record::kStagingSuffix)];
89
+ }
90
+
91
+ // Parsed completion record (cpp/patch_core/install_record.h): nil when the
92
+ // file is absent or malformed; an empty dictionary for the legacy empty
93
+ // marker written by SDK < 10.53.
94
+ static NSDictionary *PushyReadInstallRecord(NSString *versionDir) {
95
+ NSString *path = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
96
+ NSData *data = [NSData dataWithContentsOfFile:path];
97
+ if (data == nil) {
98
+ return nil;
99
+ }
100
+ if (data.length == 0) {
101
+ return @{};
102
+ }
103
+ if (data.length > 64 * 1024) {
104
+ return nil;
105
+ }
106
+ id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
107
+ return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil;
108
+ }
109
+
110
+ // Presence check (launch / dedup paths, no digest work): bundle present and
111
+ // the record exists and, unless legacy-empty, names this version.
112
+ static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) {
83
113
  NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
84
- NSString *markerPath = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
85
- return [[NSFileManager defaultManager] fileExistsAtPath:bundlePath]
86
- && [[NSFileManager defaultManager] fileExistsAtPath:markerPath];
114
+ if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) {
115
+ return NO;
116
+ }
117
+ NSDictionary *record = PushyReadInstallRecord(versionDir);
118
+ if (record == nil) {
119
+ return NO;
120
+ }
121
+ if (record.count == 0) {
122
+ return YES;
123
+ }
124
+ return [record[@"schema"] isKindOfClass:[NSNumber class]]
125
+ && [record[@"schema"] intValue] == pushy::install_record::kSchema
126
+ && [record[@"versionHash"] isKindOfClass:[NSString class]]
127
+ && [record[@"versionHash"] isEqualToString:hash];
128
+ }
129
+
130
+ // Activation check: re-hashes the bundle when the record carries a digest.
131
+ // Returns nil when the directory may be activated, the reason otherwise.
132
+ static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash) {
133
+ NSDictionary *record = PushyReadInstallRecord(versionDir);
134
+ if (record == nil) {
135
+ return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash];
136
+ }
137
+ if (record.count == 0) {
138
+ return nil;
139
+ }
140
+ if (![record[@"schema"] isKindOfClass:[NSNumber class]]
141
+ || [record[@"schema"] intValue] != pushy::install_record::kSchema
142
+ || ![record[@"versionHash"] isKindOfClass:[NSString class]]
143
+ || ![record[@"versionHash"] isEqualToString:hash]) {
144
+ return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash];
145
+ }
146
+ NSString *expected = record[@"bundleSha256"];
147
+ if (![expected isKindOfClass:[NSString class]] || expected.length == 0) {
148
+ return nil;
149
+ }
150
+ std::string actual = pushy::digest::Sha256File(
151
+ PushyToStdString([versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]));
152
+ if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) {
153
+ return nil;
154
+ }
155
+ return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash];
156
+ }
157
+
158
+ // SHA-256 of each in-flight download's archive, keyed by hash; consumed by
159
+ // the completion record write. Guarded by @synchronized on the table.
160
+ static NSMutableDictionary<NSString *, NSString *> *PushyArtifactDigests(void) {
161
+ static NSMutableDictionary *table;
162
+ static dispatch_once_t once;
163
+ dispatch_once(&once, ^{
164
+ table = [NSMutableDictionary dictionary];
165
+ });
166
+ return table;
167
+ }
168
+
169
+ static NSError *PushyErrorWithCode(const char *code, NSString *message);
170
+
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.
192
+ static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
193
+ long long free = PushyFreeDiskSpaceForPath(path);
194
+ if (free < 0) {
195
+ return nil;
196
+ }
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;
205
+ }
206
+
207
+ // 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.
211
+ @interface PushyUnzipGuard : NSObject <SSZipArchiveDelegate>
212
+ @property (nonatomic, copy) NSString *violation;
213
+ @property (nonatomic, assign) long long totalUncompressed;
214
+ @end
215
+
216
+ @implementation PushyUnzipGuard
217
+
218
+ - (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo {
219
+ if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) {
220
+ self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)",
221
+ (unsigned long)zipInfo.number_entry];
222
+ }
87
223
  }
88
224
 
225
+ - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex
226
+ totalFiles:(NSInteger)totalFiles
227
+ archivePath:(NSString *)archivePath
228
+ fileInfo:(unz_file_info)fileInfo {
229
+ if (self.violation != nil) {
230
+ return NO;
231
+ }
232
+ long long size = (long long)fileInfo.uncompressed_size;
233
+ long long compressed = (long long)fileInfo.compressed_size;
234
+ if (size > pushy::archive_limits::kMaxEntryBytes) {
235
+ self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)",
236
+ (long)fileIndex, size];
237
+ return NO;
238
+ }
239
+ self.totalUncompressed += size;
240
+ if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) {
241
+ self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes",
242
+ pushy::archive_limits::kMaxTotalUncompressedBytes];
243
+ return NO;
244
+ }
245
+ if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes
246
+ && size / compressed > pushy::archive_limits::kMaxCompressionRatio) {
247
+ self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high",
248
+ (long)fileIndex];
249
+ return NO;
250
+ }
251
+ return YES;
252
+ }
253
+
254
+ @end
255
+
89
256
  static NSError *PushyDownloadDeadlineExpiredError(void) {
90
257
  return [NSError errorWithDomain:PushyErrorDomain
91
258
  code:-1
@@ -475,6 +642,7 @@ static void PushySwitchVersionLocked(NSString *hash) {
475
642
  // launch. Decisions come from cpp/update_flow_core; this class is IO glue.
476
643
  @interface RCTPushyOrchestrator : NSObject
477
644
  + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion;
645
+ + (void)markJsCheckCompleted:(NSString *)config;
478
646
  + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime;
479
647
  + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime;
480
648
  + (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime;
@@ -510,6 +678,10 @@ static uint64_t pushyUnactivatedGeneration = 0;
510
678
  static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL;
511
679
  // ≈ process start: scheduleFromColdStart runs during the first bundleURL.
512
680
  static NSTimeInterval pushyProcessAnchorUptime = 0;
681
+ // Config JSON for which JS reported a completed check in this process
682
+ // (markJsCheckCompleted). Process-scoped by design. Guarded by
683
+ // @synchronized (RCTPushyOrchestrator class).
684
+ static NSString *pushyJsCompletedConfig = nil;
513
685
 
514
686
  static const NSTimeInterval kPushyRescueTriggerUptime = 60;
515
687
  static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10;
@@ -743,6 +915,16 @@ RCT_EXPORT_MODULE(RCTPushy);
743
915
  ret[@"currentVersion"] = currentVersion;
744
916
  if (currentVersion != nil) {
745
917
  ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)];
918
+ // bundleSha256 from the install record, for crash-report attribution.
919
+ NSString *bundleSha256 = @"";
920
+ if (PushyIsSafePathComponent(currentVersion)) {
921
+ NSDictionary *record = PushyReadInstallRecord(
922
+ [[RCTPushy downloadDir] stringByAppendingPathComponent:currentVersion]);
923
+ if ([record[@"bundleSha256"] isKindOfClass:[NSString class]]) {
924
+ bundleSha256 = record[@"bundleSha256"];
925
+ }
926
+ }
927
+ ret[@"currentBundleSha256"] = bundleSha256;
746
928
  }
747
929
 
748
930
  if (ret[@"isFirstTime"]) {
@@ -824,6 +1006,18 @@ RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve
824
1006
  resolve([PushyDefaults() stringForKey:keyNativeCheckCache] ?: @"");
825
1007
  }
826
1008
 
1009
+ RCT_EXPORT_METHOD(markJsCheckCompleted:(NSString *)config
1010
+ resolver:(RCTPromiseResolveBlock)resolve
1011
+ rejecter:(RCTPromiseRejectBlock)reject)
1012
+ {
1013
+ if (PushyStringIsBlank(config)) {
1014
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
1015
+ return;
1016
+ }
1017
+ [RCTPushyOrchestrator markJsCheckCompleted:config];
1018
+ resolve(@true);
1019
+ }
1020
+
827
1021
  RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
828
1022
  value:(NSString *)value resolver:(RCTPromiseResolveBlock)resolve
829
1023
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -1094,7 +1288,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1094
1288
  }
1095
1289
 
1096
1290
  NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
1097
- if (PushyHasCompletedVersionAtPath(unzipDir)) {
1291
+ if (PushyHasCompletedVersionAtPath(unzipDir, hash)) {
1098
1292
  callback(nil);
1099
1293
  return;
1100
1294
  }
@@ -1142,20 +1336,59 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1142
1336
  // completion marker write has run on the process-wide file queue.
1143
1337
  dispatch_async(self->_fileQueue, ^{
1144
1338
  NSError *finalError = error;
1339
+ NSFileManager *fileManager = [NSFileManager defaultManager];
1340
+ NSString *staging = PushyStagingDirForVersionDir(unzipDir);
1341
+ NSString *artifactSha256 = nil;
1342
+ @synchronized (PushyArtifactDigests()) {
1343
+ artifactSha256 = PushyArtifactDigests()[hash];
1344
+ [PushyArtifactDigests() removeObjectForKey:hash];
1345
+ }
1145
1346
  if (finalError == nil) {
1146
- NSString *marker = [unzipDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
1147
- NSError *markerError = nil;
1148
- BOOL marked = [[NSData data] writeToFile:marker
1149
- options:NSDataWritingAtomic
1150
- error:&markerError];
1151
- if (!marked) {
1152
- finalError = markerError ?: PushyErrorWithCode(
1153
- pushy::error_codes::kFileOperationFailed,
1154
- @"failed to mark completed update");
1347
+ // Two-phase install (cpp/patch_core/install_record.h): the
1348
+ // completion record with the final bundle's digest goes into
1349
+ // the staging directory, which is then renamed over the
1350
+ // version directory in one atomic step.
1351
+ NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1352
+ if (![fileManager fileExistsAtPath:bundlePath]) {
1353
+ finalError = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1354
+ @"bundle missing after install");
1355
+ } else {
1356
+ std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath));
1357
+ NSMutableDictionary *record = [NSMutableDictionary dictionary];
1358
+ record[@"schema"] = @(pushy::install_record::kSchema);
1359
+ record[@"versionHash"] = hash;
1360
+ if (!bundleSha256.empty()) {
1361
+ record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()];
1362
+ }
1363
+ if (artifactSha256.length > 0) {
1364
+ record[@"artifactSha256"] = artifactSha256;
1365
+ }
1366
+ NSError *recordError = nil;
1367
+ NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError];
1368
+ NSString *marker = [staging stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
1369
+ if (recordData == nil || ![recordData writeToFile:marker
1370
+ options:NSDataWritingAtomic
1371
+ error:&recordError]) {
1372
+ finalError = recordError ?: PushyErrorWithCode(
1373
+ pushy::error_codes::kFileOperationFailed,
1374
+ @"failed to write completion record");
1375
+ } else {
1376
+ if ([fileManager fileExistsAtPath:unzipDir]) {
1377
+ [fileManager removeItemAtPath:unzipDir error:nil];
1378
+ }
1379
+ NSError *moveError = nil;
1380
+ if (![fileManager moveItemAtPath:staging toPath:unzipDir error:&moveError]) {
1381
+ finalError = moveError ?: PushyErrorWithCode(
1382
+ pushy::error_codes::kFileOperationFailed,
1383
+ @"failed to promote staging directory");
1384
+ }
1385
+ }
1155
1386
  }
1156
1387
  }
1157
1388
  if (finalError != nil) {
1158
- [[NSFileManager defaultManager] removeItemAtPath:unzipDir error:nil];
1389
+ // Only the staging directory is ours to drop: the final
1390
+ // version directory is never touched by a failed install.
1391
+ [fileManager removeItemAtPath:staging error:nil];
1159
1392
  }
1160
1393
  PushyFinishDownload(hash, finalError);
1161
1394
  });
@@ -1192,7 +1425,17 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1192
1425
  callback:(void (^)(NSError *error))callback
1193
1426
  {
1194
1427
  RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath);
1195
- NSString *unzipFilePath = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1428
+ // Everything lands in <hash>.staging; the completion block promotes it.
1429
+ NSString *unzipFilePath = PushyStagingDirForVersionDir(
1430
+ [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]);
1431
+ dispatch_async(_fileQueue, ^{
1432
+ // Archive digest for the completion record, taken before the unzip
1433
+ // consumes the file (same serial queue, so it runs first).
1434
+ std::string digest = pushy::digest::Sha256File(PushyToStdString(zipFilePath));
1435
+ @synchronized (PushyArtifactDigests()) {
1436
+ PushyArtifactDigests()[hash] = [NSString stringWithUTF8String:digest.c_str()];
1437
+ }
1438
+ });
1196
1439
  [self unzipFileAtPath:zipFilePath
1197
1440
  toDestination:unzipFilePath
1198
1441
  completionHandler:^(NSError *error) {
@@ -1240,11 +1483,19 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1240
1483
  source:(NSString *)sourceOrigin
1241
1484
  callback:(void (^)(NSError *error))callback
1242
1485
  {
1243
- NSString *unzipDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1486
+ // Patch work happens in the staging directory (two-phase install).
1487
+ NSString *unzipDir = PushyStagingDirForVersionDir(
1488
+ [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]);
1244
1489
  NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME];
1245
1490
  NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME];
1246
1491
 
1247
1492
  NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1493
+ long long manifestBytes = PushyFileSizeAtPath(sourcePatch);
1494
+ if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) {
1495
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1496
+ [NSString stringWithFormat:@"patch manifest too large: %lld bytes", manifestBytes]));
1497
+ return;
1498
+ }
1248
1499
  NSData *data = [NSData dataWithContentsOfFile:sourcePatch];
1249
1500
  if (data == nil) {
1250
1501
  callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"missing patch manifest"));
@@ -1336,9 +1587,43 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1336
1587
  return NO;
1337
1588
  }
1338
1589
 
1590
+ __block NSError *switchError = nil;
1339
1591
  PushyWithStateLock(^{
1592
+ // Same rule as Android: only a version this SDK recorded as completely
1593
+ // installed (bundle + marker) may be activated. Versions activated
1594
+ // before markers existed are grandfathered through current/last
1595
+ // state; any other markerless directory may be a crash-left partial
1596
+ // install or a directory something else put there.
1597
+ NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1598
+ pushy::state::State state = PushyStateFromDefaults(PushyDefaults());
1599
+ std::string hashStd = PushyToStdString(hash);
1600
+ BOOL legacyActivated = state.current_version == hashStd || state.last_version == hashStd;
1601
+ if (!PushyHasCompletedVersionAtPath(versionDir, hash) && !legacyActivated) {
1602
+ BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath:
1603
+ [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]];
1604
+ switchError = PushyErrorWithCode(
1605
+ pushy::error_codes::kSwitchVersionFailed,
1606
+ [NSString stringWithFormat:@"Bundle version %@ %@", hash,
1607
+ hasBundle ? @"is incomplete." : @"not found."]);
1608
+ return;
1609
+ }
1610
+ if (!legacyActivated) {
1611
+ // The record's bundle digest must match the bytes on disk before
1612
+ // the next launch is pointed at them.
1613
+ NSString *reason = PushyVerifyInstallForActivation(versionDir, hash);
1614
+ if (reason != nil) {
1615
+ switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason);
1616
+ return;
1617
+ }
1618
+ }
1340
1619
  PushySwitchVersionLocked(hash);
1341
1620
  });
1621
+ if (switchError != nil) {
1622
+ if (error != NULL) {
1623
+ *error = switchError;
1624
+ }
1625
+ return NO;
1626
+ }
1342
1627
  return YES;
1343
1628
  }
1344
1629
 
@@ -1397,8 +1682,37 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1397
1682
  [fileManager removeItemAtPath:destination error:nil];
1398
1683
  }
1399
1684
 
1685
+ // 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);
1691
+ NSError *preflight = nil;
1692
+ if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) {
1693
+ preflight = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
1694
+ [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]);
1695
+ } else {
1696
+ preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2);
1697
+ }
1698
+ if (preflight != nil) {
1699
+ [fileManager removeItemAtPath:path error:nil];
1700
+ [fileManager removeItemAtPath:[path stringByAppendingString:@".resume"] error:nil];
1701
+ if (completionHandler != nil) {
1702
+ completionHandler(preflight);
1703
+ }
1704
+ return;
1705
+ }
1706
+
1707
+ PushyUnzipGuard *guard = [PushyUnzipGuard new];
1400
1708
  [SSZipArchive unzipFileAtPath:path
1401
1709
  toDestination:destination
1710
+ preserveAttributes:YES
1711
+ overwrite:YES
1712
+ nestedZipLevel:0
1713
+ password:nil
1714
+ error:nil
1715
+ delegate:guard
1402
1716
  progressHandler:nil
1403
1717
  completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) {
1404
1718
  [fileManager removeItemAtPath:archivePath error:nil];
@@ -1412,8 +1726,20 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1412
1726
  }
1413
1727
 
1414
1728
  NSError *unzipError = error;
1415
- if (!succeeded && unzipError == nil) {
1729
+ if (guard.violation != nil) {
1730
+ unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, guard.violation);
1731
+ } else if (!succeeded && unzipError == nil) {
1416
1732
  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);
1417
1743
  } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) {
1418
1744
  // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has
1419
1745
  // no stable code; without one, downloadUpdate's fallback would
@@ -1540,7 +1866,12 @@ static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
1540
1866
  ? (NSHTTPURLResponse *)response
1541
1867
  : nil;
1542
1868
  NSInteger status = httpResponse.statusCode;
1543
- if (error == nil && httpResponse != nil && status >= 200 &&
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 &&
1544
1875
  status < 300 && data != nil) {
1545
1876
  result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
1546
1877
  }
@@ -1567,9 +1898,9 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1567
1898
  if (responseText == nil) {
1568
1899
  return NO;
1569
1900
  }
1570
- bool ok = false;
1571
- flowjson::Value parsed = flowjson::Parse(PushyToStdString(responseText), &ok);
1572
- return ok && parsed.IsObject();
1901
+ // Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with
1902
+ // `{"error": ...}` is a failed endpoint, not a verdict.
1903
+ return updateflow::IsValidCheckResponse(PushyToStdString(responseText)) ? YES : NO;
1573
1904
  }
1574
1905
 
1575
1906
  @implementation RCTPushyOrchestrator
@@ -1595,12 +1926,40 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1595
1926
  [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5;
1596
1927
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC),
1597
1928
  dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
1929
+ if ([self isJsCheckCompleted]) {
1930
+ // Not consuming the round: a later crash rescue may still
1931
+ // need it.
1932
+ NSLog(@"RCTPushy -- native check skipped: JS check completed in this process");
1933
+ return;
1934
+ }
1598
1935
  [self startRoundWithDeadline:0];
1599
1936
  });
1600
1937
  });
1601
1938
  #endif
1602
1939
  }
1603
1940
 
1941
+ + (void)markJsCheckCompleted:(NSString *)config {
1942
+ @synchronized (RCTPushyOrchestrator.class) {
1943
+ pushyJsCompletedConfig = [config copy];
1944
+ }
1945
+ }
1946
+
1947
+ // True when JS already obtained a valid response in this process for the
1948
+ // exact config the native round would use (§10.3): the delayed round is
1949
+ // then a duplicate request. Only the scheduled round asks — the crash-rescue
1950
+ // path still runs, JS is dead by then.
1951
+ + (BOOL)isJsCheckCompleted {
1952
+ NSString *jsConfig;
1953
+ @synchronized (RCTPushyOrchestrator.class) {
1954
+ jsConfig = pushyJsCompletedConfig;
1955
+ }
1956
+ if (jsConfig == nil) {
1957
+ return NO;
1958
+ }
1959
+ NSString *persisted = [PushyDefaults() stringForKey:keyNativeConfig];
1960
+ return persisted != nil && [persisted isEqualToString:jsConfig];
1961
+ }
1962
+
1604
1963
  // Runs the process's single round on the calling thread if nobody has
1605
1964
  // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and
1606
1965
  // download phase to the remaining budget.
@@ -1656,7 +2015,7 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1656
2015
  // that are gone (e.g. wiped by a reset whose generation bump we would
1657
2016
  // catch below anyway, or by cleanup).
1658
2017
  NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1659
- if (!PushyHasCompletedVersionAtPath(versionDir)) {
2018
+ if (!PushyHasCompletedVersionAtPath(versionDir, hash)) {
1660
2019
  NSLog(@"RCTPushy -- crash rescue: version %@ no longer on disk, dropping activation", hash);
1661
2020
  return;
1662
2021
  }
@@ -1827,7 +2186,7 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1827
2186
  }
1828
2187
 
1829
2188
  NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1830
- BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir);
2189
+ BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash);
1831
2190
  if (!downloaded) {
1832
2191
  downloaded = [self performAttempts:decision.Get("attempts")
1833
2192
  hash:hash
@@ -273,6 +273,31 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
273
273
 
274
274
  #pragma mark - session delegate
275
275
 
276
+ // An https artifact URL must stay on https through every redirect: the
277
+ // package is the supply-chain boundary and TLS is what authenticates it.
278
+ // Returning nil delivers the 3xx itself as the response, which the status
279
+ // check below then rejects with the recorded reason.
280
+ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
281
+ willPerformHTTPRedirection:(NSHTTPURLResponse *)response
282
+ newRequest:(NSURLRequest *)request
283
+ completionHandler:(void (^)(NSURLRequest *))completionHandler
284
+ {
285
+ if (session != self.session) {
286
+ completionHandler(nil);
287
+ return;
288
+ }
289
+ NSString *originalScheme = [[NSURL URLWithString:self.urlString].scheme lowercaseString];
290
+ NSString *nextScheme = [request.URL.scheme lowercaseString];
291
+ if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) {
292
+ [self failWithDescription:[NSString stringWithFormat:
293
+ @"https download redirected to plaintext http: %@", request.URL.absoluteString]
294
+ code:-1];
295
+ completionHandler(nil);
296
+ return;
297
+ }
298
+ completionHandler(request);
299
+ }
300
+
276
301
  - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
277
302
  didReceiveResponse:(NSURLResponse *)response
278
303
  completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
@@ -286,6 +311,13 @@ didReceiveResponse:(NSURLResponse *)response
286
311
  [response isKindOfClass:[NSHTTPURLResponse class]] ? (NSHTTPURLResponse *)response : nil;
287
312
  NSInteger statusCode = httpResponse.statusCode;
288
313
 
314
+ if (self.fileError != nil) {
315
+ // A refused redirect (see willPerformHTTPRedirection) already
316
+ // recorded the real reason; the 3xx being delivered here is not it.
317
+ completionHandler(NSURLSessionResponseCancel);
318
+ [self completeWithError:self.fileError];
319
+ return;
320
+ }
289
321
  if (statusCode == 416) {
290
322
  long long knownTotal = [self.resumeMeta[@"total"] longLongValue];
291
323
  completionHandler(NSURLSessionResponseCancel);
package/package.json CHANGED
@@ -1,13 +1,36 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.53.0",
3
+ "version": "10.54.0",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "!src/__tests__",
10
+ "android/src",
11
+ "android/lib",
12
+ "android/jni",
13
+ "!android/jni/HDiffPatch/test",
14
+ "android/build.gradle",
15
+ "android/proguard.pro",
16
+ "ios",
17
+ "cpp/patch_core",
18
+ "cpp/update_flow_core",
19
+ "!cpp/**/tests",
20
+ "harmony/pushy.har",
21
+ "harmony/hvigor-plugin.ts",
22
+ "harmony/pushy/src",
23
+ "!harmony/pushy/src/test",
24
+ "react-native-update.podspec",
25
+ "react-native.config.js",
26
+ "expo-module.config.json",
27
+ "README.md",
28
+ "README-CN.md",
29
+ "LICENSE"
30
+ ],
7
31
  "scripts": {
8
32
  "prepublishOnly": "NODE_ENV=production bun scripts/prepublish.ts",
9
33
  "publish:local": "SKIP_NATIVE_BUILD=1 npm publish",
10
- "postinstall": "node scripts/check-expo-version.js",
11
34
  "prepack": "bun submodule && bun lint",
12
35
  "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js",
13
36
  "lint:harmony": "node scripts/check-harmony-types.js",