react-native-update 10.53.1 → 10.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README-CN.md +59 -31
  2. package/README.md +62 -32
  3. package/android/jni/HDiffPatch/.travis.yml +33 -0
  4. package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  5. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  6. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  7. package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  8. package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  9. package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  10. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  11. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  12. package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  13. package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  14. package/android/jni/lzma/DOC/7zC.txt +187 -0
  15. package/android/jni/lzma/DOC/7zFormat.txt +469 -0
  16. package/android/jni/lzma/DOC/Methods.txt +173 -0
  17. package/android/jni/lzma/DOC/installer.txt +166 -0
  18. package/android/jni/lzma/DOC/lzma-history.txt +446 -0
  19. package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
  20. package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
  21. package/android/jni/lzma/DOC/lzma.txt +328 -0
  22. package/android/jni/lzma/bin/7zS2.sfx +0 -0
  23. package/android/jni/lzma/bin/7zS2con.sfx +0 -0
  24. package/android/jni/lzma/bin/7zSD.sfx +0 -0
  25. package/android/jni/lzma/bin/7zdec.exe +0 -0
  26. package/android/jni/lzma/bin/7zr.exe +0 -0
  27. package/android/jni/lzma/bin/installer/config.txt +5 -0
  28. package/android/jni/lzma/bin/installer/cr.bat +5 -0
  29. package/android/jni/lzma/bin/lzma.exe +0 -0
  30. package/android/jni/lzma/bin/x64/7zr.exe +0 -0
  31. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  32. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  33. package/android/lib/x86/librnupdate.so +0 -0
  34. package/android/lib/x86_64/librnupdate.so +0 -0
  35. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +44 -0
  36. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +124 -40
  37. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +125 -0
  38. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +43 -11
  39. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +2 -0
  40. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +90 -0
  41. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +79 -19
  42. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +21 -0
  43. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +33 -2
  44. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +1 -0
  45. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  46. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  47. package/cpp/patch_core/archive_limits.h +38 -0
  48. package/cpp/patch_core/install_record.h +36 -0
  49. package/cpp/update_flow_core/flow_json.h +1 -0
  50. package/cpp/update_flow_core/update_flow_core.cpp +41 -7
  51. package/cpp/update_flow_core/update_flow_core.h +9 -0
  52. package/cpp/update_flow_core/update_flow_jni.cpp +12 -0
  53. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +2 -0
  54. package/harmony/pushy/src/main/cpp/pushy.cpp +37 -0
  55. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +83 -0
  56. package/harmony/pushy/src/main/ets/DownloadTask.ts +126 -30
  57. package/harmony/pushy/src/main/ets/InstallRecord.ts +129 -0
  58. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +24 -2
  59. package/harmony/pushy/src/main/ets/NativePatchCore.ts +5 -0
  60. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +12 -0
  61. package/harmony/pushy/src/main/ets/UpdateContext.ts +78 -15
  62. package/harmony/pushy.har +0 -0
  63. package/ios/RCTPushy/RCTPushy.mm +384 -25
  64. package/ios/RCTPushy/RCTPushyDownloader.mm +32 -0
  65. package/package.json +25 -2
  66. package/src/NativePushy.ts +13 -0
  67. package/src/client.ts +288 -47
  68. package/src/core.ts +17 -3
  69. package/src/error.ts +3 -0
  70. package/src/errorReporting.ts +136 -0
  71. package/src/index.ts +14 -0
  72. package/src/locales/en.ts +3 -0
  73. package/src/locales/zh.ts +2 -0
  74. package/src/metadata.ts +166 -0
  75. package/src/provider.tsx +52 -17
  76. package/src/type.ts +29 -0
  77. package/src/updateFlowCore.ts +95 -4
  78. package/src/utils.ts +31 -19
  79. package/.gitmodules +0 -6
  80. package/BUNDLEHASH_MIGRATION.md +0 -355
  81. package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
  82. package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
  83. package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
  84. package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
  85. package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
  86. package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
  87. package/biome.json +0 -59
  88. package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
  89. package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
  90. package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
  91. package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
  92. package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
  93. package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
  94. package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
  95. package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
  96. package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
  97. package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
  98. package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
  99. package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
  100. package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
  101. package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
  102. package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
  103. package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
  104. package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
  105. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
  106. package/scripts/check-expo-version.js +0 -92
@@ -12,6 +12,12 @@ export interface Spec extends TurboModule {
12
12
  isUsingBundleUrl: boolean;
13
13
  currentVersionInfo: string;
14
14
  supportedDiffVersion: number;
15
+ /**
16
+ * SHA-256 of the running hot-update bundle as recorded by the install
17
+ * (cpp/patch_core/install_record.h); '' for the embedded bundle, legacy
18
+ * installs, or older native modules.
19
+ */
20
+ currentBundleSha256?: string;
15
21
  };
16
22
  setLocalHashInfo(hash: string, info: string): Promise<void>;
17
23
  getLocalHashInfo(hash: string): Promise<string>;
@@ -30,6 +36,13 @@ export interface Spec extends TurboModule {
30
36
  * an empty string when absent; never rejects.
31
37
  */
32
38
  getNativeCheckCache(): Promise<string>;
39
+ /**
40
+ * JS obtained a valid check response in this process for `config` (the
41
+ * same JSON syncNativeConfig persists). The delayed cold-start round skips
42
+ * its own request when its persisted config matches; a crash-rescue round
43
+ * is unaffected (§10.3). Older native modules lack this method.
44
+ */
45
+ markJsCheckCompleted(config: string): Promise<void>;
33
46
  reloadUpdate(options: { hash: string }): Promise<void>;
34
47
  restartApp(): Promise<void>;
35
48
  setNeedUpdate(options: { hash: string }): Promise<void>;
package/src/client.ts CHANGED
@@ -25,6 +25,13 @@ import {
25
25
  UpdateError,
26
26
  type UpdateErrorCode,
27
27
  } from './error';
28
+ import {
29
+ type ErrorReportContext,
30
+ type ErrorReportingOptions,
31
+ installGlobalErrorHandler,
32
+ type SerializedException,
33
+ serializeException,
34
+ } from './errorReporting';
28
35
  import i18n from './i18n';
29
36
  import {
30
37
  resolveServerEventHash,
@@ -41,10 +48,13 @@ import type {
41
48
  UpdateServerConfig,
42
49
  } from './type';
43
50
  import {
51
+ buildCheckFingerprint,
44
52
  buildCheckRequestBody,
45
53
  type DownloadPlan,
46
54
  type DownloadStrategyType,
47
55
  decideDownload,
56
+ isMirrorRetryableCode,
57
+ isValidCheckResult,
48
58
  } from './updateFlowCore';
49
59
  import {
50
60
  assertWeb,
@@ -75,10 +85,6 @@ const SERVER_PRESETS = {
75
85
  main: [
76
86
  'https://update.react-native.cn/api',
77
87
  'https://update.reactnative.cn/api',
78
- 'https://1.rnupdate.online/api',
79
- 'https://2.rnupdate.online/api',
80
- 'https://3.rnupdate.online/api',
81
- 'https://4.rnupdate.online/api',
82
88
  ],
83
89
  queryUrls: [
84
90
  'https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json',
@@ -122,6 +128,7 @@ const createDefaultClientOptions = (): ClientOptions => ({
122
128
  logger: noop,
123
129
  debug: false,
124
130
  throwError: false,
131
+ disableErrorReporting: false,
125
132
  });
126
133
 
127
134
  export const sharedState: {
@@ -171,6 +178,7 @@ export class Pushy {
171
178
  clientType: 'Pushy' | 'Cresc' = 'Pushy';
172
179
  lastChecking?: number;
173
180
  lastRespJson?: Promise<CheckResult>;
181
+ lastCheckFingerprint?: string;
174
182
  // Endpoint that most recently served a successful checkUpdate; telemetry
175
183
  // reuses it instead of re-running the fallback race.
176
184
  private lastWorkingEndpoint?: string;
@@ -178,6 +186,8 @@ export class Pushy {
178
186
  private pendingNativeConfigJson?: string;
179
187
  private nativeConfigSyncInFlight = false;
180
188
  private reportedInvalidUpdates = new Set<string>();
189
+ private reportedExceptions = new WeakSet<object>();
190
+ private globalErrorCleanup?: () => void;
181
191
 
182
192
  version = cInfo.rnu;
183
193
  loggerPromise = (() => {
@@ -276,9 +286,18 @@ export class Pushy {
276
286
  log('onOptionsChange listener error:', e?.message || e);
277
287
  }
278
288
  }
289
+ this.syncAutomaticErrorReporting();
279
290
  this.syncNativeConfig();
280
291
  };
281
292
 
293
+ private syncAutomaticErrorReporting = (): void => {
294
+ if (this.options.disableTelemetry || this.options.disableErrorReporting) {
295
+ this.globalErrorCleanup?.();
296
+ return;
297
+ }
298
+ this.enableErrorReporting();
299
+ };
300
+
282
301
  /** Build the subset of options used by the native cold-start check. */
283
302
  private getNativeConfig = (): Record<string, unknown> | undefined => {
284
303
  if (
@@ -369,6 +388,35 @@ export class Pushy {
369
388
  });
370
389
  };
371
390
 
391
+ /**
392
+ * Tell the native cold-start orchestrator that this process already holds a
393
+ * valid check response for the current config, so its delayed round (5s
394
+ * after launch) does not repeat the request. Fire-and-forget: a failure
395
+ * costs one duplicate check. Never called on a failed check — the native
396
+ * rescue must keep running when JS could not reach the server.
397
+ */
398
+ private markJsCheckCompleted = () => {
399
+ if (
400
+ Platform.OS === 'web' ||
401
+ typeof PushyModule.markJsCheckCompleted !== 'function'
402
+ ) {
403
+ return;
404
+ }
405
+ const configJson = this.getNativeConfigJson();
406
+ if (!configJson) {
407
+ return;
408
+ }
409
+ try {
410
+ Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).catch(
411
+ (e: any) => {
412
+ log('markJsCheckCompleted failed:', e?.message || e);
413
+ }
414
+ );
415
+ } catch (e: any) {
416
+ log('markJsCheckCompleted failed:', e?.message || e);
417
+ }
418
+ };
419
+
372
420
  private syncNativeConfig = () => {
373
421
  if (
374
422
  Platform.OS === 'web' ||
@@ -485,6 +533,115 @@ export class Pushy {
485
533
  });
486
534
  };
487
535
 
536
+ /**
537
+ * Best-effort JS exception reporting for the currently running OTA release.
538
+ * It is intentionally synchronous to call and never throws; the transport
539
+ * runs in the background and preserves the app's existing error flow.
540
+ */
541
+ captureException = (
542
+ error: unknown,
543
+ context: ErrorReportContext = {}
544
+ ): void => {
545
+ try {
546
+ if (
547
+ __DEV__ ||
548
+ this.options.disableTelemetry ||
549
+ this.options.disableErrorReporting
550
+ ) {
551
+ return;
552
+ }
553
+ if (
554
+ error !== null &&
555
+ (typeof error === 'object' || typeof error === 'function')
556
+ ) {
557
+ const object = error as object;
558
+ if (this.reportedExceptions.has(object)) {
559
+ return;
560
+ }
561
+ this.reportedExceptions.add(object);
562
+ }
563
+ this.reportExceptionToServer(serializeException(error, context));
564
+ } catch (e: any) {
565
+ log('exception telemetry error:', e?.message || e);
566
+ }
567
+ };
568
+
569
+ /**
570
+ * Ensure uncaught JS exception capture is installed and return a runtime
571
+ * cleanup function. Capture is installed automatically by default; this
572
+ * method remains an idempotent compatibility hook. The wrapper always
573
+ * chains the handler that was installed before it and only restores that
574
+ * handler when no later integration has replaced ours.
575
+ */
576
+ enableErrorReporting = (
577
+ options: ErrorReportingOptions = {}
578
+ ): (() => void) => {
579
+ if (
580
+ options.captureGlobal === false ||
581
+ this.options.disableTelemetry ||
582
+ this.options.disableErrorReporting
583
+ ) {
584
+ return () => {};
585
+ }
586
+ if (this.globalErrorCleanup) {
587
+ return this.globalErrorCleanup;
588
+ }
589
+ const uninstall = installGlobalErrorHandler((error, isFatal) => {
590
+ this.captureException(error, { fatal: isFatal === true });
591
+ });
592
+ let active = true;
593
+ const cleanup = () => {
594
+ if (!active) {
595
+ return;
596
+ }
597
+ active = false;
598
+ uninstall();
599
+ if (this.globalErrorCleanup === cleanup) {
600
+ this.globalErrorCleanup = undefined;
601
+ }
602
+ };
603
+ this.globalErrorCleanup = cleanup;
604
+ return cleanup;
605
+ };
606
+
607
+ private reportExceptionToServer = (error: SerializedException): void => {
608
+ try {
609
+ if (
610
+ __DEV__ ||
611
+ this.options.disableTelemetry ||
612
+ this.options.disableErrorReporting ||
613
+ !currentVersion
614
+ ) {
615
+ return;
616
+ }
617
+ const { appKey } = this.options;
618
+ const endpoint =
619
+ this.lastWorkingEndpoint || this.options.server?.main?.[0];
620
+ if (!appKey || !endpoint) {
621
+ return;
622
+ }
623
+ fetchWithTimeout(
624
+ `${endpoint}/report/${appKey}`,
625
+ {
626
+ method: 'POST',
627
+ headers: { 'Content-Type': 'application/json' },
628
+ body: JSON.stringify({
629
+ type: 'js_exception',
630
+ hash: currentVersion,
631
+ packageVersion: this.getEffectivePackageVersion(),
632
+ cInfo,
633
+ error,
634
+ }),
635
+ },
636
+ DEFAULT_FETCH_TIMEOUT_MS
637
+ ).catch((e: any) => {
638
+ log('exception telemetry report failed:', e?.message || e);
639
+ });
640
+ } catch (e: any) {
641
+ log('exception telemetry error:', e?.message || e);
642
+ }
643
+ };
644
+
488
645
  report = async ({
489
646
  type,
490
647
  message = '',
@@ -738,7 +895,17 @@ export class Pushy {
738
895
  );
739
896
  }
740
897
 
741
- return (await resp.json()) as CheckResult;
898
+ const result: unknown = await resp.json();
899
+ if (!isValidCheckResult(result)) {
900
+ // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal page
901
+ // that parsed, ...) is a failed endpoint: throwing keeps the fallback
902
+ // moving to the next one instead of treating it as "no update".
903
+ throw new UpdateError(
904
+ this.t('error_invalid_check_response'),
905
+ 'INVALID_RESPONSE'
906
+ );
907
+ }
908
+ return result;
742
909
  };
743
910
  fetchCheckResult = async (fetchPayload: Parameters<typeof fetch>[1]) => {
744
911
  const { endpoint, value } = await executeEndpointFallback<CheckResult>({
@@ -927,10 +1094,33 @@ export class Pushy {
927
1094
  // buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化
928
1095
  // 检查流程。
929
1096
  const bundleHash = __DEV__ ? '' : getBundleHash();
1097
+ const fetchBody = buildCheckRequestBody({
1098
+ packageVersion: this.getEffectivePackageVersion(),
1099
+ currentVersion,
1100
+ buildTime,
1101
+ cInfo,
1102
+ supportedDiffVersion,
1103
+ bundleHash,
1104
+ isDev: __DEV__,
1105
+ extra,
1106
+ });
1107
+ const stringifyBody = JSON.stringify(fetchBody);
1108
+ // Identity of this check: only a check with the exact same request (same
1109
+ // extra / test hash / bundleHash / binary identity) against the same
1110
+ // endpoint set may share an in-flight or just-settled response. Two
1111
+ // checks 2s apart with different `extra` are different questions.
1112
+ const fingerprint = buildCheckFingerprint({
1113
+ appKey: this.options.appKey,
1114
+ endpoints: this.options.server?.main,
1115
+ queryUrls: this.options.server?.queryUrls,
1116
+ // The client uuid travels inside cInfo, i.e. inside `body`.
1117
+ body: stringifyBody,
1118
+ });
930
1119
  const now = Date.now();
931
1120
  if (
932
1121
  this.lastRespJson &&
933
1122
  this.lastChecking &&
1123
+ this.lastCheckFingerprint === fingerprint &&
934
1124
  now - this.lastChecking < 1000 * 5
935
1125
  ) {
936
1126
  try {
@@ -949,17 +1139,7 @@ export class Pushy {
949
1139
  }
950
1140
  }
951
1141
  this.lastChecking = now;
952
- const fetchBody = buildCheckRequestBody({
953
- packageVersion: this.getEffectivePackageVersion(),
954
- currentVersion,
955
- buildTime,
956
- cInfo,
957
- supportedDiffVersion,
958
- bundleHash,
959
- isDev: __DEV__,
960
- extra,
961
- });
962
- const stringifyBody = JSON.stringify(fetchBody);
1142
+ this.lastCheckFingerprint = fingerprint;
963
1143
  // harmony fetch body is not string
964
1144
  let body: any = fetchBody;
965
1145
  if (Platform.OS === 'ios' || Platform.OS === 'android') {
@@ -973,7 +1153,6 @@ export class Pushy {
973
1153
  },
974
1154
  body,
975
1155
  };
976
- const previousRespJson = this.lastRespJson;
977
1156
  try {
978
1157
  this.report({
979
1158
  type: 'checking',
@@ -1007,19 +1186,25 @@ export class Pushy {
1007
1186
  }
1008
1187
 
1009
1188
  this.notifyAfterCheckUpdate({ status: 'completed', result });
1189
+ this.markJsCheckCompleted();
1010
1190
  return result;
1011
1191
  } catch (e: any) {
1012
- this.lastRespJson = previousRespJson;
1192
+ // A failed check must not keep vouching for an older response: clear
1193
+ // the dedup slot so the next call re-asks the server.
1194
+ this.lastRespJson = undefined;
1195
+ this.lastCheckFingerprint = undefined;
1013
1196
  const err = toUpdateError(e, 'CHECK_FAILED');
1014
1197
  this.emitError(err, 'errorChecking', {
1015
1198
  message: err.message || this.t('error_cannot_connect_server'),
1016
1199
  });
1017
1200
  this.notifyAfterCheckUpdate({ status: 'error', error: err });
1018
1201
  this.throwIfEnabled(err);
1019
- // Fall back to the previous successful response if we have one; otherwise
1020
- // return undefined so callers can distinguish "check failed" from a real
1021
- // empty result and avoid overwriting the last good updateInfo.
1022
- return previousRespJson ? await previousRespJson : undefined;
1202
+ // No silent fallback to a previous successful response: a stale result
1203
+ // would let a server outage look like "still updatable" and drive the
1204
+ // provider's auto-download off it. Return undefined so callers can
1205
+ // distinguish "check failed" from a real empty result and keep their
1206
+ // last good updateInfo.
1207
+ return undefined;
1023
1208
  }
1024
1209
  };
1025
1210
  downloadUpdate = async (
@@ -1077,6 +1262,28 @@ export class Pushy {
1077
1262
  delete sharedState.downloadingTasks[hash];
1078
1263
  }
1079
1264
  };
1265
+ /**
1266
+ * Orders candidate mirrors for one artifact: the HEAD-race winner first
1267
+ * (its post-redirect URL), then the remaining mirrors in configured order.
1268
+ * Empty when there is nothing to try.
1269
+ */
1270
+ private rankDownloadUrls = async (urls?: string[]): Promise<string[]> => {
1271
+ if (!urls?.length) {
1272
+ return [];
1273
+ }
1274
+ const winner = await testUrls(urls);
1275
+ const ordered: string[] = [];
1276
+ const push = (url?: string | null) => {
1277
+ if (url && !ordered.includes(url)) {
1278
+ ordered.push(url);
1279
+ }
1280
+ };
1281
+ push(winner);
1282
+ for (const url of urls) {
1283
+ push(url);
1284
+ }
1285
+ return ordered;
1286
+ };
1080
1287
  private performDownload = async (
1081
1288
  updateInfo: CheckResult,
1082
1289
  plan: DownloadPlan,
@@ -1103,7 +1310,13 @@ export class Pushy {
1103
1310
  progress: computeProgress(data.received, data.total),
1104
1311
  };
1105
1312
  callbacks.forEach((callback) => {
1106
- callback(payload);
1313
+ // One subscriber throwing must not starve the others or abort the
1314
+ // download: business callbacks are isolated, the error is logged.
1315
+ try {
1316
+ callback(payload);
1317
+ } catch (e) {
1318
+ warn('onDownloadProgress callback threw', e);
1319
+ }
1107
1320
  });
1108
1321
  };
1109
1322
  // RN >= 0.87 types native event listeners as `(...args: readonly Object[])`,
@@ -1164,7 +1377,12 @@ export class Pushy {
1164
1377
 
1165
1378
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1166
1379
  if (attempt > 0) {
1167
- const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000);
1380
+ // Exponential backoff with jitter: a bad CDN edge otherwise gets
1381
+ // every client back at the same 1/2/4s marks.
1382
+ const backoffMs = Math.round(
1383
+ Math.min(1000 * 2 ** (attempt - 1), 10000) *
1384
+ (0.75 + Math.random() * 0.5)
1385
+ );
1168
1386
  log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`);
1169
1387
  await new Promise((r) => setTimeout(r, backoffMs));
1170
1388
  errorMessages.length = 0;
@@ -1186,28 +1404,39 @@ export class Pushy {
1186
1404
  if (succeeded) {
1187
1405
  break;
1188
1406
  }
1189
- const url = await testUrls(urls);
1190
- if (!url) {
1191
- continue;
1192
- }
1193
- log(`downloading ${type}`);
1194
- try {
1195
- await runners[type](url);
1196
- succeeded = type;
1197
- } catch (e: any) {
1198
- const errorMessage = this.t(errorKeys[type], {
1199
- message: e.message,
1200
- });
1201
- errorMessages.push(errorMessage);
1202
- // Keep the i18n message for display, but preserve the native
1203
- // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
1204
- // telemetry classifies on it) and the original error as cause.
1205
- lastError = new UpdateError(
1206
- errorMessage,
1207
- asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
1208
- { cause: e }
1209
- );
1210
- log(errorMessage);
1407
+ // The HEAD race only ranks mirrors; it is not proof that a mirror can
1408
+ // serve the whole artifact (CDNs answer HEAD and GET differently, and
1409
+ // a transfer can die mid-stream). Every mirror of the same artifact
1410
+ // gets a real download attempt before the strategy is abandoned —
1411
+ // the native cold-start path already behaves this way.
1412
+ const orderedUrls = await this.rankDownloadUrls(urls);
1413
+ for (const url of orderedUrls) {
1414
+ log(`downloading ${type} from ${url}`);
1415
+ try {
1416
+ await runners[type](url);
1417
+ succeeded = type;
1418
+ break;
1419
+ } catch (e: any) {
1420
+ const errorMessage = this.t(errorKeys[type], {
1421
+ message: e.message,
1422
+ });
1423
+ errorMessages.push(errorMessage);
1424
+ // Keep the i18n message for display, but preserve the native
1425
+ // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
1426
+ // telemetry classifies on it) and the original error as cause.
1427
+ lastError = new UpdateError(
1428
+ errorMessage,
1429
+ asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
1430
+ { cause: e }
1431
+ );
1432
+ log(errorMessage);
1433
+ if (!isMirrorRetryableCode(lastError.code)) {
1434
+ // The bytes arrived but could not be applied (patch/manifest
1435
+ // mismatch): every mirror serves the same artifact, so move
1436
+ // on to the next strategy instead of re-downloading it.
1437
+ break;
1438
+ }
1439
+ }
1211
1440
  }
1212
1441
  }
1213
1442
  if (succeeded) {
@@ -1278,7 +1507,19 @@ export class Pushy {
1278
1507
  if (sharedState.toHash === hash) {
1279
1508
  hashInfo.debugChannel = true;
1280
1509
  }
1281
- setLocalHashInfo(hash, hashInfo);
1510
+ // The version is "downloaded" only once its metadata is persisted: a
1511
+ // lost hash-info write would leave the next launch with a nameless
1512
+ // version and a switchVersion that the native side may refuse.
1513
+ try {
1514
+ await setLocalHashInfo(hash, hashInfo);
1515
+ } catch (e: any) {
1516
+ const err = toUpdateError(e, 'FILE_OPERATION_FAILED');
1517
+ this.emitError(err, 'errorUpdate', {
1518
+ message: err.message,
1519
+ data: { newVersion: hash },
1520
+ });
1521
+ throw err;
1522
+ }
1282
1523
  sharedState.downloadedHash = hash;
1283
1524
  return hash;
1284
1525
  };
package/src/core.ts CHANGED
@@ -36,8 +36,15 @@ export const downloadRootDir: string = PushyConstants.downloadRootDir;
36
36
  export const packageVersion: string = PushyConstants.packageVersion;
37
37
  export const currentVersion: string = PushyConstants.currentVersion;
38
38
 
39
- export function setLocalHashInfo(hash: string, info: Record<string, any>) {
40
- PushyModule.setLocalHashInfo(hash, JSON.stringify(info));
39
+ export function setLocalHashInfo(
40
+ hash: string,
41
+ info: Record<string, any>
42
+ ): Promise<void> {
43
+ // Always a promise so callers can await native persistence (the legacy
44
+ // bridge returns undefined when no promise is exposed).
45
+ return Promise.resolve(
46
+ PushyModule.setLocalHashInfo(hash, JSON.stringify(info))
47
+ );
41
48
  }
42
49
 
43
50
  const currentVersionInfoString: string = PushyConstants.currentVersionInfo;
@@ -49,7 +56,9 @@ if (currentVersionInfoString) {
49
56
  if (_currentVersionInfo.debugChannel) {
50
57
  isDebugChannel = true;
51
58
  delete _currentVersionInfo.debugChannel;
52
- setLocalHashInfo(currentVersion, _currentVersionInfo);
59
+ setLocalHashInfo(currentVersion, _currentVersionInfo).catch((e) => {
60
+ error('failed to persist version info', e);
61
+ });
53
62
  }
54
63
  } catch {
55
64
  error(
@@ -58,6 +67,11 @@ if (currentVersionInfoString) {
58
67
  }
59
68
  }
60
69
  export const currentVersionInfo = _currentVersionInfo;
70
+ /** SHA-256 of the running hot-update bundle from its install record; '' when unknown. */
71
+ export const currentBundleSha256: string =
72
+ typeof PushyConstants.currentBundleSha256 === 'string'
73
+ ? PushyConstants.currentBundleSha256
74
+ : '';
61
75
 
62
76
  export const isFirstTime: boolean = PushyConstants.isFirstTime;
63
77
  export const isFirstTimeDebug: boolean = isFirstTime && isDebugChannel;
package/src/error.ts CHANGED
@@ -13,6 +13,8 @@ export type UpdateErrorCode =
13
13
  | 'APPKEY_REQUIRED'
14
14
  | 'NO_ENDPOINTS'
15
15
  | 'HTTP_STATUS'
16
+ // A 2xx check response that is not a check verdict (schema gate).
17
+ | 'INVALID_RESPONSE'
16
18
  | 'CHECK_FAILED'
17
19
  | 'DOWNLOAD_FAILED'
18
20
  | 'SWITCH_VERSION_FAILED'
@@ -43,6 +45,7 @@ const KNOWN_CODES = new Set<string>([
43
45
  'APPKEY_REQUIRED',
44
46
  'NO_ENDPOINTS',
45
47
  'HTTP_STATUS',
48
+ 'INVALID_RESPONSE',
46
49
  'CHECK_FAILED',
47
50
  'DOWNLOAD_FAILED',
48
51
  'SWITCH_VERSION_FAILED',