react-native-update 10.59.1 → 10.60.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 (125) hide show
  1. package/README-CN.md +7 -7
  2. package/README.md +7 -6
  3. package/android/build.gradle +25 -33
  4. package/android/jni/Android.mk +16 -24
  5. package/android/lib/arm64-v8a/librnpushy.so +0 -0
  6. package/android/lib/armeabi-v7a/librnpushy.so +0 -0
  7. package/android/lib/x86/librnpushy.so +0 -0
  8. package/android/lib/x86_64/librnpushy.so +0 -0
  9. package/android/proguard.pro +9 -7
  10. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +11 -12
  11. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +23 -6
  12. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +50 -51
  13. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +12 -13
  14. package/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java +3 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/CrashHold.java +22 -30
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +181 -332
  17. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -1
  18. package/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +27 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +1 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +32 -8
  21. package/android/src/main/java/cn/reactnative/modules/update/HttpClients.java +63 -0
  22. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +0 -11
  23. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +66 -116
  24. package/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +9 -1
  25. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +1 -1
  26. package/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +32 -60
  27. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +8 -23
  28. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +42 -80
  29. package/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +22 -39
  30. package/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +176 -299
  31. package/android/src/main/java/cn/reactnative/modules/update/Threads.java +35 -0
  32. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +242 -174
  33. package/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +1 -1
  34. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +75 -17
  35. package/android/src/main/java/cn/reactnative/modules/update/{UpdateModuleImpl.java → UpdateModule.java} +113 -95
  36. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +16 -19
  37. package/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java +69 -0
  38. package/cpp/android_jni/archive_limits_jni.cpp +83 -0
  39. package/cpp/android_jni/install_record_jni.cpp +89 -0
  40. package/cpp/android_jni/jni_log.h +31 -0
  41. package/cpp/android_jni/jni_natives.h +40 -0
  42. package/cpp/android_jni/jni_registration.cpp +133 -0
  43. package/cpp/android_jni/jni_util.h +160 -0
  44. package/cpp/android_jni/update_core_android.cpp +450 -0
  45. package/cpp/android_jni/update_flow_jni.cpp +133 -0
  46. package/{android/jni → cpp/hpatch}/hpatch.c +5 -5
  47. package/{android/jni → cpp/hpatch}/hpatch.h +4 -1
  48. package/cpp/patch_core/archive_limits.cpp +194 -0
  49. package/cpp/patch_core/archive_limits.h +99 -10
  50. package/cpp/patch_core/archive_patch_core.cpp +96 -12
  51. package/cpp/patch_core/archive_patch_core.h +49 -0
  52. package/cpp/patch_core/digest.h +5 -4
  53. package/cpp/patch_core/error_codes.h +5 -3
  54. package/cpp/patch_core/install_record.cpp +430 -0
  55. package/cpp/patch_core/install_record.h +163 -9
  56. package/cpp/patch_core/patch_core.cpp +62 -16
  57. package/cpp/patch_core/patch_core.h +27 -7
  58. package/cpp/patch_core/patch_manifest.cpp +201 -0
  59. package/cpp/patch_core/patch_manifest.h +74 -0
  60. package/cpp/patch_core/state_ops.h +3 -3
  61. package/cpp/update_flow_core/flow_json.cpp +82 -8
  62. package/cpp/update_flow_core/flow_json.h +21 -0
  63. package/cpp/update_flow_core/native_config.cpp +456 -0
  64. package/cpp/update_flow_core/native_config.h +84 -0
  65. package/cpp/update_flow_core/native_round.cpp +326 -0
  66. package/cpp/update_flow_core/native_round.h +90 -0
  67. package/cpp/update_flow_core/update_flow_core.h +2 -2
  68. package/harmony/hvigor-plugin.ts +11 -19
  69. package/harmony/pushy/src/main/cpp/CMakeLists.txt +19 -26
  70. package/harmony/pushy/src/main/cpp/PushyPackage.h +2 -2
  71. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +55 -94
  72. package/harmony/pushy/src/main/cpp/pushy.cpp +694 -517
  73. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +48 -72
  74. package/harmony/pushy/src/main/ets/DownloadTask.ts +164 -404
  75. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +1 -3
  76. package/harmony/pushy/src/main/ets/ErrorCodes.ts +1 -14
  77. package/harmony/pushy/src/main/ets/EventHub.ts +1 -1
  78. package/harmony/pushy/src/main/ets/InstallRecord.ts +52 -151
  79. package/harmony/pushy/src/main/ets/Logger.ts +20 -34
  80. package/harmony/pushy/src/main/ets/NativePatchCore.ts +116 -42
  81. package/harmony/pushy/src/main/ets/PushyConfiguration.ts +8 -85
  82. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -25
  83. package/harmony/pushy/src/main/ets/SaveFile.ts +99 -18
  84. package/harmony/pushy/src/main/ets/SyncCoordinator.ts +136 -335
  85. package/harmony/pushy/src/main/ets/Texts.ts +0 -4
  86. package/harmony/pushy/src/main/ets/UpdateContext.ts +106 -119
  87. package/harmony/pushy.har +0 -0
  88. package/ios/RCTPushy/RCTPushy+Launch.mm +185 -0
  89. package/ios/RCTPushy/RCTPushy.h +12 -0
  90. package/ios/RCTPushy/RCTPushy.mm +87 -2628
  91. package/ios/RCTPushy/RCTPushyCommon.h +34 -0
  92. package/ios/RCTPushy/RCTPushyConfiguration.h +0 -8
  93. package/ios/RCTPushy/RCTPushyConfiguration.mm +40 -121
  94. package/ios/RCTPushy/RCTPushyDownloader.mm +40 -37
  95. package/ios/RCTPushy/RCTPushyInstallRecord.mm +61 -0
  96. package/ios/RCTPushy/RCTPushyInstaller.mm +689 -0
  97. package/ios/RCTPushy/RCTPushyInternal.h +144 -0
  98. package/ios/RCTPushy/RCTPushyOrchestrator.mm +1001 -0
  99. package/ios/RCTPushy/RCTPushyState.mm +314 -0
  100. package/package.json +25 -23
  101. package/react-native-update.podspec +74 -83
  102. package/src/client.ts +361 -451
  103. package/src/context.ts +1 -1
  104. package/src/core.ts +35 -8
  105. package/src/endpoint.ts +0 -2
  106. package/src/error.ts +13 -32
  107. package/src/i18n.ts +18 -83
  108. package/src/locales/en.ts +0 -4
  109. package/src/locales/zh.ts +0 -4
  110. package/src/provider.tsx +60 -53
  111. package/src/telemetry.ts +0 -13
  112. package/src/type.ts +0 -15
  113. package/src/updateFlowCore.ts +116 -10
  114. package/src/utils.ts +49 -42
  115. package/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java +0 -6
  116. package/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +0 -104
  117. package/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +0 -37
  118. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +0 -121
  119. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +0 -125
  120. package/cpp/patch_core/jni_natives.h +0 -27
  121. package/cpp/patch_core/jni_registration.cpp +0 -103
  122. package/cpp/patch_core/jni_util.h +0 -60
  123. package/cpp/patch_core/patch_core_android.cpp +0 -97
  124. package/cpp/patch_core/update_core_android.cpp +0 -485
  125. package/cpp/update_flow_core/update_flow_jni.cpp +0 -150
package/src/client.ts CHANGED
@@ -1,8 +1,4 @@
1
- import {
2
- DeviceEventEmitter,
3
- type EmitterSubscription,
4
- Platform,
5
- } from 'react-native';
1
+ import { DeviceEventEmitter, Platform } from 'react-native';
6
2
  import {
7
3
  buildTime,
8
4
  cInfo,
@@ -11,6 +7,7 @@ import {
11
7
  getBundleHash,
12
8
  isFirstTime,
13
9
  isRolledBack,
10
+ nativeMethod,
14
11
  PushyModule,
15
12
  packageVersion,
16
13
  pushyNativeEventEmitter,
@@ -18,7 +15,7 @@ import {
18
15
  setLocalHashInfo,
19
16
  supportedDiffVersion,
20
17
  } from './core';
21
- import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
18
+ import { executeEndpointFallback } from './endpoint';
22
19
  import {
23
20
  readErrorCode,
24
21
  toUpdateError,
@@ -53,8 +50,11 @@ import {
53
50
  type DownloadPlan,
54
51
  type DownloadStrategyType,
55
52
  decideDownload,
53
+ dedupeEndpoints,
56
54
  isMirrorRetryableCode,
57
55
  isValidCheckResult,
56
+ jsonDeepEqual,
57
+ nativeConfigsEquivalent,
58
58
  } from './updateFlowCore';
59
59
  import {
60
60
  assertWeb,
@@ -70,6 +70,14 @@ import {
70
70
  warn,
71
71
  } from './utils';
72
72
 
73
+ /** One report() event: the logger payload and the source of server telemetry. */
74
+ interface ReportEvent {
75
+ type: EventType;
76
+ message?: string;
77
+ code?: UpdateErrorCode;
78
+ data?: Record<string, string | number>;
79
+ }
80
+
73
81
  /**
74
82
  * Receives every error the client reports, alongside the report event type.
75
83
  * The UpdateProvider subscribes to surface errors as lastError/Alert; user
@@ -114,14 +122,6 @@ const RELOAD_WATCHDOG_MS = 5000;
114
122
  // for the error message (it can end up in an alert) and the logger.
115
123
  const MAX_HTTP_ERROR_TEXT = 200;
116
124
 
117
- const excludeConfiguredEndpoints = (
118
- endpoints: string[],
119
- configuredEndpoints: string[]
120
- ) => {
121
- const configured = new Set(configuredEndpoints);
122
- return endpoints.filter((endpoint) => !configured.has(endpoint));
123
- };
124
-
125
125
  assertWeb();
126
126
 
127
127
  const createDefaultClientOptions = (): ClientOptions => ({
@@ -136,7 +136,6 @@ const createDefaultClientOptions = (): ClientOptions => ({
136
136
  });
137
137
 
138
138
  export const sharedState: {
139
- progressHandlers: Record<string, EmitterSubscription>;
140
139
  downloadingTasks: Record<string, Promise<string | undefined>>;
141
140
  // Progress callbacks per hash: concurrent downloadUpdate callers of the
142
141
  // same hash each register theirs here instead of the second one being
@@ -148,7 +147,6 @@ export const sharedState: {
148
147
  marked: boolean;
149
148
  applyingUpdate: boolean;
150
149
  } = {
151
- progressHandlers: {},
152
150
  downloadingTasks: {},
153
151
  progressCallbacks: {},
154
152
  downloadedHash: undefined,
@@ -157,6 +155,19 @@ export const sharedState: {
157
155
  applyingUpdate: false,
158
156
  };
159
157
 
158
+ // The native cold-start check writes its response cache at most once per
159
+ // launch (and skips it once JS has checked), so once this process has seen it
160
+ // absent or expired, later reads are near-certain misses: skip the bridge
161
+ // call from then on. The rare miss costs one network check. A fresh entry
162
+ // that only mismatches the request or config does not set this: a later check
163
+ // with the original options (e.g. after `extra` reverts) can still reuse it.
164
+ let nativeCheckCacheExhausted = false;
165
+ const NATIVE_CHECK_CACHE_MAX_AGE_S = 120;
166
+
167
+ // In-flight native markSuccess shared by concurrent callers, so the native
168
+ // call and its telemetry happen once. Cleared once settled.
169
+ let markingPromise: Promise<void> | undefined;
170
+
160
171
  // A reset/new switch invalidates ownership of older async completions and
161
172
  // watchdogs before the process-wide applyingUpdate flag can be reused.
162
173
  let applyingUpdateGeneration = 0;
@@ -242,6 +253,8 @@ export class Pushy {
242
253
  private syncedNativeConfigJson?: string;
243
254
  private pendingNativeConfigJson?: string;
244
255
  private nativeConfigSyncInFlight = false;
256
+ // Config last acknowledged by markJsCheckCompleted.
257
+ private jsCheckCompletedConfigJson?: string;
245
258
  private reportedInvalidUpdates = new Set<string>();
246
259
  private reportedExceptions = new WeakSet<object>();
247
260
  private globalErrorCleanup?: () => void;
@@ -362,12 +375,8 @@ export class Pushy {
362
375
 
363
376
  /** Build the subset of options used by the native cold-start check. */
364
377
  private getNativeConfig = (): Record<string, unknown> | undefined => {
365
- if (
366
- Platform.OS === 'web' ||
367
- typeof PushyModule.syncNativeConfig !== 'function'
368
- ) {
369
- // Older natives lack the method; on web PushyModule is a noop Proxy
370
- // and the feature-detect would false-positive.
378
+ if (!nativeMethod('syncNativeConfig')) {
379
+ // Web, or an older native without the native check.
371
380
  return undefined;
372
381
  }
373
382
  const {
@@ -422,9 +431,11 @@ export class Pushy {
422
431
  private flushNativeConfig = () => {
423
432
  if (this.options.nativeConfigSource === 'native') {
424
433
  this.pendingNativeConfigJson = undefined;
434
+ this.syncedNativeConfigJson = undefined;
425
435
  return;
426
436
  }
427
- if (this.nativeConfigSyncInFlight) {
437
+ const sync = nativeMethod('syncNativeConfig');
438
+ if (!sync || this.nativeConfigSyncInFlight) {
428
439
  return;
429
440
  }
430
441
  const configJson = this.pendingNativeConfigJson;
@@ -433,17 +444,14 @@ export class Pushy {
433
444
  return;
434
445
  }
435
446
  this.nativeConfigSyncInFlight = true;
436
- let syncResult: Promise<void>;
437
- try {
438
- syncResult = Promise.resolve(PushyModule.syncNativeConfig(configJson));
439
- } catch (e: any) {
440
- this.nativeConfigSyncInFlight = false;
441
- log('syncNativeConfig failed:', e?.message || e);
442
- return;
443
- }
444
- syncResult
447
+ sync(configJson)
445
448
  .then(() => {
446
- this.syncedNativeConfigJson = configJson;
449
+ // A switch to 'native' while this write was in flight hands storage
450
+ // to the host; remembering the write would skip it after switching
451
+ // back.
452
+ if (this.options.nativeConfigSource !== 'native') {
453
+ this.syncedNativeConfigJson = configJson;
454
+ }
447
455
  })
448
456
  .catch((e: any) => {
449
457
  log('syncNativeConfig failed:', e?.message || e);
@@ -462,36 +470,34 @@ export class Pushy {
462
470
  * rescue must keep running when JS could not reach the server.
463
471
  */
464
472
  private markJsCheckCompleted = () => {
465
- if (
466
- Platform.OS === 'web' ||
467
- typeof PushyModule.markJsCheckCompleted !== 'function'
468
- ) {
473
+ const markCompleted = nativeMethod('markJsCheckCompleted');
474
+ if (!markCompleted) {
469
475
  return;
470
476
  }
471
477
  const configJson = this.getNativeConfigJson();
472
- if (!configJson) {
478
+ if (!configJson || configJson === this.jsCheckCompletedConfigJson) {
473
479
  return;
474
480
  }
475
- try {
476
- Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).catch(
477
- (e: any) => {
478
- log('markJsCheckCompleted failed:', e?.message || e);
479
- }
480
- );
481
- } catch (e: any) {
482
- log('markJsCheckCompleted failed:', e?.message || e);
483
- }
481
+ markCompleted(configJson).then(
482
+ () => {
483
+ this.jsCheckCompletedConfigJson = configJson;
484
+ },
485
+ (e: any) => {
486
+ log('markJsCheckCompleted failed:', e?.message || e);
487
+ }
488
+ );
484
489
  };
485
490
 
486
491
  private syncNativeConfig = () => {
487
492
  if (this.options.nativeConfigSource === 'native') {
493
+ // The host owns the stored config now; forget what JS last wrote so a
494
+ // switch back to 'javascript' writes again instead of deduping
495
+ // against a value native storage no longer holds.
488
496
  this.pendingNativeConfigJson = undefined;
497
+ this.syncedNativeConfigJson = undefined;
489
498
  return;
490
499
  }
491
- if (
492
- Platform.OS === 'web' ||
493
- typeof PushyModule.syncNativeConfig !== 'function'
494
- ) {
500
+ if (!nativeMethod('syncNativeConfig')) {
495
501
  return;
496
502
  }
497
503
  const configJson = this.getNativeConfigJson();
@@ -518,50 +524,6 @@ export class Pushy {
518
524
  getEffectivePackageVersion = () =>
519
525
  this.options.overridePackageVersion || packageVersion;
520
526
 
521
- private jsonValuesEqual = (left: unknown, right: unknown): boolean => {
522
- const compare = (a: unknown, b: unknown): boolean => {
523
- if (a === b) {
524
- return true;
525
- }
526
- if (Array.isArray(a) || Array.isArray(b)) {
527
- return (
528
- Array.isArray(a) &&
529
- Array.isArray(b) &&
530
- a.length === b.length &&
531
- a.every((value, index) => compare(value, b[index]))
532
- );
533
- }
534
- if (
535
- a === null ||
536
- b === null ||
537
- typeof a !== 'object' ||
538
- typeof b !== 'object'
539
- ) {
540
- return false;
541
- }
542
- const leftObject = a as Record<string, unknown>;
543
- const rightObject = b as Record<string, unknown>;
544
- // Match JSON.stringify semantics for request extras: object properties
545
- // whose value is undefined are omitted from the wire fingerprint.
546
- const leftKeys = Object.keys(leftObject)
547
- .filter((key) => leftObject[key] !== undefined)
548
- .sort();
549
- const rightKeys = Object.keys(rightObject)
550
- .filter((key) => rightObject[key] !== undefined)
551
- .sort();
552
- return (
553
- leftKeys.length === rightKeys.length &&
554
- leftKeys.every(
555
- (key, index) =>
556
- key === rightKeys[index] &&
557
- compare(leftObject[key], rightObject[key])
558
- )
559
- );
560
- };
561
-
562
- return compare(left, right);
563
- };
564
-
565
527
  private providerMounted = false;
566
528
  /**
567
529
  * Called by UpdateProvider on mount. A second concurrently mounted
@@ -624,7 +586,8 @@ export class Pushy {
624
586
  if (
625
587
  __DEV__ ||
626
588
  this.options.disableTelemetry ||
627
- this.options.disableErrorReporting
589
+ this.options.disableErrorReporting ||
590
+ !currentVersion
628
591
  ) {
629
592
  return;
630
593
  }
@@ -638,7 +601,11 @@ export class Pushy {
638
601
  }
639
602
  this.reportedExceptions.add(object);
640
603
  }
641
- this.reportExceptionToServer(serializeException(error, context));
604
+ this.postReport({
605
+ type: 'js_exception',
606
+ hash: currentVersion,
607
+ error: serializeException(error, context),
608
+ });
642
609
  } catch (e: any) {
643
610
  log('exception telemetry error:', e?.message || e);
644
611
  }
@@ -682,55 +649,7 @@ export class Pushy {
682
649
  return cleanup;
683
650
  };
684
651
 
685
- private reportExceptionToServer = (error: SerializedException): void => {
686
- try {
687
- if (
688
- __DEV__ ||
689
- this.options.disableTelemetry ||
690
- this.options.disableErrorReporting ||
691
- !currentVersion
692
- ) {
693
- return;
694
- }
695
- const { appKey } = this.options;
696
- const endpoint =
697
- this.lastWorkingEndpoint || this.options.server?.main?.[0];
698
- if (!appKey || !endpoint) {
699
- return;
700
- }
701
- fetchWithTimeout(
702
- `${endpoint}/report/${appKey}`,
703
- {
704
- method: 'POST',
705
- headers: { 'Content-Type': 'application/json' },
706
- body: JSON.stringify({
707
- type: 'js_exception',
708
- hash: currentVersion,
709
- packageVersion: this.getEffectivePackageVersion(),
710
- cInfo,
711
- error,
712
- }),
713
- },
714
- DEFAULT_FETCH_TIMEOUT_MS
715
- ).catch((e: any) => {
716
- log('exception telemetry report failed:', e?.message || e);
717
- });
718
- } catch (e: any) {
719
- log('exception telemetry error:', e?.message || e);
720
- }
721
- };
722
-
723
- report = async ({
724
- type,
725
- message = '',
726
- code,
727
- data = {},
728
- }: {
729
- type: EventType;
730
- message?: string;
731
- code?: UpdateErrorCode;
732
- data?: Record<string, string | number>;
733
- }) => {
652
+ report = async ({ type, message = '', code, data = {} }: ReportEvent) => {
734
653
  log(`${type} ${code ? `[${code}] ` : ''}${message}`);
735
654
  // Fire-and-forget server telemetry; must not wait for the logger below.
736
655
  this.reportToServer({ type, message, code, data });
@@ -782,15 +701,10 @@ export class Pushy {
782
701
  */
783
702
  private reportToServer = ({
784
703
  type,
785
- message = '',
704
+ message,
786
705
  code,
787
706
  data = {},
788
- }: {
789
- type: EventType;
790
- message?: string;
791
- code?: UpdateErrorCode;
792
- data?: Record<string, string | number>;
793
- }) => {
707
+ }: ReportEvent) => {
794
708
  try {
795
709
  if (__DEV__ || this.options.disableTelemetry) {
796
710
  return;
@@ -799,45 +713,56 @@ export class Pushy {
799
713
  if (!serverType) {
800
714
  return;
801
715
  }
802
- const { appKey } = this.options;
803
- const endpoint =
804
- this.lastWorkingEndpoint || this.options.server?.main?.[0];
805
- if (!appKey || !endpoint) {
806
- return;
807
- }
808
716
  const hash = resolveServerEventHash({ serverType, data, currentVersion });
809
717
  if (!hash) {
810
718
  return;
811
719
  }
812
- const send = (payloadType: typeof serverType, detail?: string) =>
813
- fetchWithTimeout(
814
- `${endpoint}/report/${appKey}`,
815
- {
816
- method: 'POST',
817
- headers: { 'Content-Type': 'application/json' },
818
- body: JSON.stringify({
819
- type: payloadType,
820
- hash,
821
- packageVersion: this.getEffectivePackageVersion(),
822
- cInfo,
823
- detail: truncateDetail(detail),
824
- }),
825
- },
826
- DEFAULT_FETCH_TIMEOUT_MS
827
- ).catch((e: any) => {
828
- log('telemetry report failed:', e?.message || e);
829
- });
830
- send(serverType, message || undefined);
831
- // A download that only succeeded after an incremental patch failed is
832
- // still a patch_fail signal server-side (diff quality), carried in
833
- // data.error alongside the downloadSuccess event.
834
- if (serverType === 'download_success' && data.error) {
835
- send('patch_fail', String(data.error));
836
- }
720
+ // A download rescued by a later strategy reports its patch-health
721
+ // signal through downloadFallback, which only counts patch-apply
722
+ // failures; downloadSuccess's data.error (any earlier failure, network
723
+ // errors included) stays local.
724
+ this.postReport({
725
+ type: serverType,
726
+ hash,
727
+ detail: truncateDetail(message),
728
+ });
837
729
  } catch (e: any) {
838
730
  log('telemetry error:', e?.message || e);
839
731
  }
840
732
  };
733
+ /**
734
+ * The single telemetry POST ({endpoint}/report/{appKey}) to the last known
735
+ * working endpoint; no retry, failures only logged.
736
+ */
737
+ private postReport = ({
738
+ type,
739
+ hash,
740
+ ...payload
741
+ }: {
742
+ type: string;
743
+ hash: string;
744
+ detail?: string;
745
+ error?: SerializedException;
746
+ }) => {
747
+ const { appKey } = this.options;
748
+ const endpoint = this.lastWorkingEndpoint || this.options.server?.main?.[0];
749
+ if (!appKey || !endpoint) {
750
+ return;
751
+ }
752
+ fetchWithTimeout(`${endpoint}/report/${appKey}`, {
753
+ method: 'POST',
754
+ headers: { 'Content-Type': 'application/json' },
755
+ body: JSON.stringify({
756
+ type,
757
+ hash,
758
+ packageVersion: this.getEffectivePackageVersion(),
759
+ cInfo,
760
+ ...payload,
761
+ }),
762
+ }).catch((e: any) => {
763
+ log('telemetry report failed:', e?.message || e);
764
+ });
765
+ };
841
766
  throwIfEnabled = (e: Error) => {
842
767
  if (this.options.throwError) {
843
768
  throw e;
@@ -894,6 +819,32 @@ export class Pushy {
894
819
  }
895
820
  }
896
821
  };
822
+ /**
823
+ * toUpdateError + emitError: how a caught failure leaves the client.
824
+ * Returns the error for the caller to throw or pass to throwIfEnabled.
825
+ * `fallbackMessage` is reported when the error has no message of its own.
826
+ */
827
+ private fail = (
828
+ e: unknown,
829
+ code: UpdateErrorCode,
830
+ type: EventType,
831
+ {
832
+ message,
833
+ fallbackMessage,
834
+ data,
835
+ }: {
836
+ message?: string;
837
+ fallbackMessage?: string;
838
+ data?: Record<string, string | number>;
839
+ } = {}
840
+ ): UpdateError => {
841
+ const err = toUpdateError(e, code);
842
+ this.emitError(err, type, {
843
+ message: message ?? (err.message || fallbackMessage),
844
+ data,
845
+ });
846
+ return err;
847
+ };
897
848
  notifyAfterCheckUpdate = (state: UpdateCheckState) => {
898
849
  const { afterCheckUpdate } = this.options;
899
850
  if (!afterCheckUpdate) {
@@ -916,16 +867,9 @@ export class Pushy {
916
867
  }
917
868
  return true;
918
869
  };
919
- getCheckUrl = (endpoint: string) => {
920
- return `${endpoint}/checkUpdate/${this.options.appKey}`;
921
- };
922
- getConfiguredCheckEndpoints = () => {
923
- const { server } = this.options;
924
- if (!server) {
925
- return [];
926
- }
927
- return dedupeEndpoints(server.main);
928
- };
870
+ // server.main is deduplicated on assignment (cloneServerConfig), and the
871
+ // endpoint plan dedupes again anyway.
872
+ getConfiguredCheckEndpoints = () => this.options.server?.main ?? [];
929
873
  getRemoteEndpoints = async () => {
930
874
  const { server } = this.options;
931
875
  if (!server?.queryUrls?.length) {
@@ -938,11 +882,7 @@ export class Pushy {
938
882
  // with an error page must not beat the healthy one.
939
883
  const remoteEndpoints = await promiseAny(
940
884
  server.queryUrls.map(async (queryUrl) => {
941
- const resp = await fetchWithTimeout(
942
- queryUrl,
943
- {},
944
- DEFAULT_FETCH_TIMEOUT_MS
945
- );
885
+ const resp = await fetchWithTimeout(queryUrl, {});
946
886
  if (!resp.ok) {
947
887
  throw Error(`${queryUrl}: ${resp.status}`);
948
888
  }
@@ -954,16 +894,15 @@ export class Pushy {
954
894
  })
955
895
  );
956
896
  log('fetch endpoints:', remoteEndpoints);
957
- return excludeConfiguredEndpoints(
958
- dedupeEndpoints(
959
- remoteEndpoints.filter(
960
- // The list is fetched from a public mirror; it may add
961
- // endpoints but never move the check outside TLS.
962
- (endpoint): endpoint is string =>
963
- typeof endpoint === 'string' && /^https:\/\//i.test(endpoint)
964
- )
965
- ),
966
- this.getConfiguredCheckEndpoints()
897
+ // Configured and already-failed endpoints are dropped by the endpoint
898
+ // fallback itself.
899
+ return dedupeEndpoints(
900
+ remoteEndpoints.filter(
901
+ // The list is fetched from a public mirror; it may add endpoints
902
+ // but never move the check outside TLS.
903
+ (endpoint): endpoint is string =>
904
+ typeof endpoint === 'string' && /^https:\/\//i.test(endpoint)
905
+ )
967
906
  );
968
907
  } catch (e) {
969
908
  log('failed to fetch endpoints from: ', server.queryUrls, e);
@@ -974,54 +913,50 @@ export class Pushy {
974
913
  endpoint: string,
975
914
  fetchPayload: Parameters<typeof fetch>[1],
976
915
  signal?: AbortSignal
977
- ) => {
978
- const resp = await fetchWithTimeout(
979
- this.getCheckUrl(endpoint),
980
- fetchPayload,
981
- DEFAULT_FETCH_TIMEOUT_MS,
982
- signal
983
- );
984
-
985
- if (!resp.ok) {
986
- const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT);
987
- throw new UpdateError(
988
- this.t('error_http_status', {
989
- status: resp.status,
990
- statusText: respText,
991
- }),
992
- 'HTTP_STATUS',
993
- { extra: { status: resp.status } }
916
+ ): Promise<CheckResult> => {
917
+ try {
918
+ const resp = await fetchWithTimeout(
919
+ `${endpoint}/checkUpdate/${this.options.appKey}`,
920
+ fetchPayload,
921
+ DEFAULT_FETCH_TIMEOUT_MS,
922
+ signal
994
923
  );
995
- }
996
924
 
997
- const result: unknown = await resp.json();
998
- if (!isValidCheckResult(result)) {
999
- // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal page
1000
- // that parsed, ...) is a failed endpoint: throwing keeps the fallback
1001
- // moving to the next one instead of treating it as "no update".
1002
- throw new UpdateError(
1003
- this.t('error_invalid_check_response'),
1004
- 'INVALID_RESPONSE'
1005
- );
925
+ if (!resp.ok) {
926
+ const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT);
927
+ throw new UpdateError(
928
+ this.t('error_http_status', {
929
+ status: resp.status,
930
+ statusText: respText,
931
+ }),
932
+ 'HTTP_STATUS',
933
+ { extra: { status: resp.status } }
934
+ );
935
+ }
936
+
937
+ const result: unknown = await resp.json();
938
+ if (!isValidCheckResult(result)) {
939
+ // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal
940
+ // page that parsed, ...) is a failed endpoint: throwing keeps the
941
+ // fallback moving to the next one instead of treating it as "no
942
+ // update".
943
+ throw new UpdateError(
944
+ this.t('error_invalid_check_response'),
945
+ 'INVALID_RESPONSE'
946
+ );
947
+ }
948
+ return result;
949
+ } catch (e) {
950
+ log('check endpoint failed', endpoint, e);
951
+ throw e;
1006
952
  }
1007
- return result;
1008
953
  };
1009
954
  fetchCheckResult = async (fetchPayload: Parameters<typeof fetch>[1]) => {
1010
955
  const { endpoint, value } = await executeEndpointFallback<CheckResult>({
1011
956
  configuredEndpoints: this.getConfiguredCheckEndpoints(),
1012
957
  getRemoteEndpoints: this.getRemoteEndpoints,
1013
- tryEndpoint: async (currentEndpoint, signal) => {
1014
- try {
1015
- return await this.requestCheckResult(
1016
- currentEndpoint,
1017
- fetchPayload,
1018
- signal
1019
- );
1020
- } catch (e) {
1021
- log('check endpoint failed', currentEndpoint, e);
1022
- throw e;
1023
- }
1024
- },
958
+ tryEndpoint: (currentEndpoint, signal) =>
959
+ this.requestCheckResult(currentEndpoint, fetchPayload, signal),
1025
960
  onFirstFailure: ({ error }) => {
1026
961
  this.report({
1027
962
  type: 'errorChecking',
@@ -1046,34 +981,32 @@ export class Pushy {
1046
981
  requestBody: Record<string, any>
1047
982
  ): Promise<CheckResult | undefined> => {
1048
983
  try {
1049
- if (__DEV__ || typeof PushyModule.getNativeCheckCache !== 'function') {
984
+ const getCache = nativeMethod('getNativeCheckCache');
985
+ if (__DEV__ || nativeCheckCacheExhausted || !getCache) {
1050
986
  return undefined;
1051
987
  }
1052
- const raw = await Promise.resolve(PushyModule.getNativeCheckCache());
988
+ const raw = await getCache();
1053
989
  if (!raw || typeof raw !== 'string') {
990
+ nativeCheckCacheExhausted = true;
1054
991
  return undefined;
1055
992
  }
1056
993
  const entry = JSON.parse(raw);
1057
- const config = this.getNativeConfig();
1058
- const cachedRequest =
1059
- typeof entry?.request === 'string'
1060
- ? JSON.parse(entry.request)
1061
- : undefined;
1062
- const cachedConfig =
1063
- typeof entry?.config === 'string'
1064
- ? JSON.parse(entry.config)
1065
- : undefined;
1066
- if (
1067
- typeof entry?.ts !== 'number' ||
1068
- typeof entry?.body !== 'string' ||
1069
- !this.jsonValuesEqual(cachedRequest, requestBody) ||
1070
- !config ||
1071
- !this.jsonValuesEqual(cachedConfig, config)
1072
- ) {
994
+ // Age first: it is the common miss and needs no further parsing.
995
+ const ageSeconds =
996
+ typeof entry?.ts === 'number' ? Date.now() / 1000 - entry.ts : NaN;
997
+ if (!(ageSeconds >= 0 && ageSeconds <= NATIVE_CHECK_CACHE_MAX_AGE_S)) {
998
+ nativeCheckCacheExhausted = true;
1073
999
  return undefined;
1074
1000
  }
1075
- const ageSeconds = Date.now() / 1000 - entry.ts;
1076
- if (ageSeconds < 0 || ageSeconds > 120) {
1001
+ const configJson = this.getNativeConfigJson();
1002
+ if (
1003
+ typeof entry.body !== 'string' ||
1004
+ typeof entry.request !== 'string' ||
1005
+ typeof entry.config !== 'string' ||
1006
+ !configJson ||
1007
+ !nativeConfigsEquivalent(entry.config, configJson) ||
1008
+ !jsonDeepEqual(JSON.parse(entry.request), requestBody)
1009
+ ) {
1077
1010
  return undefined;
1078
1011
  }
1079
1012
  const result: unknown = JSON.parse(entry.body);
@@ -1100,12 +1033,23 @@ export class Pushy {
1100
1033
  if (sharedState.marked || __DEV__ || !isFirstTime) {
1101
1034
  return;
1102
1035
  }
1036
+ // Concurrent callers (auto mark + an explicit call) would all pass the
1037
+ // check above before the first native call settles: share it.
1038
+ if (!markingPromise) {
1039
+ const promise = this.performMarkSuccess().finally(() => {
1040
+ if (markingPromise === promise) {
1041
+ markingPromise = undefined;
1042
+ }
1043
+ });
1044
+ markingPromise = promise;
1045
+ }
1046
+ return markingPromise;
1047
+ };
1048
+ private performMarkSuccess = async () => {
1103
1049
  try {
1104
1050
  await Promise.resolve(PushyModule.markSuccess());
1105
1051
  } catch (e) {
1106
- const err = toUpdateError(e, 'MARK_SUCCESS_FAILED');
1107
- this.emitError(err, 'errorMarkSuccess');
1108
- throw err;
1052
+ throw this.fail(e, 'MARK_SUCCESS_FAILED', 'errorMarkSuccess');
1109
1053
  }
1110
1054
  sharedState.marked = true;
1111
1055
  this.report({ type: 'markSuccess' });
@@ -1142,15 +1086,14 @@ export class Pushy {
1142
1086
  return false;
1143
1087
  }
1144
1088
  } catch (e) {
1089
+ // Release before emitting: an onError listener may retry right away.
1145
1090
  finishApplyingUpdate(applyingGeneration);
1146
1091
  // A throw from the user's beforeReload hook is business-code failure,
1147
1092
  // not an update-pipeline one: give it a distinct code so telemetry
1148
1093
  // excludes it from the server-side patch-health stats.
1149
- const err = toUpdateError(e, 'USER_HOOK_ERROR');
1150
- this.emitError(err, 'errorSwitchVersion', {
1094
+ throw this.fail(e, 'USER_HOOK_ERROR', 'errorSwitchVersion', {
1151
1095
  data: { newVersion: hash },
1152
1096
  });
1153
- throw err;
1154
1097
  }
1155
1098
  // resetToPackagedBundle may have completed while beforeReload awaited.
1156
1099
  if (!ownsApplyingUpdate(applyingGeneration)) {
@@ -1161,11 +1104,9 @@ export class Pushy {
1161
1104
  } catch (e) {
1162
1105
  // Do not let a superseded rejection clear a newer switch.
1163
1106
  finishApplyingUpdate(applyingGeneration);
1164
- const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
1165
- this.emitError(err, 'errorSwitchVersion', {
1107
+ throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', {
1166
1108
  data: { newVersion: hash },
1167
1109
  });
1168
- throw err;
1169
1110
  }
1170
1111
  // A resolved reloadUpdate normally tears this JS context down right away.
1171
1112
  // iOS has been seen resolving without restarting (89c638e); a stuck
@@ -1192,11 +1133,9 @@ export class Pushy {
1192
1133
  try {
1193
1134
  return await PushyModule.setNeedUpdate({ hash });
1194
1135
  } catch (e) {
1195
- const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
1196
- this.emitError(err, 'errorSwitchVersion', {
1136
+ throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', {
1197
1137
  data: { newVersion: hash },
1198
1138
  });
1199
- throw err;
1200
1139
  }
1201
1140
  }
1202
1141
  };
@@ -1241,7 +1180,6 @@ export class Pushy {
1241
1180
  appKey: this.options.appKey,
1242
1181
  endpoints: this.options.server?.main,
1243
1182
  queryUrls: this.options.server?.queryUrls,
1244
- // The client uuid travels inside cInfo, i.e. inside `body`.
1245
1183
  body: stringifyBody,
1246
1184
  });
1247
1185
  const now = Date.now();
@@ -1325,9 +1263,8 @@ export class Pushy {
1325
1263
  this.lastRespJson = undefined;
1326
1264
  this.lastCheckFingerprint = undefined;
1327
1265
  }
1328
- const err = toUpdateError(e, 'CHECK_FAILED');
1329
- this.emitError(err, 'errorChecking', {
1330
- message: err.message || this.t('error_cannot_connect_server'),
1266
+ const err = this.fail(e, 'CHECK_FAILED', 'errorChecking', {
1267
+ fallbackMessage: this.t('error_cannot_connect_server'),
1331
1268
  });
1332
1269
  this.notifyAfterCheckUpdate({ status: 'error', error: err });
1333
1270
  this.throwIfEnabled(err);
@@ -1441,15 +1378,14 @@ export class Pushy {
1441
1378
  }
1442
1379
  sharedState.progressCallbacks[hash] = progressCallbacks;
1443
1380
  const dispatchProgress = (data: ProgressData) => {
1444
- const callbacks = sharedState.progressCallbacks[hash];
1445
- if (!callbacks || callbacks.size === 0) {
1381
+ if (progressCallbacks.size === 0) {
1446
1382
  return;
1447
1383
  }
1448
1384
  const payload = {
1449
1385
  ...data,
1450
1386
  progress: computeProgress(data.received, data.total),
1451
1387
  };
1452
- callbacks.forEach((callback) => {
1388
+ progressCallbacks.forEach((callback) => {
1453
1389
  // One subscriber throwing must not starve the others or abort the
1454
1390
  // download: business callbacks are isolated, the error is logged.
1455
1391
  try {
@@ -1469,17 +1405,10 @@ export class Pushy {
1469
1405
  }
1470
1406
  };
1471
1407
  // @ts-expect-error harmony not in existing platforms
1472
- if (Platform.OS === 'harmony') {
1473
- sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener(
1474
- 'RCTPushyDownloadProgress',
1475
- onNativeProgress
1476
- );
1477
- } else {
1478
- sharedState.progressHandlers[hash] = pushyNativeEventEmitter.addListener(
1479
- 'RCTPushyDownloadProgress',
1480
- onNativeProgress
1481
- );
1482
- }
1408
+ const isHarmony = Platform.OS === 'harmony';
1409
+ const progressSubscription = (
1410
+ isHarmony ? DeviceEventEmitter : pushyNativeEventEmitter
1411
+ ).addListener('RCTPushyDownloadProgress', onNativeProgress);
1483
1412
  const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3));
1484
1413
  let succeeded = '';
1485
1414
  let lastError: UpdateError | undefined;
@@ -1489,10 +1418,6 @@ export class Pushy {
1489
1418
  // message, so the final report still names it — and skip it on later
1490
1419
  // attempts instead of downloading and patching it again.
1491
1420
  const exhaustedStrategies = new Map<DownloadStrategyType, string>();
1492
- // Whether any strategy, in any attempt, failed at the patch stage. The
1493
- // downloadFallback report carries that as its code even when a later,
1494
- // unrelated transport failure happened to be the last error seen.
1495
- let patchFailed = false;
1496
1421
 
1497
1422
  // The ordered attempts come from decideDownload (the pure decision layer);
1498
1423
  // this side only executes them: probe candidate URLs, run the matching
@@ -1518,98 +1443,112 @@ export class Pushy {
1518
1443
  hash,
1519
1444
  }),
1520
1445
  };
1446
+ // Mirror ranking per strategy, kept across retry attempts: the HEAD race
1447
+ // runs once per strategy per download instead of once per attempt. A
1448
+ // winner that failed its download is demoted to the back for the next
1449
+ // attempt (every mirror is tried on every attempt anyway).
1450
+ const rankedUrls = new Map<DownloadStrategyType, string[]>();
1521
1451
  const errorKeys = {
1522
1452
  diff: 'error_diff_failed',
1523
1453
  pdiff: 'error_pdiff_failed',
1524
1454
  full: 'error_full_patch_failed',
1525
1455
  } as const;
1526
1456
 
1527
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1528
- if (attempt > 0) {
1529
- // Exponential backoff with jitter: a bad CDN edge otherwise gets
1530
- // every client back at the same 1/2/4s marks.
1531
- const backoffMs = Math.round(
1532
- Math.min(1000 * 2 ** (attempt - 1), 10000) *
1533
- (0.75 + Math.random() * 0.5)
1534
- );
1535
- log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`);
1536
- await new Promise<void>((r) => setTimeout(() => r(), backoffMs));
1537
- errorMessages.length = 0;
1538
- errorMessages.push(...exhaustedStrategies.values());
1539
- lastError = undefined;
1540
- succeeded = '';
1541
- }
1542
- this.report({
1543
- type: 'downloading',
1544
- data: {
1545
- newVersion: hash,
1546
- attempt,
1547
- },
1548
- });
1549
- for (const { type, urls } of attempts) {
1550
- if (succeeded) {
1551
- break;
1552
- }
1553
- if (exhaustedStrategies.has(type)) {
1554
- continue;
1457
+ try {
1458
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1459
+ if (attempt > 0) {
1460
+ // Exponential backoff with jitter: a bad CDN edge otherwise gets
1461
+ // every client back at the same 1/2/4s marks.
1462
+ const backoffMs = Math.round(
1463
+ Math.min(1000 * 2 ** (attempt - 1), 10000) *
1464
+ (0.75 + Math.random() * 0.5)
1465
+ );
1466
+ log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`);
1467
+ await new Promise<void>((r) => setTimeout(() => r(), backoffMs));
1468
+ errorMessages.length = 0;
1469
+ errorMessages.push(...exhaustedStrategies.values());
1470
+ lastError = undefined;
1471
+ succeeded = '';
1555
1472
  }
1556
- // The HEAD race only ranks mirrors; it is not proof that a mirror can
1557
- // serve the whole artifact (CDNs answer HEAD and GET differently, and
1558
- // a transfer can die mid-stream). Every mirror of the same artifact
1559
- // gets a real download attempt before the strategy is abandoned —
1560
- // the native cold-start path already behaves this way.
1561
- const orderedUrls = await this.rankDownloadUrls(urls);
1562
- for (const url of orderedUrls) {
1563
- log(`downloading ${type} from ${url}`);
1564
- try {
1565
- await runners[type](url);
1566
- succeeded = type;
1473
+ this.report({
1474
+ type: 'downloading',
1475
+ data: {
1476
+ newVersion: hash,
1477
+ attempt,
1478
+ },
1479
+ });
1480
+ for (const { type, urls } of attempts) {
1481
+ if (succeeded) {
1567
1482
  break;
1568
- } catch (e: unknown) {
1569
- // The native rejection's stable code (e.g. PATCH_FAILED vs
1570
- // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the
1571
- // `code` property or, from bridges that cannot set it, as a
1572
- // `[CODE] ` message prefix. Either way it survives into the
1573
- // i18n-wrapped error together with the original as cause.
1574
- const { code, message } = readErrorCode(e);
1575
- const errorMessage = this.t(errorKeys[type], {
1576
- message: message || String(e ?? ''),
1577
- });
1578
- errorMessages.push(errorMessage);
1579
- lastError = new UpdateError(
1580
- errorMessage,
1581
- code ?? 'DOWNLOAD_FAILED',
1582
- {
1583
- cause: e,
1584
- }
1585
- );
1586
- log(errorMessage);
1587
- if (lastError.code === 'PATCH_FAILED') {
1588
- patchFailed = true;
1589
- }
1590
- if (!isMirrorRetryableCode(lastError.code)) {
1591
- // The bytes arrived but could not be applied (patch/manifest
1592
- // mismatch): every mirror serves the same artifact, so move
1593
- // on to the next strategy instead of re-downloading it.
1594
- exhaustedStrategies.set(type, errorMessage);
1483
+ }
1484
+ if (exhaustedStrategies.has(type)) {
1485
+ continue;
1486
+ }
1487
+ // The HEAD race only ranks mirrors; it is not proof that a mirror can
1488
+ // serve the whole artifact (CDNs answer HEAD and GET differently, and
1489
+ // a transfer can die mid-stream). Every mirror of the same artifact
1490
+ // gets a real download attempt before the strategy is abandoned —
1491
+ // the native cold-start path already behaves this way.
1492
+ let orderedUrls = rankedUrls.get(type);
1493
+ if (!orderedUrls) {
1494
+ orderedUrls = await this.rankDownloadUrls(urls);
1495
+ rankedUrls.set(type, orderedUrls);
1496
+ }
1497
+ for (const url of orderedUrls) {
1498
+ log(`downloading ${type} from ${url}`);
1499
+ try {
1500
+ await runners[type](url);
1501
+ succeeded = type;
1595
1502
  break;
1503
+ } catch (e: unknown) {
1504
+ // The native rejection's stable code (e.g. PATCH_FAILED vs
1505
+ // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the
1506
+ // `code` property or, from bridges that cannot set it, as a
1507
+ // `[CODE] ` message prefix. Either way it survives into the
1508
+ // i18n-wrapped error together with the original as cause.
1509
+ const { code, message } = readErrorCode(e);
1510
+ if (url === orderedUrls[0] && orderedUrls.length > 1) {
1511
+ rankedUrls.set(type, [...orderedUrls.slice(1), url]);
1512
+ }
1513
+ const errorMessage = this.t(errorKeys[type], {
1514
+ message: message || String(e ?? ''),
1515
+ });
1516
+ errorMessages.push(errorMessage);
1517
+ lastError = new UpdateError(
1518
+ errorMessage,
1519
+ code ?? 'DOWNLOAD_FAILED',
1520
+ {
1521
+ cause: e,
1522
+ }
1523
+ );
1524
+ log(errorMessage);
1525
+ if (!isMirrorRetryableCode(lastError.code)) {
1526
+ // The bytes arrived but could not be applied (patch/manifest
1527
+ // mismatch): every mirror serves the same artifact, so move
1528
+ // on to the next strategy instead of re-downloading it.
1529
+ exhaustedStrategies.set(type, errorMessage);
1530
+ break;
1531
+ }
1596
1532
  }
1597
1533
  }
1598
1534
  }
1535
+ if (
1536
+ succeeded ||
1537
+ attempts.every(({ type }) => exhaustedStrategies.has(type))
1538
+ ) {
1539
+ // Nothing left that a retry could change.
1540
+ break;
1541
+ }
1599
1542
  }
1600
- if (
1601
- succeeded ||
1602
- attempts.every(({ type }) => exhaustedStrategies.has(type))
1603
- ) {
1604
- // Nothing left that a retry could change.
1605
- break;
1606
- }
1607
- }
1608
- if (sharedState.progressHandlers[hash]) {
1609
- sharedState.progressHandlers[hash].remove();
1610
- delete sharedState.progressHandlers[hash];
1611
- }
1612
- delete sharedState.progressCallbacks[hash];
1543
+ } finally {
1544
+ progressSubscription.remove();
1545
+ delete sharedState.progressCallbacks[hash];
1546
+ }
1547
+ // Only a patch-stage failure exhausts a strategy (isMirrorRetryableCode),
1548
+ // and an exhausted strategy is never retried: these are exactly the patch
1549
+ // failures, each once.
1550
+ const patchErrorMessages = [...exhaustedStrategies.values()];
1551
+ const patchFailed = patchErrorMessages.length > 0;
1613
1552
  if (succeeded && errorMessages.length > 0) {
1614
1553
  // An earlier strategy failed and a later one rescued the download
1615
1554
  // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface
@@ -1617,50 +1556,40 @@ export class Pushy {
1617
1556
  // platform that incremental delivery is failing for this binary.
1618
1557
  this.report({
1619
1558
  type: 'downloadFallback',
1620
- // The patch-health signal wins over whatever transport failure
1621
- // happened to come last (diff PATCH_FAILED, then pdiff network
1622
- // error, then full succeeded is still a patch_fail server-side).
1559
+ // The patch-health signal (any strategy, in any attempt, failing at
1560
+ // the patch stage) wins over whatever transport failure happened to
1561
+ // come last (diff PATCH_FAILED, then pdiff network error, then full
1562
+ // succeeded is still a patch_fail server-side).
1623
1563
  code: patchFailed ? 'PATCH_FAILED' : lastError?.code,
1624
1564
  data: {
1625
1565
  newVersion: hash,
1626
1566
  succeeded,
1627
1567
  },
1628
- message: errorMessages.join(';'),
1568
+ // Only the patch failures: a mirror's timeout alongside them would
1569
+ // otherwise be read as the cause server-side.
1570
+ message: (patchFailed ? patchErrorMessages : errorMessages).join(';'),
1629
1571
  });
1630
1572
  }
1631
1573
  if (!succeeded) {
1632
- const message = errorMessages.join(';');
1633
- if (lastError) {
1634
- const err = toUpdateError(lastError, 'DOWNLOAD_FAILED');
1635
- this.emitError(err, 'errorUpdate', {
1636
- message,
1637
- data: { newVersion: hash },
1638
- });
1639
- throw err;
1640
- }
1641
- // No download URL was even attempted (e.g. dev without a full URL):
1642
- // report for diagnostics but there is no error object to surface.
1643
- this.report({
1644
- type: 'errorUpdate',
1574
+ // Every attempt runs at least one download (a plan always has an
1575
+ // artifact URL, devNoop returned above), so a failure left lastError.
1576
+ throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', {
1577
+ message: errorMessages.join(';'),
1645
1578
  data: { newVersion: hash },
1646
- message,
1647
- });
1648
- return;
1649
- } else {
1650
- const duration = Date.now() - patchStartTime;
1651
- const data: Record<string, any> = {
1652
- newVersion: hash,
1653
- diff: succeeded,
1654
- duration,
1655
- };
1656
- if (errorMessages.length > 0) {
1657
- data.error = errorMessages.join(';');
1658
- }
1659
- this.report({
1660
- type: 'downloadSuccess',
1661
- data,
1662
1579
  });
1663
1580
  }
1581
+ const data: Record<string, any> = {
1582
+ newVersion: hash,
1583
+ diff: succeeded,
1584
+ duration: Date.now() - patchStartTime,
1585
+ };
1586
+ if (errorMessages.length > 0) {
1587
+ data.error = errorMessages.join(';');
1588
+ }
1589
+ this.report({
1590
+ type: 'downloadSuccess',
1591
+ data,
1592
+ });
1664
1593
  log(`downloaded ${succeeded} hash:`, hash);
1665
1594
  const hashInfo: Record<string, any> = {
1666
1595
  name,
@@ -1676,12 +1605,9 @@ export class Pushy {
1676
1605
  try {
1677
1606
  await setLocalHashInfo(hash, hashInfo);
1678
1607
  } catch (e: any) {
1679
- const err = toUpdateError(e, 'FILE_OPERATION_FAILED');
1680
- this.emitError(err, 'errorUpdate', {
1681
- message: err.message,
1608
+ throw this.fail(e, 'FILE_OPERATION_FAILED', 'errorUpdate', {
1682
1609
  data: { newVersion: hash },
1683
1610
  });
1684
- throw err;
1685
1611
  }
1686
1612
  sharedState.downloadedHash = hash;
1687
1613
  return hash;
@@ -1708,12 +1634,8 @@ export class Pushy {
1708
1634
  sharedState.apkStatus = 'downloading';
1709
1635
  this.report({ type: 'downloadingApk' });
1710
1636
  const progressKey = 'downloadingApk';
1711
- if (onDownloadProgress) {
1712
- if (sharedState.progressHandlers[progressKey]) {
1713
- sharedState.progressHandlers[progressKey].remove();
1714
- }
1715
- sharedState.progressHandlers[progressKey] =
1716
- pushyNativeEventEmitter.addListener(
1637
+ const progressSubscription = onDownloadProgress
1638
+ ? pushyNativeEventEmitter.addListener(
1717
1639
  'RCTPushyDownloadProgress',
1718
1640
  (event: unknown) => {
1719
1641
  const progressData = event as ProgressData;
@@ -1721,8 +1643,8 @@ export class Pushy {
1721
1643
  onDownloadProgress(progressData);
1722
1644
  }
1723
1645
  }
1724
- );
1725
- }
1646
+ )
1647
+ : undefined;
1726
1648
  try {
1727
1649
  await PushyModule.downloadAndInstallApk({
1728
1650
  url,
@@ -1733,16 +1655,13 @@ export class Pushy {
1733
1655
  } catch (e) {
1734
1656
  sharedState.apkStatus = null;
1735
1657
  // Keep the native error (message/stack) instead of discarding it.
1736
- const err = toUpdateError(e, 'APK_DOWNLOAD_FAILED');
1737
- this.emitError(err, 'errorDownloadAndInstallApk', {
1738
- message: err.message || this.t('error_apk_download_failed'),
1739
- });
1740
- this.throwIfEnabled(err);
1658
+ this.throwIfEnabled(
1659
+ this.fail(e, 'APK_DOWNLOAD_FAILED', 'errorDownloadAndInstallApk', {
1660
+ fallbackMessage: this.t('error_apk_download_failed'),
1661
+ })
1662
+ );
1741
1663
  } finally {
1742
- if (sharedState.progressHandlers[progressKey]) {
1743
- sharedState.progressHandlers[progressKey].remove();
1744
- delete sharedState.progressHandlers[progressKey];
1745
- }
1664
+ progressSubscription?.remove();
1746
1665
  }
1747
1666
  };
1748
1667
  restartApp = async () => {
@@ -1751,16 +1670,12 @@ export class Pushy {
1751
1670
  return;
1752
1671
  }
1753
1672
  } catch (e) {
1754
- const err = toUpdateError(e, 'USER_HOOK_ERROR');
1755
- this.emitError(err, 'errorRestart');
1756
- throw err;
1673
+ throw this.fail(e, 'USER_HOOK_ERROR', 'errorRestart');
1757
1674
  }
1758
1675
  try {
1759
1676
  return await PushyModule.restartApp();
1760
1677
  } catch (e) {
1761
- const err = toUpdateError(e, 'RESTART_FAILED');
1762
- this.emitError(err, 'errorRestart');
1763
- throw err;
1678
+ throw this.fail(e, 'RESTART_FAILED', 'errorRestart');
1764
1679
  }
1765
1680
  };
1766
1681
  /**
@@ -1778,13 +1693,10 @@ export class Pushy {
1778
1693
  restart?: boolean;
1779
1694
  }): Promise<boolean> => {
1780
1695
  if (!assertWeb()) {
1781
- // On web PushyModule is a Proxy of noops, so the feature-detect below
1782
- // would report a false success.
1783
1696
  return false;
1784
1697
  }
1785
- if (typeof PushyModule.resetToPackagedBundle !== 'function') {
1786
- // The JS layer can arrive via hot update onto an older binary whose
1787
- // native module predates this method.
1698
+ const reset = nativeMethod('resetToPackagedBundle');
1699
+ if (!reset) {
1788
1700
  const err = new UpdateError(
1789
1701
  this.t('error_reset_not_supported'),
1790
1702
  'RESET_FAILED'
@@ -1794,11 +1706,9 @@ export class Pushy {
1794
1706
  return false;
1795
1707
  }
1796
1708
  try {
1797
- await PushyModule.resetToPackagedBundle();
1709
+ await reset();
1798
1710
  } catch (e) {
1799
- const err = toUpdateError(e, 'RESET_FAILED');
1800
- this.emitError(err, 'errorReset');
1801
- this.throwIfEnabled(err);
1711
+ this.throwIfEnabled(this.fail(e, 'RESET_FAILED', 'errorReset'));
1802
1712
  return false;
1803
1713
  }
1804
1714
  // The downloaded versions are gone; drop JS bookkeeping referring to them