react-native-update 10.48.3 → 10.50.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/BUNDLEHASH_MIGRATION.md +355 -0
- package/NATIVE_CHECKUPDATE_DESIGN.md +192 -0
- package/android/jni/Android.mk +1 -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/BundledResourceCopier.java +42 -9
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
- package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +125 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +23 -3
- 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/digest.cpp +230 -0
- package/cpp/patch_core/digest.h +61 -0
- package/cpp/patch_core/patch_core.cpp +17 -1
- package/cpp/patch_core/patch_core.h +8 -0
- package/cpp/patch_core/tests/patch_core_test.cpp +169 -0
- package/harmony/pushy/src/main/cpp/CMakeLists.txt +1 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +94 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +4 -0
- package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +79 -4
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +116 -7
- package/package.json +1 -1
- package/react-native-update.podspec +1 -0
- package/src/NativePushy.ts +6 -0
- package/src/client.ts +119 -7
- package/src/core.ts +27 -0
- package/src/endpoint.ts +98 -51
- package/src/error.ts +4 -0
- package/src/isInRollout.ts +2 -1
- package/src/locales/en.ts +6 -0
- package/src/locales/zh.ts +6 -0
- package/src/provider.tsx +36 -7
- package/src/telemetry.ts +8 -0
- package/src/type.ts +34 -3
- package/src/utils.ts +13 -0
package/ios/RCTPushy/RCTPushy.mm
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#import "RCTPushyDownloader.h"
|
|
3
3
|
#import "ZipArchive.h"
|
|
4
4
|
#include "../../cpp/patch_core/archive_patch_core.h"
|
|
5
|
+
#include "../../cpp/patch_core/digest.h"
|
|
5
6
|
#include "../../cpp/patch_core/hbc_transform_wire.h"
|
|
6
7
|
#include "../../cpp/patch_core/error_codes.h"
|
|
7
8
|
#include "../../cpp/patch_core/patch_core.h"
|
|
@@ -32,6 +33,10 @@ static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_";
|
|
|
32
33
|
static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY";
|
|
33
34
|
static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY";
|
|
34
35
|
static NSString *const KeyPackageUpdatedMarked = @"REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY";
|
|
36
|
+
// bundleHash cache: "<cacheKey>|<sha256hex>" where cacheKey identifies the
|
|
37
|
+
// installed binary (packageVersion + embedded bundle size + mtime). Recomputed
|
|
38
|
+
// only when the key changes, i.e. once per install.
|
|
39
|
+
static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY";
|
|
35
40
|
static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
|
|
36
41
|
|
|
37
42
|
// file def
|
|
@@ -134,6 +139,25 @@ static BOOL PushyStringIsBlank(NSString *value) {
|
|
|
134
139
|
return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0;
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
// Server-provided identifiers (hash/originHash) are used as child names under
|
|
143
|
+
// the download root; anything that could resolve outside of it (path
|
|
144
|
+
// separators, "..", ".") must be rejected before touching the filesystem.
|
|
145
|
+
static BOOL PushyIsSafePathComponent(NSString *value) {
|
|
146
|
+
if (PushyStringIsBlank(value)) {
|
|
147
|
+
return NO;
|
|
148
|
+
}
|
|
149
|
+
if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) {
|
|
150
|
+
return NO;
|
|
151
|
+
}
|
|
152
|
+
if ([value containsString:@"/"] || [value containsString:@"\\"]) {
|
|
153
|
+
return NO;
|
|
154
|
+
}
|
|
155
|
+
if ([value rangeOfString:@"\0"].location != NSNotFound) {
|
|
156
|
+
return NO;
|
|
157
|
+
}
|
|
158
|
+
return YES;
|
|
159
|
+
}
|
|
160
|
+
|
|
137
161
|
static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
|
|
138
162
|
// Prefer the stable cross-platform code (error_codes.h); fall back to the
|
|
139
163
|
// numeric NSError code for system errors that were not classified.
|
|
@@ -157,15 +181,28 @@ static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json
|
|
|
157
181
|
pushy::patch::PatchManifest manifest;
|
|
158
182
|
|
|
159
183
|
NSDictionary *copies = json[@"copies"];
|
|
184
|
+
// Content checksum per copy target ("copiesCrc", pdiff manifests from
|
|
185
|
+
// CLI >= 2.21.2). The patch core verifies the copy source against it and
|
|
186
|
+
// fails the patch on mismatch, so the JS strategy chain falls back to the
|
|
187
|
+
// full package instead of copying drifted bytes from a rebuilt binary.
|
|
188
|
+
NSDictionary *copiesCrc = json[@"copiesCrc"];
|
|
189
|
+
if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
|
|
190
|
+
copiesCrc = nil;
|
|
191
|
+
}
|
|
160
192
|
for (NSString *to in copies) {
|
|
161
193
|
NSString *from = copies[to];
|
|
162
194
|
if (from.length <= 0) {
|
|
163
195
|
from = to;
|
|
164
196
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
pushy::patch::CopyOperation operation;
|
|
198
|
+
operation.from = PushyToStdString(from);
|
|
199
|
+
operation.to = PushyToStdString(to);
|
|
200
|
+
NSNumber *expectedCrc = copiesCrc[to];
|
|
201
|
+
if ([expectedCrc isKindOfClass:[NSNumber class]]) {
|
|
202
|
+
operation.has_expected_crc = true;
|
|
203
|
+
operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
|
|
204
|
+
}
|
|
205
|
+
manifest.copies.push_back(operation);
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
NSDictionary *deletes = json[@"deletes"];
|
|
@@ -243,6 +280,7 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
|
|
|
243
280
|
callback:(void (^)(NSError *error))callback;
|
|
244
281
|
- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error;
|
|
245
282
|
- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path;
|
|
283
|
+
+ (void)excludeFromBackup:(NSString *)path;
|
|
246
284
|
- (void)unzipFileAtPath:(NSString *)path
|
|
247
285
|
toDestination:(NSString *)destination
|
|
248
286
|
completionHandler:(void (^)(NSError *error))completionHandler;
|
|
@@ -512,6 +550,56 @@ RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve
|
|
|
512
550
|
resolve(@true);
|
|
513
551
|
}
|
|
514
552
|
|
|
553
|
+
RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
|
|
554
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
555
|
+
{
|
|
556
|
+
// bundleHash = sha256 of the bundle embedded in the binary — the identity
|
|
557
|
+
// of the binary itself, not of whatever hot update is currently running.
|
|
558
|
+
// It must hash exactly the bytes pdiff patches from (binaryBundleURL, the
|
|
559
|
+
// pdiff fromBundle). Lazily computed once per install, cached in defaults.
|
|
560
|
+
// Never rejects: an empty string means "unknown" and the server falls back
|
|
561
|
+
// to the buildTime heuristic.
|
|
562
|
+
#if DEBUG
|
|
563
|
+
// Metro serves the bundle in debug; the embedded file (if any) is not what
|
|
564
|
+
// is running. Mirrors the dev behaviour of buildTime.
|
|
565
|
+
resolve(@"");
|
|
566
|
+
#else
|
|
567
|
+
dispatch_async(_fileQueue, ^{
|
|
568
|
+
NSString *path = [[RCTPushy binaryBundleURL] path];
|
|
569
|
+
if (path == nil) {
|
|
570
|
+
resolve(@"");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
NSDictionary *attributes =
|
|
574
|
+
[[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
|
|
575
|
+
if (attributes == nil) {
|
|
576
|
+
resolve(@"");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
|
|
580
|
+
[RCTPushy packageVersion],
|
|
581
|
+
attributes.fileSize,
|
|
582
|
+
[attributes.fileModificationDate timeIntervalSince1970]];
|
|
583
|
+
|
|
584
|
+
NSUserDefaults *defaults = PushyDefaults();
|
|
585
|
+
NSString *cached = [defaults stringForKey:keyBundleHashCache];
|
|
586
|
+
NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
|
|
587
|
+
if ([cached hasPrefix:cachedPrefix]) {
|
|
588
|
+
resolve([cached substringFromIndex:cachedPrefix.length]);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
NSString *hash = PushyFromStdString(
|
|
593
|
+
pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
|
|
594
|
+
if (hash.length > 0) {
|
|
595
|
+
[defaults setObject:[cachedPrefix stringByAppendingString:hash]
|
|
596
|
+
forKey:keyBundleHashCache];
|
|
597
|
+
}
|
|
598
|
+
resolve(hash);
|
|
599
|
+
});
|
|
600
|
+
#endif
|
|
601
|
+
}
|
|
602
|
+
|
|
515
603
|
RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
|
|
516
604
|
rejecter:(RCTPromiseRejectBlock)reject)
|
|
517
605
|
{
|
|
@@ -639,12 +727,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
639
727
|
NSString *updateUrl = PushyOptionString(options, @"updateUrl");
|
|
640
728
|
NSString *hash = PushyOptionString(options, @"hash");
|
|
641
729
|
|
|
642
|
-
if (PushyStringIsBlank(updateUrl) ||
|
|
730
|
+
if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) {
|
|
643
731
|
callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
|
|
644
732
|
return;
|
|
645
733
|
}
|
|
646
734
|
NSString *originHash = PushyOptionString(options, @"originHash");
|
|
647
|
-
if (type == PushyTypePatchFromPpk &&
|
|
735
|
+
if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) {
|
|
648
736
|
callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
|
|
649
737
|
return;
|
|
650
738
|
}
|
|
@@ -838,7 +926,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
838
926
|
|
|
839
927
|
- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error
|
|
840
928
|
{
|
|
841
|
-
if (
|
|
929
|
+
if (!PushyIsSafePathComponent(hash)) {
|
|
842
930
|
if (error != NULL) {
|
|
843
931
|
*error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
|
|
844
932
|
}
|
|
@@ -867,6 +955,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
867
955
|
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
868
956
|
BOOL isDirectory = NO;
|
|
869
957
|
if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
|
|
958
|
+
if (isDirectory) {
|
|
959
|
+
// Directories created by older versions never got the flag.
|
|
960
|
+
[RCTPushy excludeFromBackup:path];
|
|
961
|
+
}
|
|
870
962
|
return isDirectory;
|
|
871
963
|
}
|
|
872
964
|
|
|
@@ -878,10 +970,27 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
878
970
|
if (!success && error != nil) {
|
|
879
971
|
RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
|
|
880
972
|
}
|
|
973
|
+
if (success) {
|
|
974
|
+
[RCTPushy excludeFromBackup:path];
|
|
975
|
+
}
|
|
881
976
|
|
|
882
977
|
return success;
|
|
883
978
|
}
|
|
884
979
|
|
|
980
|
+
// Everything under rctpushy is re-downloadable, and Application Support is
|
|
981
|
+
// backed up to iCloud by default — Apple requires such content to be
|
|
982
|
+
// excluded from backups.
|
|
983
|
+
+ (void)excludeFromBackup:(NSString *)path
|
|
984
|
+
{
|
|
985
|
+
NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES];
|
|
986
|
+
NSError *error = nil;
|
|
987
|
+
if (![url setResourceValue:@YES
|
|
988
|
+
forKey:NSURLIsExcludedFromBackupKey
|
|
989
|
+
error:&error]) {
|
|
990
|
+
RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
885
994
|
- (void)unzipFileAtPath:(NSString *)path
|
|
886
995
|
toDestination:(NSString *)destination
|
|
887
996
|
completionHandler:(void (^)(NSError *error))completionHandler
|
package/package.json
CHANGED
|
@@ -111,6 +111,7 @@ Pod::Spec.new do |s|
|
|
|
111
111
|
s.subspec 'RCTPushy' do |ss|
|
|
112
112
|
ss.source_files = ['ios/RCTPushy/*.{h,m,mm}',
|
|
113
113
|
'cpp/patch_core/archive_patch_core.{h,cpp}',
|
|
114
|
+
'cpp/patch_core/digest.{h,cpp}',
|
|
114
115
|
'cpp/patch_core/hbc_transform.{h,cpp}',
|
|
115
116
|
'cpp/patch_core/hbc_transform_wire.{h,cpp}',
|
|
116
117
|
'cpp/patch_core/patch_core.{h,cpp}',
|
package/src/NativePushy.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface Spec extends TurboModule {
|
|
|
20
20
|
restartApp(): Promise<void>;
|
|
21
21
|
setNeedUpdate(options: { hash: string }): Promise<void>;
|
|
22
22
|
markSuccess(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* sha256 of the JS bundle embedded in the binary (the pdiff source), lazily
|
|
25
|
+
* computed and cached natively. Resolves to an empty string when unknown
|
|
26
|
+
* (debug build, no embedded bundle, hash failure) — never rejects.
|
|
27
|
+
*/
|
|
28
|
+
getBundleHash(): Promise<string>;
|
|
23
29
|
resetToPackagedBundle(): Promise<void>;
|
|
24
30
|
downloadPatchFromPpk(options: {
|
|
25
31
|
updateUrl: string;
|
package/src/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
cInfo,
|
|
9
9
|
currentVersion,
|
|
10
10
|
currentVersionInfo,
|
|
11
|
+
getBundleHash,
|
|
11
12
|
isFirstTime,
|
|
12
13
|
isRolledBack,
|
|
13
14
|
PushyModule,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
noop,
|
|
52
53
|
promiseAny,
|
|
53
54
|
testUrls,
|
|
55
|
+
warn,
|
|
54
56
|
} from './utils';
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -99,7 +101,7 @@ const excludeConfiguredEndpoints = (
|
|
|
99
101
|
|
|
100
102
|
assertWeb();
|
|
101
103
|
|
|
102
|
-
const
|
|
104
|
+
const createDefaultClientOptions = (): ClientOptions => ({
|
|
103
105
|
appKey: '',
|
|
104
106
|
autoMarkSuccess: true,
|
|
105
107
|
updateStrategy: __DEV__ ? 'alwaysAlert' : 'alertUpdateAndIgnoreError',
|
|
@@ -107,7 +109,7 @@ const defaultClientOptions: ClientOptions = {
|
|
|
107
109
|
logger: noop,
|
|
108
110
|
debug: false,
|
|
109
111
|
throwError: false,
|
|
110
|
-
};
|
|
112
|
+
});
|
|
111
113
|
|
|
112
114
|
export const sharedState: {
|
|
113
115
|
progressHandlers: Record<string, EmitterSubscription>;
|
|
@@ -131,6 +133,13 @@ export const sharedState: {
|
|
|
131
133
|
applyingUpdate: false,
|
|
132
134
|
};
|
|
133
135
|
|
|
136
|
+
// The SDK is a process-level singleton: module-level sharedState, the global
|
|
137
|
+
// i18n locale and the native update state are all per-process, so a second
|
|
138
|
+
// client would silently share (and fight over) them. Constructing one is a
|
|
139
|
+
// hard integration error, except for the idempotent re-creation of the same
|
|
140
|
+
// client (same type + appKey), which dev fast-refresh triggers legitimately.
|
|
141
|
+
let activeClient: Pushy | undefined;
|
|
142
|
+
|
|
134
143
|
const assertHash = (hash: string) => {
|
|
135
144
|
if (!sharedState.downloadedHash) {
|
|
136
145
|
log(`no downloaded hash yet, ignore switch to ${hash}`);
|
|
@@ -145,7 +154,7 @@ const assertHash = (hash: string) => {
|
|
|
145
154
|
|
|
146
155
|
// for China users
|
|
147
156
|
export class Pushy {
|
|
148
|
-
options =
|
|
157
|
+
options: ClientOptions = createDefaultClientOptions();
|
|
149
158
|
clientType: 'Pushy' | 'Cresc' = 'Pushy';
|
|
150
159
|
lastChecking?: number;
|
|
151
160
|
lastRespJson?: Promise<CheckResult>;
|
|
@@ -167,6 +176,23 @@ export class Pushy {
|
|
|
167
176
|
|
|
168
177
|
constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
|
|
169
178
|
this.clientType = clientType || 'Pushy';
|
|
179
|
+
if (activeClient) {
|
|
180
|
+
if (
|
|
181
|
+
activeClient.clientType === this.clientType &&
|
|
182
|
+
activeClient.options.appKey === options.appKey
|
|
183
|
+
) {
|
|
184
|
+
// Same client re-created (e.g. fast refresh re-running the module
|
|
185
|
+
// that builds it): apply the latest options and hand back the
|
|
186
|
+
// existing instance instead of forking process-level state.
|
|
187
|
+
activeClient.setOptions(options);
|
|
188
|
+
// biome-ignore lint/correctness/noConstructorReturn: intentional singleton — identical re-creation must yield the existing instance
|
|
189
|
+
return activeClient;
|
|
190
|
+
}
|
|
191
|
+
throw new UpdateError(
|
|
192
|
+
i18n.t('error_client_singleton'),
|
|
193
|
+
'SINGLETON_VIOLATION'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
170
196
|
this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
|
|
171
197
|
|
|
172
198
|
i18n.setLocale(
|
|
@@ -191,8 +217,28 @@ export class Pushy {
|
|
|
191
217
|
},
|
|
192
218
|
});
|
|
193
219
|
}
|
|
220
|
+
activeClient = this;
|
|
194
221
|
}
|
|
195
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Bumped on every setOptions call. `options` is mutated in place (its
|
|
225
|
+
* identity never changes), so reactive consumers (the UpdateProvider)
|
|
226
|
+
* subscribe via onOptionsChange and re-read using this version as the
|
|
227
|
+
* change signal.
|
|
228
|
+
*/
|
|
229
|
+
optionsVersion = 0;
|
|
230
|
+
private optionsListeners = new Set<() => void>();
|
|
231
|
+
/**
|
|
232
|
+
* Subscribe to option changes (any setOptions call). Returns an
|
|
233
|
+
* unsubscribe function.
|
|
234
|
+
*/
|
|
235
|
+
onOptionsChange = (listener: () => void) => {
|
|
236
|
+
this.optionsListeners.add(listener);
|
|
237
|
+
return () => {
|
|
238
|
+
this.optionsListeners.delete(listener);
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
196
242
|
setOptions = (options: Partial<ClientOptions>) => {
|
|
197
243
|
for (const [key, value] of Object.entries(options)) {
|
|
198
244
|
if (value !== undefined) {
|
|
@@ -205,6 +251,34 @@ export class Pushy {
|
|
|
205
251
|
}
|
|
206
252
|
}
|
|
207
253
|
}
|
|
254
|
+
this.optionsVersion++;
|
|
255
|
+
for (const listener of this.optionsListeners) {
|
|
256
|
+
try {
|
|
257
|
+
listener();
|
|
258
|
+
} catch (e: any) {
|
|
259
|
+
log('onOptionsChange listener error:', e?.message || e);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
private providerMounted = false;
|
|
265
|
+
/**
|
|
266
|
+
* Called by UpdateProvider on mount. A second concurrently mounted
|
|
267
|
+
* provider is the same integration error as a second client — fail hard
|
|
268
|
+
* instead of double-subscribing app-state listeners and update checks.
|
|
269
|
+
* Returns the release function used on unmount.
|
|
270
|
+
*/
|
|
271
|
+
claimProviderMount = () => {
|
|
272
|
+
if (this.providerMounted) {
|
|
273
|
+
throw new UpdateError(
|
|
274
|
+
this.t('error_provider_singleton'),
|
|
275
|
+
'SINGLETON_VIOLATION'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
this.providerMounted = true;
|
|
279
|
+
return () => {
|
|
280
|
+
this.providerMounted = false;
|
|
281
|
+
};
|
|
208
282
|
};
|
|
209
283
|
|
|
210
284
|
/**
|
|
@@ -450,11 +524,12 @@ export class Pushy {
|
|
|
450
524
|
};
|
|
451
525
|
requestCheckResult = async (
|
|
452
526
|
endpoint: string,
|
|
453
|
-
fetchPayload: Parameters<typeof fetch>[1]
|
|
527
|
+
fetchPayload: Parameters<typeof fetch>[1],
|
|
528
|
+
signal?: AbortSignal
|
|
454
529
|
) => {
|
|
455
530
|
const resp = await fetchWithTimeout(
|
|
456
531
|
this.getCheckUrl(endpoint),
|
|
457
|
-
fetchPayload,
|
|
532
|
+
signal ? { ...fetchPayload, signal } : fetchPayload,
|
|
458
533
|
DEFAULT_FETCH_TIMEOUT_MS
|
|
459
534
|
);
|
|
460
535
|
|
|
@@ -476,9 +551,13 @@ export class Pushy {
|
|
|
476
551
|
const { endpoint, value } = await executeEndpointFallback<CheckResult>({
|
|
477
552
|
configuredEndpoints: this.getConfiguredCheckEndpoints(),
|
|
478
553
|
getRemoteEndpoints: this.getRemoteEndpoints,
|
|
479
|
-
tryEndpoint: async (currentEndpoint) => {
|
|
554
|
+
tryEndpoint: async (currentEndpoint, signal) => {
|
|
480
555
|
try {
|
|
481
|
-
return await this.requestCheckResult(
|
|
556
|
+
return await this.requestCheckResult(
|
|
557
|
+
currentEndpoint,
|
|
558
|
+
fetchPayload,
|
|
559
|
+
signal
|
|
560
|
+
);
|
|
482
561
|
} catch (e) {
|
|
483
562
|
log('check endpoint failed', currentEndpoint, e);
|
|
484
563
|
throw e;
|
|
@@ -591,6 +670,11 @@ export class Pushy {
|
|
|
591
670
|
this.notifyAfterCheckUpdate({ status: 'skipped' });
|
|
592
671
|
return;
|
|
593
672
|
}
|
|
673
|
+
// 内容寻址的二进制身份,服务端据此精确判定 pdiff 适用性(取代 buildTime
|
|
674
|
+
// 启发式)。同步读 core 里已预取的值:还没算完就省略字段(服务端回退
|
|
675
|
+
// buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化
|
|
676
|
+
// 检查流程。
|
|
677
|
+
const bundleHash = __DEV__ ? '' : getBundleHash();
|
|
594
678
|
const now = Date.now();
|
|
595
679
|
if (
|
|
596
680
|
this.lastRespJson &&
|
|
@@ -620,6 +704,7 @@ export class Pushy {
|
|
|
620
704
|
cInfo,
|
|
621
705
|
// 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
|
|
622
706
|
...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}),
|
|
707
|
+
...(bundleHash ? { bundleHash } : {}),
|
|
623
708
|
...extra,
|
|
624
709
|
};
|
|
625
710
|
if (__DEV__) {
|
|
@@ -651,6 +736,16 @@ export class Pushy {
|
|
|
651
736
|
|
|
652
737
|
log('checking result:', result);
|
|
653
738
|
|
|
739
|
+
if (result?.bundleStatus === 'unknownBundle') {
|
|
740
|
+
// 服务端判定当前二进制内嵌 bundle 未注册,增量已被降级为全量。只面向
|
|
741
|
+
// 开发者(日志 + 遥测,控制台聚合是主渠道);终端用户无感知。
|
|
742
|
+
warn(this.t('warn_unknown_bundle'));
|
|
743
|
+
this.report({
|
|
744
|
+
type: 'bundleMismatch',
|
|
745
|
+
data: bundleHash ? { bundleHash } : {},
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
654
749
|
this.notifyAfterCheckUpdate({ status: 'completed', result });
|
|
655
750
|
return result;
|
|
656
751
|
} catch (e: any) {
|
|
@@ -886,6 +981,23 @@ export class Pushy {
|
|
|
886
981
|
delete sharedState.progressHandlers[hash];
|
|
887
982
|
}
|
|
888
983
|
delete sharedState.progressCallbacks[hash];
|
|
984
|
+
if (succeeded && errorMessages.length > 0) {
|
|
985
|
+
// An earlier strategy failed and a later one rescued the download
|
|
986
|
+
// (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface
|
|
987
|
+
// the degradation: it is invisible to the end user but tells the
|
|
988
|
+
// platform that incremental delivery is failing for this binary.
|
|
989
|
+
this.report({
|
|
990
|
+
type: 'downloadFallback',
|
|
991
|
+
// UpdateError.code already carries the classified native rejection
|
|
992
|
+
// code (PATCH_FAILED vs DOWNLOAD_FAILED).
|
|
993
|
+
code: lastError?.code,
|
|
994
|
+
data: {
|
|
995
|
+
newVersion: hash,
|
|
996
|
+
succeeded,
|
|
997
|
+
},
|
|
998
|
+
message: errorMessages.join(';'),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
889
1001
|
if (!succeeded) {
|
|
890
1002
|
const message = errorMessages.join(';');
|
|
891
1003
|
if (lastError) {
|
package/src/core.ts
CHANGED
|
@@ -71,6 +71,33 @@ export const supportedDiffVersion: number =
|
|
|
71
71
|
PushyConstants.supportedDiffVersion || 0;
|
|
72
72
|
let uuid = PushyConstants.uuid;
|
|
73
73
|
|
|
74
|
+
// bundleHash = sha256 of the JS bundle embedded in the binary (the pdiff
|
|
75
|
+
// source). Content identity for the server's pdiff-applicability check,
|
|
76
|
+
// replacing the buildTime heuristic. Natively lazy-computed and cached;
|
|
77
|
+
// prefetched fire-and-forget at module load so the value is usually settled
|
|
78
|
+
// before the first checkUpdate builds its body. Until then (first install,
|
|
79
|
+
// slow device) getBundleHash returns '' and the field is simply omitted — the
|
|
80
|
+
// server falls back to the buildTime heuristic and the next check carries it.
|
|
81
|
+
// Deliberately synchronous at read time: checkUpdate must not await anything
|
|
82
|
+
// between its dedup window and the fetch, and a hash is never worth delaying
|
|
83
|
+
// a check for.
|
|
84
|
+
let bundleHash = '';
|
|
85
|
+
// The JS layer can arrive via hot update onto an older binary whose native
|
|
86
|
+
// module predates the method, hence the feature detect. On web PushyModule is
|
|
87
|
+
// a noop Proxy and the call settles to ''.
|
|
88
|
+
if (typeof PushyModule.getBundleHash === 'function') {
|
|
89
|
+
Promise.resolve(PushyModule.getBundleHash())
|
|
90
|
+
.then((hash: unknown) => {
|
|
91
|
+
bundleHash = String(hash || '');
|
|
92
|
+
})
|
|
93
|
+
.catch((e: any) => {
|
|
94
|
+
log('getBundleHash failed:', e?.message || e);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** '' while unknown (not yet computed, debug build, no embedded bundle, older native). */
|
|
99
|
+
export const getBundleHash = (): string => bundleHash;
|
|
100
|
+
|
|
74
101
|
async function getLocalHashInfo(hash: string) {
|
|
75
102
|
return JSON.parse(await PushyModule.getLocalHashInfo(hash));
|
|
76
103
|
}
|
package/src/endpoint.ts
CHANGED
|
@@ -14,9 +14,10 @@ export interface EndpointAttemptFailure {
|
|
|
14
14
|
export interface ExecuteEndpointFallbackOptions<T> {
|
|
15
15
|
configuredEndpoints: string[];
|
|
16
16
|
getRemoteEndpoints?: () => Promise<string[]>;
|
|
17
|
-
tryEndpoint: (endpoint: string) => Promise<T>;
|
|
17
|
+
tryEndpoint: (endpoint: string, signal?: AbortSignal) => Promise<T>;
|
|
18
18
|
random?: () => number;
|
|
19
19
|
now?: () => number;
|
|
20
|
+
hedgeDelayMs?: number;
|
|
20
21
|
onFirstFailure?: (failure: EndpointAttemptFailure) => void | Promise<void>;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -54,60 +55,104 @@ export const pickRandomEndpoint = (
|
|
|
54
55
|
return endpoints[Math.floor(random() * endpoints.length)];
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
export
|
|
58
|
+
export const DEFAULT_HEDGE_DELAY_MS = 250;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Hedged race over the candidate endpoints: the first candidate starts
|
|
62
|
+
* immediately, each following one is only started after `hedgeDelayMs` of
|
|
63
|
+
* silence (or immediately when a previous attempt failed). The first
|
|
64
|
+
* successful response wins and every other in-flight request is aborted, so
|
|
65
|
+
* one slow endpoint never gates a fast one, and healthy rounds send a single
|
|
66
|
+
* request instead of blasting every candidate at once.
|
|
67
|
+
*/
|
|
68
|
+
export function selectFastestSuccessfulEndpoint<T>(
|
|
58
69
|
endpoints: string[],
|
|
59
|
-
tryEndpoint: (endpoint: string) => Promise<T>,
|
|
60
|
-
now: () => number = Date.now
|
|
70
|
+
tryEndpoint: (endpoint: string, signal?: AbortSignal) => Promise<T>,
|
|
71
|
+
now: () => number = Date.now,
|
|
72
|
+
hedgeDelayMs: number = DEFAULT_HEDGE_DELAY_MS
|
|
61
73
|
): Promise<{
|
|
62
|
-
|
|
74
|
+
success?: EndpointAttemptSuccess<T>;
|
|
63
75
|
failures: EndpointAttemptFailure[];
|
|
64
76
|
}> {
|
|
65
|
-
|
|
66
|
-
endpoints.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const value = await tryEndpoint(endpoint);
|
|
70
|
-
return {
|
|
71
|
-
ok: true as const,
|
|
72
|
-
endpoint,
|
|
73
|
-
value,
|
|
74
|
-
duration: now() - start,
|
|
75
|
-
};
|
|
76
|
-
} catch (error) {
|
|
77
|
-
return {
|
|
78
|
-
ok: false as const,
|
|
79
|
-
endpoint,
|
|
80
|
-
error: normalizeError(error),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
})
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
const successes: EndpointAttemptSuccess<T>[] = [];
|
|
87
|
-
const failures: EndpointAttemptFailure[] = [];
|
|
88
|
-
|
|
89
|
-
for (const attempt of attempts) {
|
|
90
|
-
if (attempt.ok) {
|
|
91
|
-
successes.push({
|
|
92
|
-
endpoint: attempt.endpoint,
|
|
93
|
-
value: attempt.value,
|
|
94
|
-
duration: attempt.duration,
|
|
95
|
-
});
|
|
96
|
-
continue;
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
if (!endpoints.length) {
|
|
79
|
+
resolve({ failures: [] });
|
|
80
|
+
return;
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
failures
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
83
|
+
const failures: EndpointAttemptFailure[] = [];
|
|
84
|
+
const controllers: AbortController[] = [];
|
|
85
|
+
let nextIndex = 0;
|
|
86
|
+
let pending = 0;
|
|
87
|
+
let settled = false;
|
|
88
|
+
let hedgeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
89
|
|
|
105
|
-
|
|
90
|
+
const finish = (success?: EndpointAttemptSuccess<T>) => {
|
|
91
|
+
if (settled) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
settled = true;
|
|
95
|
+
if (hedgeTimer) {
|
|
96
|
+
clearTimeout(hedgeTimer);
|
|
97
|
+
}
|
|
98
|
+
for (const controller of controllers) {
|
|
99
|
+
controller.abort();
|
|
100
|
+
}
|
|
101
|
+
resolve({ success, failures });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const launchNext = () => {
|
|
105
|
+
if (hedgeTimer) {
|
|
106
|
+
clearTimeout(hedgeTimer);
|
|
107
|
+
hedgeTimer = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (settled || nextIndex >= endpoints.length) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const endpoint = endpoints[nextIndex++];
|
|
113
|
+
const controller =
|
|
114
|
+
typeof AbortController === 'undefined'
|
|
115
|
+
? undefined
|
|
116
|
+
: new AbortController();
|
|
117
|
+
if (controller) {
|
|
118
|
+
controllers.push(controller);
|
|
119
|
+
}
|
|
120
|
+
pending++;
|
|
121
|
+
const start = now();
|
|
122
|
+
tryEndpoint(endpoint, controller?.signal)
|
|
123
|
+
.then((value) => {
|
|
124
|
+
if (controller) {
|
|
125
|
+
// Only the losing requests get aborted, not the winner's own
|
|
126
|
+
// (already settled) one.
|
|
127
|
+
const index = controllers.indexOf(controller);
|
|
128
|
+
if (index >= 0) {
|
|
129
|
+
controllers.splice(index, 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finish({ endpoint, value, duration: now() - start });
|
|
133
|
+
})
|
|
134
|
+
.catch((error) => {
|
|
135
|
+
pending--;
|
|
136
|
+
if (settled) {
|
|
137
|
+
// Losers cancelled after a win must not count as failures.
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
failures.push({ endpoint, error: normalizeError(error) });
|
|
141
|
+
if (nextIndex < endpoints.length) {
|
|
142
|
+
// A failure frees its slot: hedge the next candidate right away
|
|
143
|
+
// instead of waiting out the stagger.
|
|
144
|
+
launchNext();
|
|
145
|
+
} else if (pending === 0) {
|
|
146
|
+
finish(undefined);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (!settled && nextIndex < endpoints.length) {
|
|
150
|
+
hedgeTimer = setTimeout(launchNext, hedgeDelayMs);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
106
153
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
failures,
|
|
110
|
-
};
|
|
154
|
+
launchNext();
|
|
155
|
+
});
|
|
111
156
|
}
|
|
112
157
|
|
|
113
158
|
export async function executeEndpointFallback<T>({
|
|
@@ -116,6 +161,7 @@ export async function executeEndpointFallback<T>({
|
|
|
116
161
|
tryEndpoint,
|
|
117
162
|
random = Math.random,
|
|
118
163
|
now = Date.now,
|
|
164
|
+
hedgeDelayMs = DEFAULT_HEDGE_DELAY_MS,
|
|
119
165
|
onFirstFailure,
|
|
120
166
|
}: ExecuteEndpointFallbackOptions<T>): Promise<EndpointAttemptSuccess<T>> {
|
|
121
167
|
const excludedEndpoints = new Set<string>();
|
|
@@ -154,14 +200,15 @@ export async function executeEndpointFallback<T>({
|
|
|
154
200
|
throw lastError;
|
|
155
201
|
}
|
|
156
202
|
|
|
157
|
-
const {
|
|
203
|
+
const { success, failures } = await selectFastestSuccessfulEndpoint(
|
|
158
204
|
candidates,
|
|
159
205
|
tryEndpoint,
|
|
160
|
-
now
|
|
206
|
+
now,
|
|
207
|
+
hedgeDelayMs
|
|
161
208
|
);
|
|
162
209
|
|
|
163
|
-
if (
|
|
164
|
-
return
|
|
210
|
+
if (success) {
|
|
211
|
+
return success;
|
|
165
212
|
}
|
|
166
213
|
|
|
167
214
|
for (const failure of failures) {
|