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
@@ -0,0 +1,136 @@
1
+ export type ErrorContextValue = string | number | boolean | null;
2
+
3
+ export interface ErrorReportContext {
4
+ fatal?: boolean;
5
+ componentStack?: string;
6
+ extra?: Record<string, ErrorContextValue>;
7
+ }
8
+
9
+ export interface ErrorReportingOptions {
10
+ /** Install a chained React Native ErrorUtils handler. Default: true. */
11
+ captureGlobal?: boolean;
12
+ }
13
+
14
+ export interface SerializedException {
15
+ name: string;
16
+ message: string;
17
+ stack: string;
18
+ fatal: boolean;
19
+ componentStack?: string;
20
+ extra?: Record<string, ErrorContextValue>;
21
+ }
22
+
23
+ const MAX_NAME = 128;
24
+ const MAX_MESSAGE = 2 * 1024;
25
+ const MAX_STACK = 32 * 1024;
26
+ const MAX_COMPONENT_STACK = 16 * 1024;
27
+ const MAX_EXTRA_FIELDS = 32;
28
+ const MAX_EXTRA_KEY = 64;
29
+ const MAX_EXTRA_STRING = 1024;
30
+ const MAX_EXTRA_JSON = 8 * 1024;
31
+
32
+ const boundedString = (value: unknown, maximum: number) =>
33
+ typeof value === 'string' ? value.slice(0, maximum) : '';
34
+
35
+ const normalizeExtra = (
36
+ extra?: Record<string, ErrorContextValue>
37
+ ): Record<string, ErrorContextValue> | undefined => {
38
+ if (!extra || typeof extra !== 'object') {
39
+ return undefined;
40
+ }
41
+ const result: Record<string, ErrorContextValue> = {};
42
+ for (const [rawKey, value] of Object.entries(extra).slice(
43
+ 0,
44
+ MAX_EXTRA_FIELDS
45
+ )) {
46
+ const key = rawKey.slice(0, MAX_EXTRA_KEY);
47
+ if (!key) {
48
+ continue;
49
+ }
50
+ if (typeof value === 'string') {
51
+ result[key] = value.slice(0, MAX_EXTRA_STRING);
52
+ } else if (
53
+ value === null ||
54
+ typeof value === 'boolean' ||
55
+ (typeof value === 'number' && Number.isFinite(value))
56
+ ) {
57
+ result[key] = value;
58
+ }
59
+ // Keep the client comfortably inside the server's byte cap. The server
60
+ // remains authoritative because JS string length is not UTF-8 byte length.
61
+ if (JSON.stringify(result).length > MAX_EXTRA_JSON) {
62
+ delete result[key];
63
+ break;
64
+ }
65
+ }
66
+ return Object.keys(result).length > 0 ? result : undefined;
67
+ };
68
+
69
+ export const serializeException = (
70
+ error: unknown,
71
+ context: ErrorReportContext = {}
72
+ ): SerializedException => {
73
+ const source =
74
+ error !== null && (typeof error === 'object' || typeof error === 'function')
75
+ ? (error as Record<string, unknown>)
76
+ : undefined;
77
+ const name = boundedString(source?.name, MAX_NAME).trim() || 'Error';
78
+ const message = (
79
+ boundedString(source?.message, MAX_MESSAGE) || String(error ?? '')
80
+ ).slice(0, MAX_MESSAGE);
81
+ const stack =
82
+ boundedString(source?.stack, MAX_STACK) || `${name}: ${message}`;
83
+ const componentStack = boundedString(
84
+ context.componentStack,
85
+ MAX_COMPONENT_STACK
86
+ );
87
+ const extra = normalizeExtra(context.extra);
88
+ return {
89
+ name,
90
+ message,
91
+ stack,
92
+ fatal: context.fatal === true,
93
+ ...(componentStack ? { componentStack } : {}),
94
+ ...(extra ? { extra } : {}),
95
+ };
96
+ };
97
+
98
+ type GlobalErrorHandler = (error: unknown, isFatal?: boolean) => void;
99
+
100
+ interface GlobalErrorUtils {
101
+ getGlobalHandler?: () => GlobalErrorHandler;
102
+ setGlobalHandler?: (handler: GlobalErrorHandler) => void;
103
+ }
104
+
105
+ /**
106
+ * Install a React Native global error wrapper without taking ownership away
107
+ * from ExceptionsManager, Sentry, Crashlytics, or a handler installed later.
108
+ */
109
+ export const installGlobalErrorHandler = (
110
+ capture: GlobalErrorHandler
111
+ ): (() => void) => {
112
+ const errorUtils = (globalThis as any).ErrorUtils as
113
+ | GlobalErrorUtils
114
+ | undefined;
115
+ if (
116
+ typeof errorUtils?.getGlobalHandler !== 'function' ||
117
+ typeof errorUtils.setGlobalHandler !== 'function'
118
+ ) {
119
+ return () => {};
120
+ }
121
+ const previous = errorUtils.getGlobalHandler();
122
+ const wrapper: GlobalErrorHandler = (error, isFatal) => {
123
+ try {
124
+ capture(error, isFatal);
125
+ } catch {
126
+ // Reporting must never alter React Native's original failure path.
127
+ }
128
+ previous?.(error, isFatal);
129
+ };
130
+ errorUtils.setGlobalHandler(wrapper);
131
+ return () => {
132
+ if (errorUtils.getGlobalHandler?.() === wrapper) {
133
+ errorUtils.setGlobalHandler?.(previous);
134
+ }
135
+ };
136
+ };
package/src/index.ts CHANGED
@@ -10,4 +10,18 @@ export {
10
10
  export { PushyModule, UpdateModule } from './core';
11
11
  export type { UpdateErrorCode } from './error';
12
12
  export { UpdateError } from './error';
13
+ export type {
14
+ ErrorContextValue,
15
+ ErrorReportContext,
16
+ ErrorReportingOptions,
17
+ SerializedException,
18
+ } from './errorReporting';
19
+ export type { CrashReporterLike, UpdateMetadata } from './metadata';
20
+ export {
21
+ attachToCrashlytics,
22
+ attachToSentry,
23
+ attachUpdateMetadata,
24
+ getUpdateMetadata,
25
+ updateMetadataTags,
26
+ } from './metadata';
13
27
  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
+ }
package/src/provider.tsx CHANGED
@@ -343,13 +343,36 @@ export const UpdateProvider = ({
343
343
  if (!assertWeb()) {
344
344
  return;
345
345
  }
346
- const { checkStrategy, autoMarkSuccess } = options;
346
+ const { checkStrategy, autoMarkSuccess, autoMarkSuccessDelayMs } = options;
347
347
  let markSuccessTimer: ReturnType<typeof setTimeout> | undefined;
348
348
  if (autoMarkSuccess) {
349
+ const delay =
350
+ typeof autoMarkSuccessDelayMs === 'number' &&
351
+ autoMarkSuccessDelayMs >= 0
352
+ ? autoMarkSuccessDelayMs
353
+ : 1000;
349
354
  markSuccessTimer = setTimeout(() => {
350
- // Failures are reported and surfaced via the onError subscription.
351
- Promise.resolve(markSuccess()).catch(noop);
352
- }, 1000);
355
+ // The health check is read when the timer fires, not when it was
356
+ // armed, so a late setOptions still applies.
357
+ const { healthCheck } = options;
358
+ (async () => {
359
+ if (healthCheck) {
360
+ let healthy = false;
361
+ try {
362
+ healthy = (await healthCheck()) !== false;
363
+ } catch (e: any) {
364
+ log('healthCheck threw, not marking success:', e?.message || e);
365
+ return;
366
+ }
367
+ if (!healthy) {
368
+ log('healthCheck returned false, not marking success');
369
+ return;
370
+ }
371
+ }
372
+ // Failures are reported and surfaced via the onError subscription.
373
+ await markSuccess();
374
+ })().catch(noop);
375
+ }, delay);
353
376
  }
354
377
  if (checkStrategy === 'both' || checkStrategy === 'onAppResume') {
355
378
  stateListener.current = AppState.addEventListener(
@@ -396,30 +419,42 @@ export const UpdateProvider = ({
396
419
  const parseTestPayload = useCallback(
397
420
  (payload: UpdateTestPayload) => {
398
421
  if (payload?.type?.startsWith('__rnPushy')) {
422
+ if (options.testChannel === false) {
423
+ // Production builds may refuse scanned codes that would pull an
424
+ // arbitrary published version.
425
+ log('test channel disabled, ignoring payload', payload.type);
426
+ return false;
427
+ }
399
428
  if (payload.type === '__rnPushyVersionHash') {
400
- // Wrap the logger only for the duration of this test check; wrapping
401
- // on any other __rnPushy* payload would never be undone (the finally
402
- // below is the only restore point).
403
- const logger = options.logger || (() => {});
404
- options.logger = ({ type, data }) => {
405
- logger({ type, data });
406
- Alert.alert(type, JSON.stringify(data));
407
- };
408
429
  const toHash = payload.data;
430
+ if (typeof toHash !== 'string' || !toHash) {
431
+ log('test payload without a target hash ignored');
432
+ return false;
433
+ }
409
434
  sharedState.toHash = toHash;
435
+ // No global logger swap for the duration of the check (it leaked
436
+ // into concurrent checks): the tester gets one alert with the
437
+ // outcome instead of one per logged event.
410
438
  checkUpdate({ extra: { toHash } })
411
439
  .then(() => {
412
- if (updateInfoRef.current?.upToDate) {
440
+ const info = updateInfoRef.current;
441
+ if (info?.upToDate) {
413
442
  Alert.alert(
414
443
  client.t('alert_info'),
415
444
  client.t('alert_no_update_wait')
416
445
  );
446
+ } else if (info?.update) {
447
+ Alert.alert(
448
+ client.t('alert_info'),
449
+ JSON.stringify({
450
+ hash: info.hash,
451
+ name: info.name,
452
+ description: info.description,
453
+ })
454
+ );
417
455
  }
418
456
  })
419
- .catch(noop)
420
- .finally(() => {
421
- options.logger = logger;
422
- });
457
+ .catch(noop);
423
458
  }
424
459
  return true;
425
460
  }
package/src/type.ts CHANGED
@@ -162,6 +162,28 @@ export interface ClientOptions {
162
162
  | null;
163
163
  checkStrategy?: 'onAppStart' | 'onAppResume' | 'both' | null;
164
164
  autoMarkSuccess?: boolean;
165
+ /**
166
+ * Delay before the Provider marks the running update as successful when
167
+ * `autoMarkSuccess` is on. Default 1000ms. Apps whose critical modules load
168
+ * after the first frame (navigation, remote config) should raise it — a
169
+ * version that crashes 3s after mount would otherwise already be marked
170
+ * good and never rolled back. For full control use `autoMarkSuccess: false`
171
+ * and call `markSuccess()` once the app is really ready.
172
+ */
173
+ autoMarkSuccessDelayMs?: number;
174
+ /**
175
+ * Consulted when the auto-mark timer fires: return false (or throw) to
176
+ * skip marking this launch — the next launch's crash protection then still
177
+ * applies. Call `markSuccess()` yourself later once the app is healthy.
178
+ */
179
+ healthCheck?: () => boolean | Promise<boolean>;
180
+ /**
181
+ * Whether the Provider honours test-channel payloads (`parseTestQrCode`,
182
+ * `__rnPushyVersionHash` deep links / QR codes) that target a specific
183
+ * version hash. Default true. Set to false in production builds that must
184
+ * not let a scanned code pull an arbitrary published version.
185
+ */
186
+ testChannel?: boolean;
165
187
  dismissErrorAfter?: number;
166
188
  debug?: boolean;
167
189
  throwError?: boolean;
@@ -183,6 +205,13 @@ export interface ClientOptions {
183
205
  * the version health view in the console. Default: false (enabled).
184
206
  */
185
207
  disableTelemetry?: boolean;
208
+ /**
209
+ * Disable automatic and manual JavaScript exception reporting. Reporting is
210
+ * enabled by default and chains React Native's existing global ErrorUtils
211
+ * handler. Set this to true for an explicit opt-out; update lifecycle
212
+ * telemetry remains controlled separately by `disableTelemetry`.
213
+ */
214
+ disableErrorReporting?: boolean;
186
215
  /**
187
216
  * Disable the native cold-start update check: the background check that runs
188
217
  * a few seconds after every launch, independent of JS
@@ -172,22 +172,113 @@ export function buildCheckRequestBody({
172
172
  isDev,
173
173
  extra,
174
174
  }: CheckRequestInput): Record<string, any> {
175
+ // Caller extras are spread FIRST so they can add fields (channel, custom
176
+ // filters, the debug-channel toHash) but never override the SDK identity
177
+ // fields the server keys its decision on: a stray `packageVersion` or
178
+ // `hash` in extra would otherwise make the server hand out a package for
179
+ // the wrong binary.
175
180
  const body: Record<string, any> = {
181
+ ...extra,
176
182
  packageVersion,
177
183
  hash: currentVersion,
178
184
  buildTime,
179
185
  cInfo,
180
- // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
181
- ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}),
182
- ...(bundleHash ? { bundleHash } : {}),
183
- ...extra,
184
186
  };
187
+ // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
188
+ if (supportedDiffVersion) {
189
+ body.diffV = supportedDiffVersion;
190
+ } else {
191
+ delete body.diffV;
192
+ }
193
+ if (bundleHash) {
194
+ body.bundleHash = bundleHash;
195
+ } else {
196
+ delete body.bundleHash;
197
+ }
185
198
  if (isDev) {
186
199
  delete body.buildTime;
187
200
  }
201
+ // Transitional dual form: the legacy root-level spread above is what
202
+ // servers before 2026-08-30 read; the nested copy is what new servers read
203
+ // first (extra.toHash wins there) and lets caller keys stop sharing the
204
+ // SDK's namespace once the root spread is retired.
205
+ if (extra) {
206
+ // Match JSON.stringify semantics: undefined values do not exist on the
207
+ // wire, so they must not create a nested copy either (the native check
208
+ // cache compares serialized requests).
209
+ const defined: Record<string, any> = {};
210
+ for (const [key, value] of Object.entries(extra)) {
211
+ if (value !== undefined) {
212
+ defined[key] = value;
213
+ }
214
+ }
215
+ if (Object.keys(defined).length > 0) {
216
+ body.extra = defined;
217
+ }
218
+ }
188
219
  return body;
189
220
  }
190
221
 
222
+ export interface CheckFingerprintInput {
223
+ appKey?: string;
224
+ endpoints?: string[];
225
+ queryUrls?: string[];
226
+ uuid?: string;
227
+ /** The exact serialized request body (identity fields + extra). */
228
+ body: string;
229
+ }
230
+
231
+ /**
232
+ * Identity of one update check. Two checks may share an in-flight response
233
+ * only when every input that could change the server's answer is identical.
234
+ */
235
+ export function buildCheckFingerprint({
236
+ appKey,
237
+ endpoints,
238
+ queryUrls,
239
+ uuid,
240
+ body,
241
+ }: CheckFingerprintInput): string {
242
+ return JSON.stringify([
243
+ appKey ?? '',
244
+ endpoints ?? [],
245
+ queryUrls ?? [],
246
+ uuid ?? '',
247
+ body,
248
+ ]);
249
+ }
250
+
251
+ /**
252
+ * Whether a failed download attempt is worth retrying on another mirror of
253
+ * the same artifact. Transport failures (DNS, connect, timeout, truncated
254
+ * body, 5xx) and corrupt bytes are; a patch that downloaded fine but could
255
+ * not be applied is not — every mirror serves the same bytes, so the next
256
+ * strategy is the only way forward.
257
+ */
258
+ export function isMirrorRetryableCode(code?: string): boolean {
259
+ return code !== 'PATCH_FAILED';
260
+ }
261
+
262
+ /**
263
+ * Schema gate for a checkUpdate response (mirrors C++ IsValidCheckResult): a
264
+ * JSON object carrying at least one verdict field of the expected type —
265
+ * `upToDate` / `update` / `expired` (boolean) or `paused` (string). A 200
266
+ * with `{"error": ...}`, an array or an HTML page that happened to parse is
267
+ * a failed endpoint, not a verdict, and must not stop the endpoint fallback.
268
+ */
269
+ export function isValidCheckResult(value: unknown): value is CheckResult {
270
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
271
+ return false;
272
+ }
273
+ const root = value as Record<string, unknown>;
274
+ return (
275
+ typeof root.upToDate === 'boolean' ||
276
+ typeof root.update === 'boolean' ||
277
+ typeof root.expired === 'boolean' ||
278
+ typeof root.paused === 'string'
279
+ );
280
+ }
281
+
191
282
  export interface UpdateIdentity {
192
283
  packageVersion: string;
193
284
  currentVersion?: string;
package/src/utils.ts CHANGED
@@ -66,6 +66,13 @@ const ping = isWeb
66
66
  DEFAULT_FETCH_TIMEOUT_MS
67
67
  );
68
68
  if (status === 200) {
69
+ if (isProtocolDowngrade(url, finalUrl)) {
70
+ // The probe was redirected from https to plaintext http. Never
71
+ // let a downgraded final URL win the race: the winner is what
72
+ // the real artifact download uses.
73
+ log('ping rejected: https redirected to http', url, finalUrl);
74
+ throw Error(i18n.t('error_ping_failed'));
75
+ }
69
76
  return finalUrl;
70
77
  }
71
78
  log('ping failed', url, status, statusText);
@@ -166,29 +173,34 @@ export const fetchWithTimeout = (
166
173
  });
167
174
  };
168
175
 
169
- const isIdempotentRequest = (params: Parameters<typeof fetch>[1]) => {
170
- const method = params?.method?.toUpperCase() ?? 'GET';
171
- return method === 'GET' || method === 'HEAD';
172
- };
176
+ /**
177
+ * True when `finalUrl` (after redirects) is plaintext http while the request
178
+ * started on https. An https endpoint or artifact URL must never be served
179
+ * over http, redirected or otherwise: the update package is the supply chain
180
+ * boundary, and TLS is the only thing authenticating it.
181
+ */
182
+ export const isProtocolDowngrade = (
183
+ requestedUrl: string,
184
+ finalUrl?: string | null
185
+ ): boolean =>
186
+ !!finalUrl && /^https:/i.test(requestedUrl) && /^http:/i.test(finalUrl);
173
187
 
174
188
  export const enhancedFetch = async (
175
189
  url: string,
176
- params: Parameters<typeof fetch>[1],
177
- isRetry = false
190
+ params: Parameters<typeof fetch>[1]
178
191
  ): Promise<Response> => {
179
- return fetch(url, params).catch((e) => {
192
+ // No https -> http retry here, ever. Earlier versions replayed failed
193
+ // idempotent https requests over plaintext http as a "network
194
+ // compatibility" fallback; that silently moved endpoint discovery and
195
+ // package downloads outside TLS. Self-hosted plaintext deployments must
196
+ // configure explicit http:// URLs instead.
197
+ const response = await fetch(url, params).catch((e) => {
180
198
  log('fetch error', url, e);
181
- if (
182
- isRetry ||
183
- (params as any)?.signal?.aborted ||
184
- !url.startsWith('https:') ||
185
- // Never replay non-idempotent requests (e.g. the checkUpdate POST)
186
- // over plaintext http: the server may have processed the original.
187
- !isIdempotentRequest(params)
188
- ) {
189
- throw e;
190
- }
191
- log('trying fallback to http');
192
- return enhancedFetch(url.replace(/^https:/, 'http:'), params, true);
199
+ throw e;
193
200
  });
201
+ if (isProtocolDowngrade(url, response?.url)) {
202
+ log('fetch rejected: https redirected to http', url, response.url);
203
+ throw Error(i18n.t('error_insecure_redirect'));
204
+ }
205
+ return response;
194
206
  };
package/.gitmodules DELETED
@@ -1,6 +0,0 @@
1
- [submodule "android/jni/lzma"]
2
- path = android/jni/lzma
3
- url = https://github.com/sisong/lzma.git
4
- [submodule "android/jni/HDiffPatch"]
5
- path = android/jni/HDiffPatch
6
- url = https://github.com/sisong/HDiffPatch.git