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.
- package/README-CN.md +59 -31
- package/README.md +62 -32
- package/android/jni/HDiffPatch/.travis.yml +33 -0
- package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/lzma/DOC/7zC.txt +187 -0
- package/android/jni/lzma/DOC/7zFormat.txt +469 -0
- package/android/jni/lzma/DOC/Methods.txt +173 -0
- package/android/jni/lzma/DOC/installer.txt +166 -0
- package/android/jni/lzma/DOC/lzma-history.txt +446 -0
- package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
- package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
- package/android/jni/lzma/DOC/lzma.txt +328 -0
- package/android/jni/lzma/bin/7zS2.sfx +0 -0
- package/android/jni/lzma/bin/7zS2con.sfx +0 -0
- package/android/jni/lzma/bin/7zSD.sfx +0 -0
- package/android/jni/lzma/bin/7zdec.exe +0 -0
- package/android/jni/lzma/bin/7zr.exe +0 -0
- package/android/jni/lzma/bin/installer/config.txt +5 -0
- package/android/jni/lzma/bin/installer/cr.bat +5 -0
- package/android/jni/lzma/bin/lzma.exe +0 -0
- package/android/jni/lzma/bin/x64/7zr.exe +0 -0
- package/android/lib/arm64-v8a/librnupdate.so +0 -0
- package/android/lib/armeabi-v7a/librnupdate.so +0 -0
- package/android/lib/x86/librnupdate.so +0 -0
- package/android/lib/x86_64/librnupdate.so +0 -0
- package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +44 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +124 -40
- package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +125 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +43 -11
- package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +2 -0
- package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +90 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +79 -19
- package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +21 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +33 -2
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +1 -0
- package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
- package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
- package/cpp/patch_core/archive_limits.h +38 -0
- package/cpp/patch_core/install_record.h +36 -0
- package/cpp/update_flow_core/flow_json.h +1 -0
- package/cpp/update_flow_core/update_flow_core.cpp +41 -7
- package/cpp/update_flow_core/update_flow_core.h +9 -0
- package/cpp/update_flow_core/update_flow_jni.cpp +12 -0
- package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +2 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +37 -0
- package/harmony/pushy/src/main/ets/ArchiveLimits.ts +83 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +126 -30
- package/harmony/pushy/src/main/ets/InstallRecord.ts +129 -0
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +24 -2
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +5 -0
- package/harmony/pushy/src/main/ets/PushyTurboModule.ts +12 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +78 -15
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +384 -25
- package/ios/RCTPushy/RCTPushyDownloader.mm +32 -0
- package/package.json +25 -2
- package/src/NativePushy.ts +13 -0
- package/src/client.ts +288 -47
- package/src/core.ts +17 -3
- package/src/error.ts +3 -0
- package/src/errorReporting.ts +136 -0
- package/src/index.ts +14 -0
- package/src/locales/en.ts +3 -0
- package/src/locales/zh.ts +2 -0
- package/src/metadata.ts +166 -0
- package/src/provider.tsx +52 -17
- package/src/type.ts +29 -0
- package/src/updateFlowCore.ts +95 -4
- package/src/utils.ts +31 -19
- package/.gitmodules +0 -6
- package/BUNDLEHASH_MIGRATION.md +0 -355
- package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
- package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
- package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
- package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
- package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
- package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
- package/biome.json +0 -59
- package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
- package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
- package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
- package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
- package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
- package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
- package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
- 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: '存储权限被拒绝,无法下载安装包',
|
package/src/metadata.ts
ADDED
|
@@ -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
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
package/src/updateFlowCore.ts
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|