react-native-update 10.44.0 → 10.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,6 @@ Hdp_Files := \
20
20
  lzma/C/Lzma2Dec.c
21
21
 
22
22
  LOCAL_SRC_FILES := \
23
- DownloadTask.c \
24
23
  ../../cpp/patch_core/archive_patch_core.cpp \
25
24
  ../../cpp/patch_core/patch_core.cpp \
26
25
  ../../cpp/patch_core/patch_core_android.cpp \
Binary file
Binary file
@@ -33,7 +33,16 @@ class DownloadTask implements Runnable {
33
33
  // events on percentage change, so throttle by bytes to avoid flooding the
34
34
  // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events).
35
35
  private static final long PROGRESS_BYTES_THRESHOLD = 256 * 1024;
36
- private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();
36
+ // Explicit timeouts: the default client has no call timeout, so a
37
+ // slow-dripping connection could occupy the single-threaded download
38
+ // executor indefinitely and starve queued tasks. The call timeout is a
39
+ // generous upper bound sized for large full-package downloads.
40
+ private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
41
+ .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
42
+ .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
43
+ .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
44
+ .callTimeout(10, java.util.concurrent.TimeUnit.MINUTES)
45
+ .build();
37
46
 
38
47
  static {
39
48
  NativeUpdateCore.ensureLoaded();
@@ -214,7 +214,7 @@ public class UpdateContext {
214
214
 
215
215
  public void switchVersion(String hash) {
216
216
  if (!new File(rootDir, hash+"/index.bundlejs").exists()) {
217
- throw new Error("Bundle version " + hash + " not found.");
217
+ throw new IllegalStateException("Bundle version " + hash + " not found.");
218
218
  }
219
219
  StateCoreResult currentState = getStateSnapshot();
220
220
  StateCoreResult nextState = runStateCore(
@@ -385,7 +385,11 @@ public class UpdateContext {
385
385
  return defaultAssetsUrl;
386
386
  }
387
387
 
388
- while (currentVersion != null) {
388
+ // Guard the rollback chain against cycles: a corrupted state returning
389
+ // an already-visited version would otherwise spin this loop forever on
390
+ // the main thread.
391
+ java.util.HashSet<String> visitedVersions = new java.util.HashSet<>();
392
+ while (currentVersion != null && visitedVersions.add(currentVersion)) {
389
393
  File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs");
390
394
  if (!bundleFile.exists()) {
391
395
  Log.e(TAG, "Bundle version " + currentVersion + " not found.");
@@ -24,7 +24,7 @@ final class UpdateEventEmitter {
24
24
 
25
25
  static void sendEvent(String eventName, WritableMap params) {
26
26
  ReactApplicationContext reactContext = getReactContext();
27
- if (reactContext == null || !reactContext.hasActiveCatalystInstance()) {
27
+ if (reactContext == null || !hasActiveInstance(reactContext)) {
28
28
  return;
29
29
  }
30
30
 
@@ -32,4 +32,16 @@ final class UpdateEventEmitter {
32
32
  .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
33
33
  .emit(eventName, params);
34
34
  }
35
+
36
+ @SuppressWarnings("deprecation")
37
+ private static boolean hasActiveInstance(ReactApplicationContext reactContext) {
38
+ try {
39
+ // hasActiveCatalystInstance() is always false in bridgeless mode, which
40
+ // silently drops every progress event on the new architecture.
41
+ return reactContext.hasActiveReactInstance();
42
+ } catch (NoSuchMethodError e) {
43
+ // RN < 0.68 has no hasActiveReactInstance(); fall back for old peers.
44
+ return reactContext.hasActiveCatalystInstance();
45
+ }
46
+ }
35
47
  }
@@ -8,7 +8,6 @@
8
8
  #include <sys/types.h>
9
9
  #include <unistd.h>
10
10
 
11
- #include <set>
12
11
  #include <vector>
13
12
 
14
13
  extern "C" {
@@ -17,6 +16,11 @@ extern "C" {
17
16
 
18
17
  namespace pushy {
19
18
  namespace patch {
19
+
20
+ namespace internal {
21
+ bool g_disable_hard_links = false;
22
+ } // namespace internal
23
+
20
24
  namespace {
21
25
 
22
26
  constexpr size_t kCopyBufferSize = 16 * 1024;
@@ -202,6 +206,17 @@ Status CopyFile(const std::string& from, const std::string& to, bool overwrite)
202
206
  }
203
207
  }
204
208
 
209
+ // Prefer a hard link over copying bytes: unchanged files between versions
210
+ // are identical, so linking is O(1) per file, writes nothing to flash, and
211
+ // shares disk blocks. Version directories are immutable once created (patch
212
+ // outputs are always written as new files), so sharing the inode with the
213
+ // source is safe. Fall back to a byte copy whenever linking is not possible
214
+ // (cross-device source such as the installed app bundle, EPERM, EMLINK, or
215
+ // filesystems without hard-link support).
216
+ if (!internal::g_disable_hard_links && link(from.c_str(), to.c_str()) == 0) {
217
+ return Status::Ok();
218
+ }
219
+
205
220
  FILE* source = std::fopen(from.c_str(), "rb");
206
221
  if (!source) {
207
222
  return MakeErrnoStatus("Failed to open source file " + from);
@@ -64,5 +64,11 @@ Status CleanupOldEntries(
64
64
 
65
65
  bool IsSafeRelativePath(const std::string& path);
66
66
 
67
+ namespace internal {
68
+ // Test-only escape hatch: forces file copies to take the byte-copy fallback
69
+ // instead of hard-linking, so tests can cover both paths on one filesystem.
70
+ extern bool g_disable_hard_links;
71
+ } // namespace internal
72
+
67
73
  } // namespace patch
68
74
  } // namespace pushy
@@ -175,6 +175,81 @@ void TestApplyPatchFromFileSourceMergesAndCopies() {
175
175
  Expect(!Exists(JoinPath(target, "assets/delete.txt")), "deleted asset should not be copied");
176
176
  }
177
177
 
178
+ void TestApplyPatchMergeHardLinksUnchangedFiles() {
179
+ TempDir temp;
180
+ const std::string source = JoinPath(temp.path, "origin");
181
+ const std::string target = JoinPath(temp.path, "target");
182
+ const std::string patch = JoinPath(temp.path, "bundle.patch");
183
+
184
+ WriteFile(JoinPath(source, "index.bundlejs"), "old bundle");
185
+ WriteFile(JoinPath(source, "assets/keep.txt"), "keep");
186
+ WriteFile(patch, "unused patch");
187
+
188
+ FakeBundlePatcher patcher("patched bundle");
189
+ FileSourcePatchOptions options;
190
+ options.source_root = source;
191
+ options.target_root = target;
192
+ options.origin_bundle_path = JoinPath(source, "index.bundlejs");
193
+ options.bundle_patch_path = patch;
194
+ options.bundle_output_path = JoinPath(target, "index.bundlejs");
195
+ options.manifest.copies.push_back(CopyOperation{"assets/keep.txt", "assets/copied.txt"});
196
+
197
+ Status status = ApplyPatchFromFileSource(options, patcher);
198
+ Expect(status.ok, status.message);
199
+
200
+ // Unchanged files must share the source inode (hard link) instead of being
201
+ // byte-copied: same filesystem, so the fast path has to kick in.
202
+ struct stat source_stat;
203
+ struct stat merged_stat;
204
+ struct stat copied_stat;
205
+ Expect(stat(JoinPath(source, "assets/keep.txt").c_str(), &source_stat) == 0, "stat source");
206
+ Expect(stat(JoinPath(target, "assets/keep.txt").c_str(), &merged_stat) == 0, "stat merged");
207
+ Expect(stat(JoinPath(target, "assets/copied.txt").c_str(), &copied_stat) == 0, "stat copied");
208
+ Expect(merged_stat.st_ino == source_stat.st_ino, "merged file should hard-link the source");
209
+ Expect(copied_stat.st_ino == source_stat.st_ino, "manifest copy should hard-link the source");
210
+ Expect(source_stat.st_nlink >= 3, "source should have three names");
211
+ ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "merged content mismatch");
212
+
213
+ // The patched bundle must be a fresh file, never a link into the source dir.
214
+ struct stat bundle_stat;
215
+ struct stat origin_stat;
216
+ Expect(stat(JoinPath(target, "index.bundlejs").c_str(), &bundle_stat) == 0, "stat bundle");
217
+ Expect(stat(JoinPath(source, "index.bundlejs").c_str(), &origin_stat) == 0, "stat origin bundle");
218
+ Expect(bundle_stat.st_ino != origin_stat.st_ino, "patched bundle must not link the origin");
219
+ }
220
+
221
+ void TestApplyPatchMergeFallsBackToByteCopy() {
222
+ TempDir temp;
223
+ const std::string source = JoinPath(temp.path, "origin");
224
+ const std::string target = JoinPath(temp.path, "target");
225
+ const std::string patch = JoinPath(temp.path, "bundle.patch");
226
+
227
+ WriteFile(JoinPath(source, "index.bundlejs"), "old bundle");
228
+ WriteFile(JoinPath(source, "assets/keep.txt"), "keep");
229
+ WriteFile(patch, "unused patch");
230
+
231
+ FakeBundlePatcher patcher("patched bundle");
232
+ FileSourcePatchOptions options;
233
+ options.source_root = source;
234
+ options.target_root = target;
235
+ options.origin_bundle_path = JoinPath(source, "index.bundlejs");
236
+ options.bundle_patch_path = patch;
237
+ options.bundle_output_path = JoinPath(target, "index.bundlejs");
238
+
239
+ pushy::patch::internal::g_disable_hard_links = true;
240
+ Status status = ApplyPatchFromFileSource(options, patcher);
241
+ pushy::patch::internal::g_disable_hard_links = false;
242
+ Expect(status.ok, status.message);
243
+
244
+ struct stat source_stat;
245
+ struct stat merged_stat;
246
+ Expect(stat(JoinPath(source, "assets/keep.txt").c_str(), &source_stat) == 0, "stat source");
247
+ Expect(stat(JoinPath(target, "assets/keep.txt").c_str(), &merged_stat) == 0, "stat merged");
248
+ Expect(merged_stat.st_ino != source_stat.st_ino, "fallback should produce an independent copy");
249
+ Expect(merged_stat.st_nlink == 1, "fallback copy should have a single name");
250
+ ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "fallback content mismatch");
251
+ }
252
+
178
253
  void TestApplyPatchFromFileSourceCanLimitMergeSubdir() {
179
254
  TempDir temp;
180
255
  const std::string source = JoinPath(temp.path, "origin");
@@ -537,6 +612,8 @@ void TestStateCoreSwitchToSameVersion() {
537
612
  int main() {
538
613
  const std::vector<std::pair<std::string, void (*)()>> tests = {
539
614
  {"ApplyPatchFromFileSourceMergesAndCopies", TestApplyPatchFromFileSourceMergesAndCopies},
615
+ {"ApplyPatchMergeHardLinksUnchangedFiles", TestApplyPatchMergeHardLinksUnchangedFiles},
616
+ {"ApplyPatchMergeFallsBackToByteCopy", TestApplyPatchMergeFallsBackToByteCopy},
540
617
  {"ApplyPatchFromFileSourceCanLimitMergeSubdir", TestApplyPatchFromFileSourceCanLimitMergeSubdir},
541
618
  {"ApplyPatchFromFileSourceRejectsUnsafePaths", TestApplyPatchFromFileSourceRejectsUnsafePaths},
542
619
  {"CleanupOldEntriesRemovesOnlyExpiredPaths", TestCleanupOldEntriesRemovesOnlyExpiredPaths},
@@ -11,7 +11,6 @@
11
11
  #include "state_ops.h"
12
12
 
13
13
  extern "C" {
14
- #include "hpatch.h"
15
14
  }
16
15
 
17
16
  namespace {
@@ -407,96 +406,6 @@ pushy::patch::PatchManifest BuildManifest(
407
406
  return manifest;
408
407
  }
409
408
 
410
- napi_value HdiffPatch(napi_env env, napi_callback_info info) {
411
- napi_value args[2] = {nullptr, nullptr};
412
- size_t argc = 2;
413
- if (!GetArgCount(env, info, &argc, args) || argc < 2) {
414
- ThrowError(env, "Wrong number of arguments");
415
- return nullptr;
416
- }
417
-
418
- bool is_typed_array = false;
419
- if (napi_is_typedarray(env, args[0], &is_typed_array) != napi_ok || !is_typed_array) {
420
- ThrowError(env, "First argument must be a TypedArray");
421
- return nullptr;
422
- }
423
-
424
- uint8_t* origin_ptr = nullptr;
425
- size_t origin_length = 0;
426
- if (napi_get_typedarray_info(
427
- env,
428
- args[0],
429
- nullptr,
430
- &origin_length,
431
- reinterpret_cast<void**>(&origin_ptr),
432
- nullptr,
433
- nullptr) != napi_ok) {
434
- ThrowError(env, "Failed to get origin buffer");
435
- return nullptr;
436
- }
437
-
438
- if (napi_is_typedarray(env, args[1], &is_typed_array) != napi_ok || !is_typed_array) {
439
- ThrowError(env, "Second argument must be a TypedArray");
440
- return nullptr;
441
- }
442
-
443
- uint8_t* patch_ptr = nullptr;
444
- size_t patch_length = 0;
445
- if (napi_get_typedarray_info(
446
- env,
447
- args[1],
448
- nullptr,
449
- &patch_length,
450
- reinterpret_cast<void**>(&patch_ptr),
451
- nullptr,
452
- nullptr) != napi_ok) {
453
- ThrowError(env, "Failed to get patch buffer");
454
- return nullptr;
455
- }
456
-
457
- hpatch_singleCompressedDiffInfo patch_info;
458
- if (!((origin_length == 0) || origin_ptr) || !patch_ptr || patch_length == 0) {
459
- ThrowError(env, "Corrupt patch");
460
- return nullptr;
461
- }
462
- if (kHPatch_ok != hpatch_getInfo_by_mem(&patch_info, patch_ptr, patch_length)) {
463
- ThrowError(env, "Error info in hpatch");
464
- return nullptr;
465
- }
466
- if (origin_length != patch_info.oldDataSize) {
467
- ThrowError(env, "Error oldDataSize in hpatch");
468
- return nullptr;
469
- }
470
-
471
- size_t new_size = static_cast<size_t>(patch_info.newDataSize);
472
- if (sizeof(size_t) != sizeof(hpatch_StreamPos_t) &&
473
- new_size != patch_info.newDataSize) {
474
- ThrowError(env, "Error newDataSize in hpatch");
475
- return nullptr;
476
- }
477
-
478
- void* output_data = nullptr;
479
- napi_value result = nullptr;
480
- if (napi_create_arraybuffer(env, new_size, &output_data, &result) != napi_ok) {
481
- ThrowError(env, "Failed to create result buffer");
482
- return nullptr;
483
- }
484
-
485
- if (kHPatch_ok != hpatch_by_mem(
486
- origin_ptr,
487
- origin_length,
488
- static_cast<uint8_t*>(output_data),
489
- new_size,
490
- patch_ptr,
491
- patch_length,
492
- &patch_info)) {
493
- ThrowError(env, "hpatch");
494
- return nullptr;
495
- }
496
-
497
- return result;
498
- }
499
-
500
409
  napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) {
501
410
  napi_value args[3] = {nullptr, nullptr, nullptr};
502
411
  size_t argc = 3;
@@ -968,8 +877,7 @@ bool ExportFunction(
968
877
  } // namespace
969
878
 
970
879
  napi_value Init(napi_env env, napi_value exports) {
971
- if (!ExportFunction(env, exports, "hdiffPatch", HdiffPatch) ||
972
- !ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) ||
880
+ if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) ||
973
881
  !ExportFunction(env, exports, "runStateCore", RunStateCore) ||
974
882
  !ExportFunction(env, exports, "buildArchivePatchPlan", BuildArchivePatchPlan) ||
975
883
  !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) ||
@@ -69,7 +69,7 @@ const FILE_COPY_BUFFER_SIZE = 64 * 1024;
69
69
 
70
70
  export class DownloadTask {
71
71
  private context: common.Context;
72
- private hash: string;
72
+ private hash = '';
73
73
  private eventHub: EventHub;
74
74
 
75
75
  constructor(context: common.Context) {
@@ -244,7 +244,7 @@ export class DownloadTask {
244
244
  bundleOutputPath: outputFile,
245
245
  enableMerge: false,
246
246
  });
247
- } catch (error) {
247
+ } catch (error: any) {
248
248
  error.message = `Failed to process bundle patch: ${error.message}`;
249
249
  throw error;
250
250
  } finally {
@@ -395,17 +395,18 @@ export class DownloadTask {
395
395
  fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE,
396
396
  );
397
397
 
398
- httpRequest.on('headersReceive', (header: Record<string, string>) => {
398
+ httpRequest.on('headersReceive', (header: Object) => {
399
399
  if (!header) {
400
400
  return;
401
401
  }
402
- const lengthKey = Object.keys(header).find(
402
+ const headers = header as Record<string, string>;
403
+ const lengthKey = Object.keys(headers).find(
403
404
  key => key.toLowerCase() === 'content-length',
404
405
  );
405
406
  if (!lengthKey) {
406
407
  return;
407
408
  }
408
- const length = parseInt(header[lengthKey], 10);
409
+ const length = parseInt(headers[lengthKey], 10);
409
410
  if (!Number.isNaN(length)) {
410
411
  contentLength = length;
411
412
  }
@@ -606,9 +607,11 @@ export class DownloadTask {
606
607
  }
607
608
 
608
609
  if (currentFrom.startsWith('resources/base/media/')) {
610
+ // Strip only the final extension: 'icon.round.png' -> 'icon.round',
611
+ // not 'icon' (getMediaByName expects the full resource name).
609
612
  const mediaName = currentFrom
610
613
  .replace('resources/base/media/', '')
611
- .split('.')[0];
614
+ .replace(/\.[^.]+$/, '');
612
615
  const mediaBuffer = await resourceManager.getMediaByName(mediaName);
613
616
  const parentDirs = [
614
617
  ...new Set(
@@ -649,7 +652,7 @@ export class DownloadTask {
649
652
  }
650
653
  }
651
654
  }
652
- } catch (error) {
655
+ } catch (error: any) {
653
656
  error.message =
654
657
  'Copy from resource failed:' +
655
658
  currentFrom +
@@ -670,7 +673,7 @@ export class DownloadTask {
670
673
  params.originHash || '',
671
674
  3,
672
675
  );
673
- } catch (error) {
676
+ } catch (error: any) {
674
677
  error.message = 'Cleanup failed:' + error.message;
675
678
  console.error(error);
676
679
  throw error;
@@ -698,7 +701,7 @@ export class DownloadTask {
698
701
  default:
699
702
  throw Error(`Unknown task type: ${params.type}`);
700
703
  }
701
- } catch (error) {
704
+ } catch (error: any) {
702
705
  console.error('Task execution failed:', error.message);
703
706
  if (params.type !== DownloadTaskParams.TASK_TYPE_CLEANUP) {
704
707
  try {
@@ -707,7 +710,7 @@ export class DownloadTask {
707
710
  } else {
708
711
  await this.removeDirectory(params.unzipDirectory);
709
712
  }
710
- } catch (cleanupError) {
713
+ } catch (cleanupError: any) {
711
714
  console.error('Cleanup after error failed:', cleanupError.message);
712
715
  }
713
716
  }
@@ -50,10 +50,6 @@ export interface FileSourcePatchRequest {
50
50
  }
51
51
 
52
52
  interface NativePatchCoreBindings {
53
- hdiffPatch(
54
- origin: Uint8Array,
55
- patch: Uint8Array,
56
- ): ArrayBuffer | Uint8Array;
57
53
  syncStateWithBinaryVersion(
58
54
  packageVersion: string,
59
55
  buildTime: string,
package/harmony/pushy.har CHANGED
Binary file
@@ -14,6 +14,9 @@
14
14
 
15
15
  #import <React/RCTConvert.h>
16
16
  #import <React/RCTLog.h>
17
+ #import <os/lock.h>
18
+
19
+ #include <atomic>
17
20
 
18
21
  static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY";
19
22
  static NSString *const paramPackageVersion = @"packageVersion";
@@ -52,7 +55,20 @@ typedef NS_ENUM(NSInteger, PushyType) {
52
55
  //TASK_TYPE_PLAIN_DOWNLOAD=4?
53
56
  };
54
57
 
55
- static BOOL ignoreRollback = false;
58
+ static std::atomic<bool> ignoreRollback{false};
59
+
60
+ // Serializes every read-modify-write of the persisted update state. The state
61
+ // machine itself is a pure function (state_core), but callers run on different
62
+ // threads (main thread bundleURL, module method queue, _fileQueue), so the
63
+ // read→transform→write sequence must be atomic to avoid e.g. markSuccess being
64
+ // overwritten by a concurrent bundleURL and the version being rolled back.
65
+ static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT;
66
+
67
+ static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) {
68
+ os_unfair_lock_lock(&pushyStateLock);
69
+ block();
70
+ os_unfair_lock_unlock(&pushyStateLock);
71
+ }
56
72
 
57
73
  static std::string PushyToStdString(NSString *value) {
58
74
  if (value == nil) {
@@ -221,64 +237,71 @@ RCT_EXPORT_MODULE(RCTPushy);
221
237
 
222
238
  + (NSURL *)bundleURL
223
239
  {
224
- NSUserDefaults *defaults = PushyDefaults();
225
-
226
- NSString *curPackageVersion = [RCTPushy packageVersion];
227
- NSString *curBuildTime = [RCTPushy buildTime];
228
-
229
- pushy::state::State state = PushyStateFromDefaults(defaults);
230
- pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion(
231
- state,
232
- PushyToStdString(curPackageVersion),
233
- PushyToStdString(curBuildTime)
234
- );
235
- if (sync.changed) {
236
- [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked];
237
- state = sync.state;
238
- PushyApplyStateToDefaults(defaults, state);
239
- }
240
+ __block NSURL *resolvedURL = nil;
241
+ PushyWithStateLock(^{
242
+ NSUserDefaults *defaults = PushyDefaults();
243
+
244
+ NSString *curPackageVersion = [RCTPushy packageVersion];
245
+ NSString *curBuildTime = [RCTPushy buildTime];
240
246
 
241
- if (!state.current_version.empty()) {
242
- pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(
247
+ pushy::state::State state = PushyStateFromDefaults(defaults);
248
+ pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion(
243
249
  state,
244
- ignoreRollback,
245
- true
250
+ PushyToStdString(curPackageVersion),
251
+ PushyToStdString(curBuildTime)
246
252
  );
247
- state = decision.state;
248
-
249
- if (decision.did_rollback || decision.consumed_first_time) {
253
+ if (sync.changed) {
254
+ [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked];
255
+ state = sync.state;
250
256
  PushyApplyStateToDefaults(defaults, state);
251
257
  }
252
- if (decision.consumed_first_time) {
253
- // bundleURL may be called many times, ignore rollbacks before process restarted again.
254
- ignoreRollback = true;
255
- [defaults setObject:@(YES) forKey:keyFirstLoadMarked];
256
- }
257
258
 
258
- NSString *loadVersion = PushyFromStdString(decision.load_version);
259
- NSString *downloadDir = [RCTPushy downloadDir];
260
- while (loadVersion.length) {
261
- NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME];
262
- if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
263
- NSURL *bundleURL = [NSURL fileURLWithPath:bundlePath];
264
- return bundleURL;
265
- } else {
266
- RCTLogError(@"RCTPushy -- bundle version %@ not found", loadVersion);
267
- state = pushy::state::Rollback(state);
259
+ if (!state.current_version.empty()) {
260
+ pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(
261
+ state,
262
+ ignoreRollback.load(),
263
+ true
264
+ );
265
+ state = decision.state;
266
+
267
+ if (decision.did_rollback || decision.consumed_first_time) {
268
268
  PushyApplyStateToDefaults(defaults, state);
269
- loadVersion = PushyFromStdString(state.current_version);
269
+ }
270
+ if (decision.consumed_first_time) {
271
+ // bundleURL may be called many times, ignore rollbacks before process restarted again.
272
+ ignoreRollback = true;
273
+ [defaults setObject:@(YES) forKey:keyFirstLoadMarked];
274
+ }
275
+
276
+ NSString *loadVersion = PushyFromStdString(decision.load_version);
277
+ NSString *downloadDir = [RCTPushy downloadDir];
278
+ while (loadVersion.length) {
279
+ NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME];
280
+ if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
281
+ resolvedURL = [NSURL fileURLWithPath:bundlePath];
282
+ return;
283
+ } else {
284
+ RCTLogError(@"RCTPushy -- bundle version %@ not found", loadVersion);
285
+ state = pushy::state::Rollback(state);
286
+ PushyApplyStateToDefaults(defaults, state);
287
+ loadVersion = PushyFromStdString(state.current_version);
288
+ }
270
289
  }
271
290
  }
272
- }
273
-
274
- return [RCTPushy binaryBundleURL];
291
+ });
292
+
293
+ return resolvedURL ?: [RCTPushy binaryBundleURL];
275
294
  }
276
295
 
277
296
  + (NSString *) rollback {
278
- NSUserDefaults *defaults = PushyDefaults();
279
- pushy::state::State state = pushy::state::Rollback(PushyStateFromDefaults(defaults));
280
- PushyApplyStateToDefaults(defaults, state);
281
- return PushyFromStdString(state.current_version);
297
+ __block NSString *currentVersion = nil;
298
+ PushyWithStateLock(^{
299
+ NSUserDefaults *defaults = PushyDefaults();
300
+ pushy::state::State state = pushy::state::Rollback(PushyStateFromDefaults(defaults));
301
+ PushyApplyStateToDefaults(defaults, state);
302
+ currentVersion = PushyFromStdString(state.current_version);
303
+ });
304
+ return currentVersion;
282
305
  }
283
306
 
284
307
  + (BOOL)requiresMainQueueSetup
@@ -288,36 +311,37 @@ RCT_EXPORT_MODULE(RCTPushy);
288
311
 
289
312
  - (NSDictionary *)constantsToExport
290
313
  {
291
- NSUserDefaults *defaults = PushyDefaults();
292
-
293
314
  NSMutableDictionary *ret = [NSMutableDictionary new];
294
- ret[@"downloadRootDir"] = [RCTPushy downloadDir];
295
- ret[@"packageVersion"] = [RCTPushy packageVersion];
296
- ret[@"buildTime"] = [RCTPushy buildTime];
297
- ret[@"rolledBackVersion"] = [defaults objectForKey:keyRolledBackMarked];
298
- ret[@"isFirstTime"] = [defaults objectForKey:keyFirstLoadMarked];
299
- ret[@"uuid"] = [defaults objectForKey:keyUuid];
300
- NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo];
301
- NSString *currentVersion = [pushyInfo objectForKey:paramCurrentVersion];
302
- ret[@"currentVersion"] = currentVersion;
303
- if (currentVersion != nil) {
304
- ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)];
305
- }
306
-
307
- if (ret[@"isFirstTime"]) {
308
- [defaults removeObjectForKey:keyFirstLoadMarked];
309
- }
310
-
311
- if (ret[@"rolledBackVersion"] != nil) {
312
- [defaults removeObjectForKey:keyRolledBackMarked];
313
- [self clearInvalidFiles];
314
- }
315
-
316
- if ([[defaults objectForKey:KeyPackageUpdatedMarked] boolValue]) {
317
- [defaults removeObjectForKey:KeyPackageUpdatedMarked];
318
- [self clearInvalidFiles];
319
- }
320
-
315
+ PushyWithStateLock(^{
316
+ NSUserDefaults *defaults = PushyDefaults();
317
+
318
+ ret[@"downloadRootDir"] = [RCTPushy downloadDir];
319
+ ret[@"packageVersion"] = [RCTPushy packageVersion];
320
+ ret[@"buildTime"] = [RCTPushy buildTime];
321
+ ret[@"rolledBackVersion"] = [defaults objectForKey:keyRolledBackMarked];
322
+ ret[@"isFirstTime"] = [defaults objectForKey:keyFirstLoadMarked];
323
+ ret[@"uuid"] = [defaults objectForKey:keyUuid];
324
+ NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo];
325
+ NSString *currentVersion = [pushyInfo objectForKey:paramCurrentVersion];
326
+ ret[@"currentVersion"] = currentVersion;
327
+ if (currentVersion != nil) {
328
+ ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)];
329
+ }
330
+
331
+ if (ret[@"isFirstTime"]) {
332
+ [defaults removeObjectForKey:keyFirstLoadMarked];
333
+ }
334
+
335
+ if (ret[@"rolledBackVersion"] != nil) {
336
+ [defaults removeObjectForKey:keyRolledBackMarked];
337
+ [self clearInvalidFiles];
338
+ }
339
+
340
+ if ([[defaults objectForKey:KeyPackageUpdatedMarked] boolValue]) {
341
+ [defaults removeObjectForKey:KeyPackageUpdatedMarked];
342
+ [self clearInvalidFiles];
343
+ }
344
+ });
321
345
 
322
346
  return ret;
323
347
  }
@@ -438,13 +462,15 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
438
462
  resolve(@true);
439
463
  #else
440
464
 
441
- NSUserDefaults *defaults = PushyDefaults();
442
- pushy::state::MarkSuccessResult result =
443
- pushy::state::MarkSuccess(PushyStateFromDefaults(defaults));
444
- if (!result.stale_version_to_delete.empty()) {
445
- [defaults removeObjectForKey:PushyHashInfoKey(PushyFromStdString(result.stale_version_to_delete))];
446
- }
447
- PushyApplyStateToDefaults(defaults, result.state);
465
+ PushyWithStateLock(^{
466
+ NSUserDefaults *defaults = PushyDefaults();
467
+ pushy::state::MarkSuccessResult result =
468
+ pushy::state::MarkSuccess(PushyStateFromDefaults(defaults));
469
+ if (!result.stale_version_to_delete.empty()) {
470
+ [defaults removeObjectForKey:PushyHashInfoKey(PushyFromStdString(result.stale_version_to_delete))];
471
+ }
472
+ PushyApplyStateToDefaults(defaults, result.state);
473
+ });
448
474
 
449
475
  [self clearInvalidFiles];
450
476
  resolve(@true);
@@ -674,38 +700,39 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
674
700
  return NO;
675
701
  }
676
702
 
677
- NSUserDefaults *defaults = PushyDefaults();
678
- pushy::state::State next = pushy::state::SwitchVersion(
679
- PushyStateFromDefaults(defaults),
680
- PushyToStdString(hash)
681
- );
682
- PushyApplyStateToDefaults(defaults, next);
683
- // Re-enable first-load consumption and rollback checks for the newly selected bundle.
684
- ignoreRollback = false;
703
+ PushyWithStateLock(^{
704
+ NSUserDefaults *defaults = PushyDefaults();
705
+ pushy::state::State next = pushy::state::SwitchVersion(
706
+ PushyStateFromDefaults(defaults),
707
+ PushyToStdString(hash)
708
+ );
709
+ PushyApplyStateToDefaults(defaults, next);
710
+ // Re-enable first-load consumption and rollback checks for the newly selected bundle.
711
+ ignoreRollback = false;
712
+ });
685
713
  return YES;
686
714
  }
687
715
 
688
716
  - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path
689
717
  {
690
- __block BOOL success = NO;
691
-
692
- dispatch_sync(_fileQueue, ^{
693
- NSFileManager *fileManager = [NSFileManager defaultManager];
694
- BOOL isDirectory = NO;
695
- if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
696
- success = isDirectory;
697
- return;
698
- }
718
+ // No _fileQueue hop here: that queue also runs multi-second unzip/patch
719
+ // work, and a dispatch_sync onto it would block the whole module method
720
+ // queue for the duration. createDirectoryAtPath is idempotent and
721
+ // thread-safe, so checking inline is fine.
722
+ NSFileManager *fileManager = [NSFileManager defaultManager];
723
+ BOOL isDirectory = NO;
724
+ if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
725
+ return isDirectory;
726
+ }
699
727
 
700
- NSError *error = nil;
701
- success = [fileManager createDirectoryAtPath:path
702
- withIntermediateDirectories:YES
703
- attributes:nil
704
- error:&error];
705
- if (!success && error != nil) {
706
- RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
707
- }
708
- });
728
+ NSError *error = nil;
729
+ BOOL success = [fileManager createDirectoryAtPath:path
730
+ withIntermediateDirectories:YES
731
+ attributes:nil
732
+ error:&error];
733
+ if (!success && error != nil) {
734
+ RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
735
+ }
709
736
 
710
737
  return success;
711
738
  }
@@ -741,8 +768,12 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
741
768
  - (void)clearInvalidFiles
742
769
  {
743
770
  dispatch_async(_fileQueue, ^{
744
- NSUserDefaults *defaults = PushyDefaults();
745
- pushy::state::State state = PushyStateFromDefaults(defaults);
771
+ // Snapshot the state under the lock, but run the (slow) filesystem
772
+ // cleanup outside of it so state operations are not blocked.
773
+ __block pushy::state::State state;
774
+ PushyWithStateLock(^{
775
+ state = PushyStateFromDefaults(PushyDefaults());
776
+ });
746
777
  NSString *downloadDir = [RCTPushy downloadDir];
747
778
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
748
779
  PushyToStdString(downloadDir),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.44.0",
3
+ "version": "10.45.0",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
@@ -9,12 +9,13 @@
9
9
  "publish:local": "SKIP_NATIVE_BUILD=1 npm publish",
10
10
  "postinstall": "node scripts/check-expo-version.js",
11
11
  "prepack": "bun submodule && bun lint",
12
- "lint": "eslint \"src/*.@(ts|tsx|js|jsx)\" && tsc --noEmit",
12
+ "lint": "eslint \"src/*.@(ts|tsx|js|jsx)\" && tsc --noEmit && node scripts/check-harmony-types.js",
13
+ "lint:harmony": "node scripts/check-harmony-types.js",
13
14
  "submodule": "git submodule update --init --recursive",
14
15
  "test": "bun test src/__tests__",
15
16
  "test:patch-core": "./scripts/test-patch-core.sh",
16
17
  "build:harmony-har": "node scripts/build-harmony-har.js",
17
- "build:so": "bun submodule && $ANDROID_HOME/ndk/28.2.13676358/ndk-build NDK_PROJECT_PATH=android APP_BUILD_SCRIPT=android/jni/Android.mk NDK_APPLICATION_MK=android/jni/Application.mk NDK_LIBS_OUT=android/lib",
18
+ "build:so": "bun submodule && bash scripts/build-android-so.sh",
18
19
  "build:ios-debug": "cd Example/e2etest && bun && detox build --configuration ios.sim.debug",
19
20
  "build:ios-release": "cd Example/e2etest && bun && detox build --configuration ios.sim.release",
20
21
  "test:ios-debug": "cd Example/e2etest && E2E_PLATFORM=ios detox test --configuration ios.sim.debug",
@@ -68,6 +69,7 @@
68
69
  "@types/jest": "^30.0.0",
69
70
  "@types/node": "^25.5.0",
70
71
  "@types/react": "^19.2.14",
72
+ "@types/react-test-renderer": "18.0.7",
71
73
  "detox": "^20.50.1",
72
74
  "eslint": "^8.57.0",
73
75
  "firebase-tools": "^15.11.0",
@@ -76,6 +78,7 @@
76
78
  "prettier": "^2",
77
79
  "react": "18.2.0",
78
80
  "react-native": "0.73",
81
+ "react-test-renderer": "18.2.0",
79
82
  "ts-jest": "^29.4.6",
80
83
  "typescript": "^5.6.3"
81
84
  }
package/src/client.ts CHANGED
@@ -191,23 +191,39 @@ export class Pushy {
191
191
  data?: Record<string, string | number>;
192
192
  }) => {
193
193
  log(`${type} ${message}`);
194
- await this.loggerPromise.promise;
194
+ if (this.options.logger === noop) {
195
+ // Wait briefly for a logger to arrive via setOptions (e.g. the rollback
196
+ // report fires in the constructor before the user configures one), but
197
+ // give up after a bound instead of retaining the closure forever when
198
+ // no logger is ever provided.
199
+ await Promise.race([
200
+ this.loggerPromise.promise,
201
+ new Promise(resolve => setTimeout(resolve, 10 * 1000)),
202
+ ]);
203
+ }
195
204
  const { logger = noop, appKey } = this.options;
196
205
  const overridePackageVersion = this.options.overridePackageVersion;
197
- logger({
198
- type,
199
- data: {
200
- appKey,
201
- currentVersion,
202
- cInfo,
203
- packageVersion,
204
- overridePackageVersion,
205
- buildTime,
206
- message,
207
- ...currentVersionInfo,
208
- ...data,
209
- },
210
- });
206
+ try {
207
+ logger({
208
+ type,
209
+ data: {
210
+ appKey,
211
+ currentVersion,
212
+ cInfo,
213
+ packageVersion,
214
+ overridePackageVersion,
215
+ buildTime,
216
+ message,
217
+ ...currentVersionInfo,
218
+ ...data,
219
+ },
220
+ });
221
+ } catch (e: any) {
222
+ // A user-provided logger must never break the update flow, and report()
223
+ // calls are fire-and-forget so a throw here would be an unhandled
224
+ // rejection.
225
+ log('logger error:', e?.message || e);
226
+ }
211
227
  };
212
228
  throwIfEnabled = (e: Error) => {
213
229
  if (this.options.throwError) {
@@ -454,17 +470,6 @@ export class Pushy {
454
470
  return previousRespJson ? await previousRespJson : undefined;
455
471
  }
456
472
  };
457
- getBackupEndpoints = async () => {
458
- const { server } = this.options;
459
- if (!server) {
460
- return [];
461
- }
462
- const remoteEndpoints = await this.getRemoteEndpoints();
463
- return excludeConfiguredEndpoints(
464
- dedupeEndpoints(remoteEndpoints),
465
- this.getConfiguredCheckEndpoints(),
466
- );
467
- };
468
473
  downloadUpdate = async (
469
474
  updateInfo: CheckResult,
470
475
  onDownloadProgress?: (data: ProgressData) => void,
package/src/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useContext } from 'react';
1
+ import { createContext, useContext, useMemo } from 'react';
2
2
  import { CheckResult, ProgressData } from './type';
3
3
  import { Pushy, Cresc } from './client';
4
4
  import i18n from './i18n';
@@ -46,21 +46,35 @@ export const UpdateContext = createContext<{
46
46
  currentHash: string;
47
47
  packageVersion: string;
48
48
  client?: Pushy | Cresc;
49
- progress?: ProgressData;
50
49
  updateInfo?: CheckResult;
51
50
  lastError?: Error;
52
51
  }>(defaultContext);
53
52
 
54
- export const useUpdate = __DEV__ ? () => {
53
+ // Download progress ticks at high frequency, so it lives in its own context;
54
+ // otherwise every tick would re-render all useUpdate() consumers even when
55
+ // they never read progress.
56
+ export const ProgressContext = createContext<ProgressData | undefined>(
57
+ undefined,
58
+ );
59
+
60
+ /**
61
+ * Subscribe to download progress only. Components that render a progress bar
62
+ * should prefer this over useUpdate() so the rest of the tree is not
63
+ * re-rendered on every progress event.
64
+ */
65
+ export const useUpdateProgress = () => useContext(ProgressContext);
66
+
67
+ export const useUpdate = () => {
55
68
  const context = useContext(UpdateContext);
69
+ const progress = useContext(ProgressContext);
56
70
 
57
- // 检查是否在 UpdateProvider 内部使用
58
- if (!context.client) {
71
+ if (__DEV__ && !context.client) {
72
+ // 检查是否在 UpdateProvider 内部使用
59
73
  throw new Error(i18n.t('error_use_update_outside_provider'));
60
74
  }
61
75
 
62
- return context;
63
- } : () => useContext(UpdateContext);
76
+ return useMemo(() => ({ ...context, progress }), [context, progress]);
77
+ };
64
78
 
65
79
  /** @deprecated Please use `useUpdate` instead */
66
80
  export const usePushy = useUpdate;
package/src/core.ts CHANGED
@@ -82,7 +82,11 @@ export const pushyNativeEventEmitter = new NativeEventEmitter(PushyModule);
82
82
 
83
83
  if (!uuid) {
84
84
  uuid = require('nanoid/non-secure').nanoid();
85
- PushyModule.setUuid(uuid);
85
+ // If persisting fails the uuid drifts on every launch, which skews gray
86
+ // release bucketing and inflates stats — log it instead of failing silently.
87
+ Promise.resolve(PushyModule.setUuid(uuid)).catch((e: any) => {
88
+ log('setUuid error:', e?.message || e);
89
+ });
86
90
  }
87
91
 
88
92
  export const cInfo = {
package/src/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export { Pushy, Cresc } from './client';
2
- export { UpdateContext, usePushy, useUpdate } from './context';
2
+ export {
3
+ ProgressContext,
4
+ UpdateContext,
5
+ usePushy,
6
+ useUpdate,
7
+ useUpdateProgress,
8
+ } from './context';
3
9
  export { PushyProvider, UpdateProvider } from './provider';
4
10
  export { PushyModule, UpdateModule } from './core';
package/src/provider.tsx CHANGED
@@ -2,6 +2,7 @@ import React, {
2
2
  ReactNode,
3
3
  useCallback,
4
4
  useEffect,
5
+ useMemo,
5
6
  useRef,
6
7
  useState,
7
8
  } from 'react';
@@ -24,7 +25,7 @@ import {
24
25
  ProgressData,
25
26
  UpdateTestPayload,
26
27
  } from './type';
27
- import { UpdateContext } from './context';
28
+ import { ProgressContext, UpdateContext } from './context';
28
29
  import { URL } from 'react-native-url-polyfill';
29
30
  import { resolveCheckResult } from './resolveCheckResult';
30
31
  import { assertWeb, log, noop } from './utils';
@@ -44,7 +45,6 @@ export const UpdateProvider = ({
44
45
  const updateInfoRef = useRef(updateInfo);
45
46
  const [progress, setProgress] = useState<ProgressData>();
46
47
  const [lastError, setLastError] = useState<Error>();
47
- const lastChecking = useRef(0);
48
48
 
49
49
  const throwErrorIfEnabled = useCallback(
50
50
  (e: Error) => {
@@ -161,12 +161,9 @@ export const UpdateProvider = ({
161
161
 
162
162
  const checkUpdate = useCallback(
163
163
  async ({ extra }: { extra?: Partial<{ toHash: string }> } = {}) => {
164
- const now = Date.now();
165
- if (lastChecking.current && now - lastChecking.current < 1000) {
166
- client.notifyAfterCheckUpdate({ status: 'skipped' });
167
- return;
168
- }
169
- lastChecking.current = now;
164
+ // No throttle here: the client already dedupes checks via its 5s
165
+ // response cache, and a second throttle layer silently returned
166
+ // undefined, indistinguishable from a failed check.
170
167
  let rootInfo: CheckResult | undefined;
171
168
  try {
172
169
  rootInfo = await client.checkUpdate(extra);
@@ -274,8 +271,9 @@ export const UpdateProvider = ({
274
271
  return;
275
272
  }
276
273
  const { checkStrategy, autoMarkSuccess } = options;
274
+ let markSuccessTimer: ReturnType<typeof setTimeout> | undefined;
277
275
  if (autoMarkSuccess) {
278
- setTimeout(() => {
276
+ markSuccessTimer = setTimeout(() => {
279
277
  markSuccess();
280
278
  }, 1000);
281
279
  }
@@ -293,6 +291,9 @@ export const UpdateProvider = ({
293
291
  checkUpdate().catch(noop);
294
292
  }
295
293
  return () => {
294
+ if (markSuccessTimer) {
295
+ clearTimeout(markSuccessTimer);
296
+ }
296
297
  stateListener.current && stateListener.current.remove();
297
298
  };
298
299
  }, [checkUpdate, options, dismissError, markSuccess, client]);
@@ -391,28 +392,48 @@ export const UpdateProvider = ({
391
392
  };
392
393
  }, [parseTestPayload]);
393
394
 
395
+ // progress lives in its own context (see context.ts), so this value only
396
+ // changes when the update state itself changes, not on every progress tick.
397
+ const contextValue = useMemo(
398
+ () => ({
399
+ checkUpdate,
400
+ switchVersion,
401
+ switchVersionLater,
402
+ dismissError,
403
+ updateInfo,
404
+ lastError,
405
+ markSuccess,
406
+ client,
407
+ downloadUpdate,
408
+ packageVersion,
409
+ currentHash: currentVersion,
410
+ downloadAndInstallApk,
411
+ getCurrentVersionInfo,
412
+ currentVersionInfo,
413
+ parseTestQrCode,
414
+ restartApp,
415
+ }),
416
+ [
417
+ checkUpdate,
418
+ switchVersion,
419
+ switchVersionLater,
420
+ dismissError,
421
+ updateInfo,
422
+ lastError,
423
+ markSuccess,
424
+ client,
425
+ downloadUpdate,
426
+ downloadAndInstallApk,
427
+ parseTestQrCode,
428
+ restartApp,
429
+ ],
430
+ );
431
+
394
432
  return (
395
- <UpdateContext.Provider
396
- value={{
397
- checkUpdate,
398
- switchVersion,
399
- switchVersionLater,
400
- dismissError,
401
- updateInfo,
402
- lastError,
403
- markSuccess,
404
- client,
405
- downloadUpdate,
406
- packageVersion,
407
- currentHash: currentVersion,
408
- progress,
409
- downloadAndInstallApk,
410
- getCurrentVersionInfo,
411
- currentVersionInfo,
412
- parseTestQrCode,
413
- restartApp,
414
- }}>
415
- {children}
433
+ <UpdateContext.Provider value={contextValue}>
434
+ <ProgressContext.Provider value={progress}>
435
+ {children}
436
+ </ProgressContext.Provider>
416
437
  </UpdateContext.Provider>
417
438
  );
418
439
  };
package/src/utils.ts CHANGED
@@ -22,6 +22,10 @@ export const DEFAULT_FETCH_TIMEOUT_MS = 5000;
22
22
 
23
23
  export function promiseAny<T>(promises: Promise<T>[]) {
24
24
  return new Promise<T>((resolve, reject) => {
25
+ if (!promises.length) {
26
+ reject(Error(i18n.t('error_all_promises_rejected')));
27
+ return;
28
+ }
25
29
  let count = 0;
26
30
 
27
31
  promises.forEach(promise => {
@@ -118,21 +122,50 @@ export const fetchWithTimeout = (
118
122
  params: Parameters<typeof fetch>[1],
119
123
  timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
120
124
  ): Promise<Response> => {
121
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
122
-
123
- return Promise.race([
124
- enhancedFetch(url, params),
125
- new Promise<Response>((_, reject) => {
126
- timeoutId = setTimeout(() => {
127
- log('fetch timeout', url);
128
- reject(Error(i18n.t('error_ping_timeout')));
129
- }, timeoutMs);
130
- }),
131
- ]).finally(() => {
132
- if (timeoutId) {
125
+ // AbortController landed in the RN fetch polyfill around 0.60; we support
126
+ // older peers, so fall back to a plain timer race when it is unavailable
127
+ // (the losing fetch keeps running there — old runtimes can't do better).
128
+ if (typeof AbortController === 'undefined') {
129
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
130
+ return Promise.race([
131
+ enhancedFetch(url, params),
132
+ new Promise<Response>((_, reject) => {
133
+ timeoutId = setTimeout(() => {
134
+ log('fetch timeout', url);
135
+ reject(Error(i18n.t('error_ping_timeout')));
136
+ }, timeoutMs);
137
+ }),
138
+ ]).finally(() => {
139
+ if (timeoutId) {
140
+ clearTimeout(timeoutId);
141
+ }
142
+ });
143
+ }
144
+
145
+ // Abort the underlying request on timeout instead of racing a timer: with
146
+ // Promise.race the losing fetch kept running (and kept the connection busy)
147
+ // long after the caller had already moved on.
148
+ const controller = new AbortController();
149
+ const timeoutId = setTimeout(() => {
150
+ log('fetch timeout', url);
151
+ controller.abort();
152
+ }, timeoutMs);
153
+
154
+ return enhancedFetch(url, { ...params, signal: controller.signal })
155
+ .catch((e: any) => {
156
+ if (controller.signal.aborted) {
157
+ throw Error(i18n.t('error_ping_timeout'));
158
+ }
159
+ throw e;
160
+ })
161
+ .finally(() => {
133
162
  clearTimeout(timeoutId);
134
- }
135
- });
163
+ });
164
+ };
165
+
166
+ const isIdempotentRequest = (params: Parameters<typeof fetch>[1]) => {
167
+ const method = params?.method?.toUpperCase() ?? 'GET';
168
+ return method === 'GET' || method === 'HEAD';
136
169
  };
137
170
 
138
171
  export const enhancedFetch = async (
@@ -143,10 +176,17 @@ export const enhancedFetch = async (
143
176
  return fetch(url, params)
144
177
  .catch(e => {
145
178
  log('fetch error', url, e);
146
- if (isRetry) {
179
+ if (
180
+ isRetry ||
181
+ (params as any)?.signal?.aborted ||
182
+ !url.startsWith('https:') ||
183
+ // Never replay non-idempotent requests (e.g. the checkUpdate POST)
184
+ // over plaintext http: the server may have processed the original.
185
+ !isIdempotentRequest(params)
186
+ ) {
147
187
  throw e;
148
188
  }
149
189
  log('trying fallback to http');
150
- return enhancedFetch(url.replace('https', 'http'), params, true);
190
+ return enhancedFetch(url.replace(/^https:/, 'http:'), params, true);
151
191
  });
152
192
  };
@@ -1,56 +0,0 @@
1
- //
2
- // Created by DengYun on 3/31/16.
3
- //
4
-
5
- #include "cn_reactnative_modules_update_DownloadTask.h"
6
-
7
- #include "hpatch.h"
8
- #define _check(v,errInfo) do{ if (!(v)) { _isError=hpatch_TRUE; _errInfo=errInfo; goto _clear; } }while(0)
9
-
10
- JNIEXPORT jbyteArray JNICALL Java_cn_reactnative_modules_update_DownloadTask_hdiffPatch
11
- (JNIEnv *env, jobject self, jbyteArray origin, jbyteArray patch){
12
- hpatch_BOOL _isError=hpatch_FALSE;
13
- const char* _errInfo="";
14
-
15
- jbyte* originPtr = (*env)->GetByteArrayElements(env, origin, NULL);
16
- size_t originLength = (*env)->GetArrayLength(env, origin);
17
- jbyte* patchPtr = (*env)->GetByteArrayElements(env, patch, NULL);
18
- size_t patchLength = (*env)->GetArrayLength(env, patch);
19
- jbyteArray ret = NULL;
20
- jbyte* outPtr = NULL;
21
- size_t newsize = 0;
22
- hpatch_singleCompressedDiffInfo patInfo;
23
-
24
- _check(((originLength==0)||originPtr) && patchPtr && (patchLength>0),"Corrupt patch");
25
- _check(kHPatch_ok==hpatch_getInfo_by_mem(&patInfo,(const uint8_t*)patchPtr,patchLength),"Error info in hpatch");
26
- _check(originLength==patInfo.oldDataSize,"Error oldDataSize in hpatch");
27
- newsize=(size_t)patInfo.newDataSize;
28
- if (sizeof(size_t)!=sizeof(hpatch_StreamPos_t))
29
- _check(newsize==patInfo.newDataSize,"Error newDataSize in hpatch");
30
-
31
- ret = (*env)->NewByteArray(env,newsize);
32
- _check(ret,"Error JNIEnv::NewByteArray()");
33
- if (newsize>0) {
34
- outPtr = (*env)->GetByteArrayElements(env, ret, NULL);
35
- _check(outPtr,"Corrupt JNIEnv::GetByteArrayElements");
36
- }
37
-
38
- _check(kHPatch_ok==hpatch_by_mem((const uint8_t*)originPtr,originLength,(uint8_t*)outPtr,newsize,
39
- (const uint8_t*)patchPtr,patchLength,&patInfo),"hpacth");
40
-
41
- _clear:
42
- if (outPtr) (*env)->ReleaseByteArrayElements(env, ret, outPtr, (_isError?JNI_ABORT:0));
43
- if (originPtr) (*env)->ReleaseByteArrayElements(env, origin, originPtr, JNI_ABORT);
44
- if (patchPtr) (*env)->ReleaseByteArrayElements(env, patch, patchPtr, JNI_ABORT);
45
- if (_isError){
46
- jclass newExcCls = NULL;
47
- if (ret){
48
- (*env)->DeleteLocalRef(env, ret);
49
- ret = NULL;
50
- }
51
- newExcCls = (*env)->FindClass(env, "java/lang/Error");
52
- if (newExcCls != NULL) // Unable to find the new exception class, give up.
53
- (*env)->ThrowNew(env, newExcCls, _errInfo);
54
- }
55
- return ret;
56
- }
@@ -1,21 +0,0 @@
1
- /* DO NOT EDIT THIS FILE - it is machine generated */
2
- #include <jni.h>
3
- /* Header for class cn_reactnative_modules_update_DownloadTask */
4
-
5
- #ifndef _Included_cn_reactnative_modules_update_DownloadTask
6
- #define _Included_cn_reactnative_modules_update_DownloadTask
7
- #ifdef __cplusplus
8
- extern "C" {
9
- #endif
10
- /*
11
- * Class: cn_reactnative_modules_update_DownloadTask
12
- * Method: hdiffPatch
13
- * Signature: ([B[B)[B
14
- */
15
- JNIEXPORT jbyteArray JNICALL Java_cn_reactnative_modules_update_DownloadTask_hdiffPatch
16
- (JNIEnv *, jclass, jbyteArray, jbyteArray);
17
-
18
- #ifdef __cplusplus
19
- }
20
- #endif
21
- #endif