react-native-update 10.53.1 → 10.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README-CN.md +31 -31
  2. package/README.md +31 -31
  3. package/android/jni/HDiffPatch/.travis.yml +33 -0
  4. package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  5. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  6. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  7. package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  8. package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  9. package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  10. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  11. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  12. package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  13. package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  14. package/android/jni/lzma/DOC/7zC.txt +187 -0
  15. package/android/jni/lzma/DOC/7zFormat.txt +469 -0
  16. package/android/jni/lzma/DOC/Methods.txt +173 -0
  17. package/android/jni/lzma/DOC/installer.txt +166 -0
  18. package/android/jni/lzma/DOC/lzma-history.txt +446 -0
  19. package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
  20. package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
  21. package/android/jni/lzma/DOC/lzma.txt +328 -0
  22. package/android/jni/lzma/bin/7zS2.sfx +0 -0
  23. package/android/jni/lzma/bin/7zS2con.sfx +0 -0
  24. package/android/jni/lzma/bin/7zSD.sfx +0 -0
  25. package/android/jni/lzma/bin/7zdec.exe +0 -0
  26. package/android/jni/lzma/bin/7zr.exe +0 -0
  27. package/android/jni/lzma/bin/installer/config.txt +5 -0
  28. package/android/jni/lzma/bin/installer/cr.bat +5 -0
  29. package/android/jni/lzma/bin/lzma.exe +0 -0
  30. package/android/jni/lzma/bin/x64/7zr.exe +0 -0
  31. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  32. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  33. package/android/lib/x86/librnupdate.so +0 -0
  34. package/android/lib/x86_64/librnupdate.so +0 -0
  35. package/android/src/main/java/cn/reactnative/modules/update/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 +160 -43
  68. package/src/core.ts +17 -3
  69. package/src/error.ts +3 -0
  70. package/src/index.ts +8 -0
  71. package/src/locales/en.ts +3 -0
  72. package/src/locales/zh.ts +2 -0
  73. package/src/metadata.ts +166 -0
  74. package/src/provider.tsx +52 -17
  75. package/src/type.ts +22 -0
  76. package/src/updateFlowCore.ts +95 -4
  77. package/src/utils.ts +31 -19
  78. package/.gitmodules +0 -6
  79. package/BUNDLEHASH_MIGRATION.md +0 -355
  80. package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
  81. package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
  82. package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
  83. package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
  84. package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
  85. package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
  86. package/biome.json +0 -59
  87. package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
  88. package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
  89. package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
  90. package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
  91. package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
  92. package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
  93. package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
  94. package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
  95. package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
  96. package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
  97. package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
  98. package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
  99. package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
  100. package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
  101. package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
  102. package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
  103. package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
  104. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
  105. 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
@@ -41,10 +41,13 @@ import type {
41
41
  UpdateServerConfig,
42
42
  } from './type';
43
43
  import {
44
+ buildCheckFingerprint,
44
45
  buildCheckRequestBody,
45
46
  type DownloadPlan,
46
47
  type DownloadStrategyType,
47
48
  decideDownload,
49
+ isMirrorRetryableCode,
50
+ isValidCheckResult,
48
51
  } from './updateFlowCore';
49
52
  import {
50
53
  assertWeb,
@@ -171,6 +174,7 @@ export class Pushy {
171
174
  clientType: 'Pushy' | 'Cresc' = 'Pushy';
172
175
  lastChecking?: number;
173
176
  lastRespJson?: Promise<CheckResult>;
177
+ lastCheckFingerprint?: string;
174
178
  // Endpoint that most recently served a successful checkUpdate; telemetry
175
179
  // reuses it instead of re-running the fallback race.
176
180
  private lastWorkingEndpoint?: string;
@@ -369,6 +373,35 @@ export class Pushy {
369
373
  });
370
374
  };
371
375
 
376
+ /**
377
+ * Tell the native cold-start orchestrator that this process already holds a
378
+ * valid check response for the current config, so its delayed round (5s
379
+ * after launch) does not repeat the request. Fire-and-forget: a failure
380
+ * costs one duplicate check. Never called on a failed check — the native
381
+ * rescue must keep running when JS could not reach the server.
382
+ */
383
+ private markJsCheckCompleted = () => {
384
+ if (
385
+ Platform.OS === 'web' ||
386
+ typeof PushyModule.markJsCheckCompleted !== 'function'
387
+ ) {
388
+ return;
389
+ }
390
+ const configJson = this.getNativeConfigJson();
391
+ if (!configJson) {
392
+ return;
393
+ }
394
+ try {
395
+ Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).catch(
396
+ (e: any) => {
397
+ log('markJsCheckCompleted failed:', e?.message || e);
398
+ }
399
+ );
400
+ } catch (e: any) {
401
+ log('markJsCheckCompleted failed:', e?.message || e);
402
+ }
403
+ };
404
+
372
405
  private syncNativeConfig = () => {
373
406
  if (
374
407
  Platform.OS === 'web' ||
@@ -738,7 +771,17 @@ export class Pushy {
738
771
  );
739
772
  }
740
773
 
741
- return (await resp.json()) as CheckResult;
774
+ const result: unknown = await resp.json();
775
+ if (!isValidCheckResult(result)) {
776
+ // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal page
777
+ // that parsed, ...) is a failed endpoint: throwing keeps the fallback
778
+ // moving to the next one instead of treating it as "no update".
779
+ throw new UpdateError(
780
+ this.t('error_invalid_check_response'),
781
+ 'INVALID_RESPONSE'
782
+ );
783
+ }
784
+ return result;
742
785
  };
743
786
  fetchCheckResult = async (fetchPayload: Parameters<typeof fetch>[1]) => {
744
787
  const { endpoint, value } = await executeEndpointFallback<CheckResult>({
@@ -927,10 +970,33 @@ export class Pushy {
927
970
  // buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化
928
971
  // 检查流程。
929
972
  const bundleHash = __DEV__ ? '' : getBundleHash();
973
+ const fetchBody = buildCheckRequestBody({
974
+ packageVersion: this.getEffectivePackageVersion(),
975
+ currentVersion,
976
+ buildTime,
977
+ cInfo,
978
+ supportedDiffVersion,
979
+ bundleHash,
980
+ isDev: __DEV__,
981
+ extra,
982
+ });
983
+ const stringifyBody = JSON.stringify(fetchBody);
984
+ // Identity of this check: only a check with the exact same request (same
985
+ // extra / test hash / bundleHash / binary identity) against the same
986
+ // endpoint set may share an in-flight or just-settled response. Two
987
+ // checks 2s apart with different `extra` are different questions.
988
+ const fingerprint = buildCheckFingerprint({
989
+ appKey: this.options.appKey,
990
+ endpoints: this.options.server?.main,
991
+ queryUrls: this.options.server?.queryUrls,
992
+ // The client uuid travels inside cInfo, i.e. inside `body`.
993
+ body: stringifyBody,
994
+ });
930
995
  const now = Date.now();
931
996
  if (
932
997
  this.lastRespJson &&
933
998
  this.lastChecking &&
999
+ this.lastCheckFingerprint === fingerprint &&
934
1000
  now - this.lastChecking < 1000 * 5
935
1001
  ) {
936
1002
  try {
@@ -949,17 +1015,7 @@ export class Pushy {
949
1015
  }
950
1016
  }
951
1017
  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);
1018
+ this.lastCheckFingerprint = fingerprint;
963
1019
  // harmony fetch body is not string
964
1020
  let body: any = fetchBody;
965
1021
  if (Platform.OS === 'ios' || Platform.OS === 'android') {
@@ -973,7 +1029,6 @@ export class Pushy {
973
1029
  },
974
1030
  body,
975
1031
  };
976
- const previousRespJson = this.lastRespJson;
977
1032
  try {
978
1033
  this.report({
979
1034
  type: 'checking',
@@ -1007,19 +1062,25 @@ export class Pushy {
1007
1062
  }
1008
1063
 
1009
1064
  this.notifyAfterCheckUpdate({ status: 'completed', result });
1065
+ this.markJsCheckCompleted();
1010
1066
  return result;
1011
1067
  } catch (e: any) {
1012
- this.lastRespJson = previousRespJson;
1068
+ // A failed check must not keep vouching for an older response: clear
1069
+ // the dedup slot so the next call re-asks the server.
1070
+ this.lastRespJson = undefined;
1071
+ this.lastCheckFingerprint = undefined;
1013
1072
  const err = toUpdateError(e, 'CHECK_FAILED');
1014
1073
  this.emitError(err, 'errorChecking', {
1015
1074
  message: err.message || this.t('error_cannot_connect_server'),
1016
1075
  });
1017
1076
  this.notifyAfterCheckUpdate({ status: 'error', error: err });
1018
1077
  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;
1078
+ // No silent fallback to a previous successful response: a stale result
1079
+ // would let a server outage look like "still updatable" and drive the
1080
+ // provider's auto-download off it. Return undefined so callers can
1081
+ // distinguish "check failed" from a real empty result and keep their
1082
+ // last good updateInfo.
1083
+ return undefined;
1023
1084
  }
1024
1085
  };
1025
1086
  downloadUpdate = async (
@@ -1077,6 +1138,28 @@ export class Pushy {
1077
1138
  delete sharedState.downloadingTasks[hash];
1078
1139
  }
1079
1140
  };
1141
+ /**
1142
+ * Orders candidate mirrors for one artifact: the HEAD-race winner first
1143
+ * (its post-redirect URL), then the remaining mirrors in configured order.
1144
+ * Empty when there is nothing to try.
1145
+ */
1146
+ private rankDownloadUrls = async (urls?: string[]): Promise<string[]> => {
1147
+ if (!urls?.length) {
1148
+ return [];
1149
+ }
1150
+ const winner = await testUrls(urls);
1151
+ const ordered: string[] = [];
1152
+ const push = (url?: string | null) => {
1153
+ if (url && !ordered.includes(url)) {
1154
+ ordered.push(url);
1155
+ }
1156
+ };
1157
+ push(winner);
1158
+ for (const url of urls) {
1159
+ push(url);
1160
+ }
1161
+ return ordered;
1162
+ };
1080
1163
  private performDownload = async (
1081
1164
  updateInfo: CheckResult,
1082
1165
  plan: DownloadPlan,
@@ -1103,7 +1186,13 @@ export class Pushy {
1103
1186
  progress: computeProgress(data.received, data.total),
1104
1187
  };
1105
1188
  callbacks.forEach((callback) => {
1106
- callback(payload);
1189
+ // One subscriber throwing must not starve the others or abort the
1190
+ // download: business callbacks are isolated, the error is logged.
1191
+ try {
1192
+ callback(payload);
1193
+ } catch (e) {
1194
+ warn('onDownloadProgress callback threw', e);
1195
+ }
1107
1196
  });
1108
1197
  };
1109
1198
  // RN >= 0.87 types native event listeners as `(...args: readonly Object[])`,
@@ -1164,7 +1253,12 @@ export class Pushy {
1164
1253
 
1165
1254
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1166
1255
  if (attempt > 0) {
1167
- const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000);
1256
+ // Exponential backoff with jitter: a bad CDN edge otherwise gets
1257
+ // every client back at the same 1/2/4s marks.
1258
+ const backoffMs = Math.round(
1259
+ Math.min(1000 * 2 ** (attempt - 1), 10000) *
1260
+ (0.75 + Math.random() * 0.5)
1261
+ );
1168
1262
  log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`);
1169
1263
  await new Promise((r) => setTimeout(r, backoffMs));
1170
1264
  errorMessages.length = 0;
@@ -1186,28 +1280,39 @@ export class Pushy {
1186
1280
  if (succeeded) {
1187
1281
  break;
1188
1282
  }
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);
1283
+ // The HEAD race only ranks mirrors; it is not proof that a mirror can
1284
+ // serve the whole artifact (CDNs answer HEAD and GET differently, and
1285
+ // a transfer can die mid-stream). Every mirror of the same artifact
1286
+ // gets a real download attempt before the strategy is abandoned —
1287
+ // the native cold-start path already behaves this way.
1288
+ const orderedUrls = await this.rankDownloadUrls(urls);
1289
+ for (const url of orderedUrls) {
1290
+ log(`downloading ${type} from ${url}`);
1291
+ try {
1292
+ await runners[type](url);
1293
+ succeeded = type;
1294
+ break;
1295
+ } catch (e: any) {
1296
+ const errorMessage = this.t(errorKeys[type], {
1297
+ message: e.message,
1298
+ });
1299
+ errorMessages.push(errorMessage);
1300
+ // Keep the i18n message for display, but preserve the native
1301
+ // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
1302
+ // telemetry classifies on it) and the original error as cause.
1303
+ lastError = new UpdateError(
1304
+ errorMessage,
1305
+ asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
1306
+ { cause: e }
1307
+ );
1308
+ log(errorMessage);
1309
+ if (!isMirrorRetryableCode(lastError.code)) {
1310
+ // The bytes arrived but could not be applied (patch/manifest
1311
+ // mismatch): every mirror serves the same artifact, so move
1312
+ // on to the next strategy instead of re-downloading it.
1313
+ break;
1314
+ }
1315
+ }
1211
1316
  }
1212
1317
  }
1213
1318
  if (succeeded) {
@@ -1278,7 +1383,19 @@ export class Pushy {
1278
1383
  if (sharedState.toHash === hash) {
1279
1384
  hashInfo.debugChannel = true;
1280
1385
  }
1281
- setLocalHashInfo(hash, hashInfo);
1386
+ // The version is "downloaded" only once its metadata is persisted: a
1387
+ // lost hash-info write would leave the next launch with a nameless
1388
+ // version and a switchVersion that the native side may refuse.
1389
+ try {
1390
+ await setLocalHashInfo(hash, hashInfo);
1391
+ } catch (e: any) {
1392
+ const err = toUpdateError(e, 'FILE_OPERATION_FAILED');
1393
+ this.emitError(err, 'errorUpdate', {
1394
+ message: err.message,
1395
+ data: { newVersion: hash },
1396
+ });
1397
+ throw err;
1398
+ }
1282
1399
  sharedState.downloadedHash = hash;
1283
1400
  return hash;
1284
1401
  };
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',
package/src/index.ts CHANGED
@@ -10,4 +10,12 @@ export {
10
10
  export { PushyModule, UpdateModule } from './core';
11
11
  export type { UpdateErrorCode } from './error';
12
12
  export { UpdateError } from './error';
13
+ export type { CrashReporterLike, UpdateMetadata } from './metadata';
14
+ export {
15
+ attachToCrashlytics,
16
+ attachToSentry,
17
+ attachUpdateMetadata,
18
+ getUpdateMetadata,
19
+ updateMetadataTags,
20
+ } from './metadata';
13
21
  export { PushyProvider, UpdateProvider } from './provider';
package/src/locales/en.ts CHANGED
@@ -61,6 +61,9 @@ export default {
61
61
  error_all_promises_rejected: 'All promises were rejected',
62
62
  error_ping_failed: 'Ping failed',
63
63
  error_ping_timeout: 'Ping timeout',
64
+ error_insecure_redirect:
65
+ 'Request was redirected from https to plaintext http',
66
+ error_invalid_check_response: 'Update check response is not a valid verdict',
64
67
  error_http_status: '{{status}} {{statusText}}',
65
68
  error_apk_pending_install:
66
69
  'The APK has been downloaded, please complete the installation in the system installer',
package/src/locales/zh.ts CHANGED
@@ -58,6 +58,8 @@ export default {
58
58
  error_all_promises_rejected: '所有请求都被拒绝',
59
59
  error_ping_failed: 'Ping 失败',
60
60
  error_ping_timeout: 'Ping 超时',
61
+ error_insecure_redirect: '请求被从 https 重定向到明文 http',
62
+ error_invalid_check_response: '更新检查响应不是有效结果',
61
63
  error_http_status: '{{status}} {{statusText}}',
62
64
  error_apk_pending_install: '安装包已下载完成,请在系统安装界面完成安装',
63
65
  error_storage_permission_rejected: '存储权限被拒绝,无法下载安装包',
@@ -0,0 +1,166 @@
1
+ import {
2
+ buildTime,
3
+ cInfo,
4
+ currentBundleSha256,
5
+ currentVersion,
6
+ currentVersionInfo,
7
+ getBundleHash,
8
+ isFirstTime,
9
+ isRolledBack,
10
+ packageVersion,
11
+ rolledBackVersion,
12
+ } from './core';
13
+
14
+ /**
15
+ * Everything a crash report needs to be mapped back to the exact JS bundle
16
+ * that was running: attach it as tags/context in Sentry, Crashlytics, Bugly…
17
+ * The `currentVersion` hash (empty = the binary's embedded bundle) is the
18
+ * key that selects the matching source map on the update platform.
19
+ */
20
+ export interface UpdateMetadata {
21
+ /** react-native-update SDK version. */
22
+ sdkVersion: string;
23
+ /** Native binary version the update decision is keyed on. */
24
+ packageVersion: string;
25
+ /** Build time of the native binary (empty in debug). */
26
+ buildTime: string;
27
+ /** Hash of the running hot-update version; '' when the embedded bundle runs. */
28
+ currentVersion: string;
29
+ /** Publisher-facing name/description/metaInfo of the running version. */
30
+ versionName: string;
31
+ versionDescription: string;
32
+ metaInfo: string;
33
+ /** SHA-256 of the embedded bundle; '' until computed / unavailable. */
34
+ bundleHash: string;
35
+ /**
36
+ * SHA-256 of the running hot-update bundle from its install record
37
+ * (matches the `bundleSha256` the CLI archives with the source map); ''
38
+ * for the embedded bundle or a legacy install.
39
+ */
40
+ bundleSha256: string;
41
+ /** First launch of `currentVersion` (crash protection still armed). */
42
+ isFirstTime: boolean;
43
+ /** This launch is the result of a rollback; `rolledBackVersion` names the culprit. */
44
+ isRolledBack: boolean;
45
+ rolledBackVersion: string;
46
+ /**
47
+ * How the running version got activated when not by the JS flow:
48
+ * 'forceBoot' — the server's per-version override applied by the native
49
+ * cold-start check; 'crashRescue' — activated by the crash-time rescue.
50
+ */
51
+ rescueSource: 'forceBoot' | 'crashRescue' | null;
52
+ /** Stable per-install client id (gray-release bucketing key). */
53
+ uuid: string;
54
+ os: string;
55
+ }
56
+
57
+ export function getUpdateMetadata(): UpdateMetadata {
58
+ const info = currentVersionInfo || {};
59
+ return {
60
+ sdkVersion: cInfo.rnu,
61
+ packageVersion,
62
+ buildTime,
63
+ currentVersion: currentVersion || '',
64
+ versionName: typeof info.name === 'string' ? info.name : '',
65
+ versionDescription:
66
+ typeof info.description === 'string' ? info.description : '',
67
+ metaInfo: typeof info.metaInfo === 'string' ? info.metaInfo : '',
68
+ bundleHash: getBundleHash(),
69
+ bundleSha256: currentBundleSha256 || '',
70
+ isFirstTime,
71
+ isRolledBack,
72
+ rolledBackVersion: rolledBackVersion || '',
73
+ rescueSource: info.forceBootRescue
74
+ ? 'forceBoot'
75
+ : info.crashRescue
76
+ ? 'crashRescue'
77
+ : null,
78
+ uuid: cInfo.uuid,
79
+ os: cInfo.os,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The duck-typed surface of a crash reporter. Sentry exposes setTag/setContext,
85
+ * Firebase Crashlytics setAttributes/setAttribute; any object with one of
86
+ * those works.
87
+ */
88
+ export interface CrashReporterLike {
89
+ setTag?: (key: string, value: string) => unknown;
90
+ setContext?: (
91
+ name: string,
92
+ context: Record<string, unknown> | null
93
+ ) => unknown;
94
+ setAttributes?: (attributes: Record<string, string>) => unknown;
95
+ setAttribute?: (key: string, value: string) => unknown;
96
+ }
97
+
98
+ /** Flat string tags (`pushy.currentVersion` …) for the running update. */
99
+ export function updateMetadataTags(
100
+ metadata: UpdateMetadata = getUpdateMetadata(),
101
+ prefix = 'pushy.'
102
+ ): Record<string, string> {
103
+ const tags: Record<string, string> = {};
104
+ for (const [key, value] of Object.entries(metadata)) {
105
+ tags[`${prefix}${key}`] =
106
+ value === null || value === undefined ? '' : String(value);
107
+ }
108
+ return tags;
109
+ }
110
+
111
+ /**
112
+ * Attach the running update's metadata to a crash reporter so every event can
113
+ * be mapped to the exact bundle (and source map) it came from. Call once after
114
+ * the reporter is initialised; safe to call again (e.g. after markSuccess).
115
+ * Every reporter method is optional and failures are swallowed — a reporting
116
+ * hiccup must never break the app.
117
+ */
118
+ export function attachUpdateMetadata(
119
+ reporter: CrashReporterLike,
120
+ options: { prefix?: string; contextName?: string | null } = {}
121
+ ): UpdateMetadata {
122
+ const metadata = getUpdateMetadata();
123
+ const tags = updateMetadataTags(metadata, options.prefix ?? 'pushy.');
124
+ const swallow = (fn: () => unknown) => {
125
+ try {
126
+ const result = fn();
127
+ if (result && typeof (result as Promise<unknown>).catch === 'function') {
128
+ (result as Promise<unknown>).catch(() => {});
129
+ }
130
+ } catch {
131
+ // never let a reporter failure propagate
132
+ }
133
+ };
134
+ if (typeof reporter.setTag === 'function') {
135
+ for (const [key, value] of Object.entries(tags)) {
136
+ swallow(() => reporter.setTag?.(key, value));
137
+ }
138
+ } else if (typeof reporter.setAttributes === 'function') {
139
+ swallow(() => reporter.setAttributes?.(tags));
140
+ } else if (typeof reporter.setAttribute === 'function') {
141
+ for (const [key, value] of Object.entries(tags)) {
142
+ swallow(() => reporter.setAttribute?.(key, value));
143
+ }
144
+ }
145
+ if (
146
+ options.contextName !== null &&
147
+ typeof reporter.setContext === 'function'
148
+ ) {
149
+ swallow(() =>
150
+ reporter.setContext?.(options.contextName ?? 'pushy', { ...metadata })
151
+ );
152
+ }
153
+ return metadata;
154
+ }
155
+
156
+ /** `attachUpdateMetadata` for `@sentry/react-native` (tags + a `pushy` context). */
157
+ export function attachToSentry(sentry: CrashReporterLike): UpdateMetadata {
158
+ return attachUpdateMetadata(sentry, { contextName: 'pushy' });
159
+ }
160
+
161
+ /** `attachUpdateMetadata` for `@react-native-firebase/crashlytics` (custom keys). */
162
+ export function attachToCrashlytics(
163
+ crashlytics: CrashReporterLike
164
+ ): UpdateMetadata {
165
+ return attachUpdateMetadata(crashlytics, { contextName: null });
166
+ }