react-native-update 10.49.0 → 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 +22 -2
- 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 +36 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +12 -3
- package/cpp/patch_core/digest.cpp +53 -0
- package/cpp/patch_core/digest.h +18 -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 +99 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +47 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +2 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +25 -4
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +61 -7
- package/package.json +1 -1
- package/src/client.ts +98 -4
- package/src/endpoint.ts +98 -51
- package/src/error.ts +4 -0
- package/src/isInRollout.ts +2 -1
- package/src/locales/en.ts +4 -0
- package/src/locales/zh.ts +4 -0
- package/src/provider.tsx +36 -7
- package/src/telemetry.ts +8 -0
- package/src/type.ts +5 -0
- package/src/utils.ts +13 -0
|
@@ -15,6 +15,10 @@ import NativePatchCore, {
|
|
|
15
15
|
export interface PatchManifestArrays {
|
|
16
16
|
copyFroms: string[];
|
|
17
17
|
copyTos: string[];
|
|
18
|
+
// 与 copyFroms/copyTos 逐位对齐的 copiesCrc(CLI >= 2.21.2 的 pdiff
|
|
19
|
+
// manifest 携带,键是 to);无声明时为 null。拷贝前用它校验包内资源内容,
|
|
20
|
+
// 不符则整次 patch 失败落 full——重打包二进制不能静默拷出漂移的资源。
|
|
21
|
+
copyCrcs: (number | null)[];
|
|
18
22
|
deletes: string[];
|
|
19
23
|
// __diff.json 中对应 bundle patch 条目的 hbcTransform 元数据(原始 JSON
|
|
20
24
|
// 字符串);为空时 native 走现状路径
|
|
@@ -27,6 +31,7 @@ export function parseManifestToArrays(
|
|
|
27
31
|
): PatchManifestArrays {
|
|
28
32
|
const copyFroms: string[] = [];
|
|
29
33
|
const copyTos: string[] = [];
|
|
34
|
+
const copyCrcs: (number | null)[] = [];
|
|
30
35
|
const deletesValue = manifest.deletes;
|
|
31
36
|
const deletes = Array.isArray(deletesValue)
|
|
32
37
|
? deletesValue.map(item => String(item))
|
|
@@ -35,6 +40,11 @@ export function parseManifestToArrays(
|
|
|
35
40
|
: [];
|
|
36
41
|
|
|
37
42
|
const copies = (manifest.copies || {}) as Record<string, string>;
|
|
43
|
+
const copiesCrcValue = manifest.copiesCrc;
|
|
44
|
+
const copiesCrc =
|
|
45
|
+
copiesCrcValue && typeof copiesCrcValue === 'object'
|
|
46
|
+
? (copiesCrcValue as Record<string, number>)
|
|
47
|
+
: ({} as Record<string, number>);
|
|
38
48
|
for (const [to, rawFrom] of Object.entries(copies)) {
|
|
39
49
|
let from = String(rawFrom || '');
|
|
40
50
|
if (normalizeResourceCopies) {
|
|
@@ -45,6 +55,8 @@ export function parseManifestToArrays(
|
|
|
45
55
|
}
|
|
46
56
|
copyFroms.push(from);
|
|
47
57
|
copyTos.push(to);
|
|
58
|
+
const crc = copiesCrc[to];
|
|
59
|
+
copyCrcs.push(typeof crc === 'number' && Number.isFinite(crc) ? crc : null);
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
const hbcTransform = manifest.hbcTransform as
|
|
@@ -62,6 +74,7 @@ export function parseManifestToArrays(
|
|
|
62
74
|
return {
|
|
63
75
|
copyFroms,
|
|
64
76
|
copyTos,
|
|
77
|
+
copyCrcs,
|
|
65
78
|
deletes,
|
|
66
79
|
hbcTransformMeta,
|
|
67
80
|
};
|
|
@@ -229,6 +242,7 @@ export class DownloadTask {
|
|
|
229
242
|
return {
|
|
230
243
|
copyFroms: [],
|
|
231
244
|
copyTos: [],
|
|
245
|
+
copyCrcs: [],
|
|
232
246
|
deletes: [],
|
|
233
247
|
hbcTransformMeta: '',
|
|
234
248
|
};
|
|
@@ -534,6 +548,20 @@ export class DownloadTask {
|
|
|
534
548
|
|
|
535
549
|
private async doPatchFromApp(params: DownloadTaskParams): Promise<void> {
|
|
536
550
|
await this.downloadFile(params);
|
|
551
|
+
try {
|
|
552
|
+
await this.doPatchFromAppAfterDownload(params);
|
|
553
|
+
} catch (error: any) {
|
|
554
|
+
// 下载已完成,之后的失败都是 patch 应用失败(解压/hpatch/资源拷贝,
|
|
555
|
+
// 含 copiesCrc 校验)。打上稳定码,JS 层遥测按 patch_fail 归类,
|
|
556
|
+
// 而不是混进网络失败。
|
|
557
|
+
error.code = 'PATCH_FAILED';
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async doPatchFromAppAfterDownload(
|
|
563
|
+
params: DownloadTaskParams,
|
|
564
|
+
): Promise<void> {
|
|
537
565
|
await this.recreateDirectory(params.unzipDirectory);
|
|
538
566
|
|
|
539
567
|
await zlib.decompressFile(params.targetFile, params.unzipDirectory);
|
|
@@ -566,12 +594,22 @@ export class DownloadTask {
|
|
|
566
594
|
`${params.unzipDirectory}/bundle.harmony.js`,
|
|
567
595
|
manifestArrays.hbcTransformMeta,
|
|
568
596
|
);
|
|
597
|
+
// 组按 from 聚合;同一 from 的内容必然一致,所以每组一个 CRC 即可
|
|
598
|
+
// (与 manifest 逐位对齐的 copyCrcs 里取第一个非空值)。
|
|
599
|
+
const crcByFrom = new Map<string, number>();
|
|
600
|
+
manifestArrays.copyFroms.forEach((from, index) => {
|
|
601
|
+
const crc = manifestArrays.copyCrcs[index];
|
|
602
|
+
if (crc !== null && !crcByFrom.has(from)) {
|
|
603
|
+
crcByFrom.set(from, crc);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
569
606
|
await this.copyFromResource(
|
|
570
607
|
NativePatchCore.buildCopyGroups(
|
|
571
608
|
manifestArrays.copyFroms,
|
|
572
609
|
manifestArrays.copyTos,
|
|
573
610
|
),
|
|
574
611
|
params.unzipDirectory,
|
|
612
|
+
crcByFrom,
|
|
575
613
|
);
|
|
576
614
|
try {
|
|
577
615
|
if (fileIo.accessSync(params.targetFile)) {
|
|
@@ -584,6 +622,18 @@ export class DownloadTask {
|
|
|
584
622
|
|
|
585
623
|
private async doPatchFromPpk(params: DownloadTaskParams): Promise<void> {
|
|
586
624
|
await this.downloadFile(params);
|
|
625
|
+
try {
|
|
626
|
+
await this.doPatchFromPpkAfterDownload(params);
|
|
627
|
+
} catch (error: any) {
|
|
628
|
+
// 同 doPatchFromApp:下载完成后的失败按 patch 失败归类。
|
|
629
|
+
error.code = 'PATCH_FAILED';
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private async doPatchFromPpkAfterDownload(
|
|
635
|
+
params: DownloadTaskParams,
|
|
636
|
+
): Promise<void> {
|
|
587
637
|
await this.recreateDirectory(params.unzipDirectory);
|
|
588
638
|
|
|
589
639
|
await zlib.decompressFile(params.targetFile, params.unzipDirectory);
|
|
@@ -626,6 +676,7 @@ export class DownloadTask {
|
|
|
626
676
|
private async copyFromResource(
|
|
627
677
|
copyGroups: CopyGroupResult[],
|
|
628
678
|
targetRoot: string,
|
|
679
|
+
crcByFrom?: Map<string, number>,
|
|
629
680
|
): Promise<void> {
|
|
630
681
|
let currentFrom = '';
|
|
631
682
|
try {
|
|
@@ -637,6 +688,7 @@ export class DownloadTask {
|
|
|
637
688
|
if (targets.length === 0) {
|
|
638
689
|
continue;
|
|
639
690
|
}
|
|
691
|
+
const expectedCrc = crcByFrom?.get(currentFrom);
|
|
640
692
|
|
|
641
693
|
if (currentFrom.startsWith('resources/base/media/')) {
|
|
642
694
|
// Strip only the final extension: 'icon.round.png' -> 'icon.round',
|
|
@@ -645,6 +697,7 @@ export class DownloadTask {
|
|
|
645
697
|
.replace('resources/base/media/', '')
|
|
646
698
|
.replace(/\.[^.]+$/, '');
|
|
647
699
|
const mediaBuffer = await resourceManager.getMediaByName(mediaName);
|
|
700
|
+
this.verifyCopySourceCrc(mediaBuffer, expectedCrc, currentFrom);
|
|
648
701
|
const parentDirs = [
|
|
649
702
|
...new Set(
|
|
650
703
|
targets.map(t => t.substring(0, t.lastIndexOf('/'))).filter(Boolean),
|
|
@@ -658,6 +711,22 @@ export class DownloadTask {
|
|
|
658
711
|
);
|
|
659
712
|
continue;
|
|
660
713
|
}
|
|
714
|
+
|
|
715
|
+
if (expectedCrc !== undefined) {
|
|
716
|
+
// 声明了 CRC 的条目改走字节路径:读出内容先校验再落盘。
|
|
717
|
+
// 重打包的二进制里同名 rawfile 内容可能已漂移,fd 直拷会把
|
|
718
|
+
// 错误字节静默装进版本目录。
|
|
719
|
+
const rawContent =
|
|
720
|
+
await resourceManager.getRawFileContent(currentFrom);
|
|
721
|
+
this.verifyCopySourceCrc(rawContent, expectedCrc, currentFrom);
|
|
722
|
+
const [firstTarget, ...restTargets] = targets;
|
|
723
|
+
await this.writeFileContent(firstTarget, rawContent);
|
|
724
|
+
await Promise.all(
|
|
725
|
+
restTargets.map(target => this.copySandboxFile(firstTarget, target)),
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
661
730
|
const fromContent = await resourceManager.getRawFd(currentFrom);
|
|
662
731
|
try {
|
|
663
732
|
const [firstTarget, ...restTargets] = targets;
|
|
@@ -697,6 +766,22 @@ export class DownloadTask {
|
|
|
697
766
|
}
|
|
698
767
|
}
|
|
699
768
|
|
|
769
|
+
// pdiff 拷贝源内容校验(__diff.json copiesCrc):不符即抛错,整次 patch
|
|
770
|
+
// 失败后 JS 策略链自动回退 full,绝不把漂移的资源装进版本目录。
|
|
771
|
+
private verifyCopySourceCrc(
|
|
772
|
+
content: Uint8Array,
|
|
773
|
+
expectedCrc: number | undefined,
|
|
774
|
+
from: string,
|
|
775
|
+
): void {
|
|
776
|
+
if (expectedCrc === undefined) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const actualCrc = NativePatchCore.crc32(content);
|
|
780
|
+
if (actualCrc !== expectedCrc) {
|
|
781
|
+
throw new Error(`resource content mismatch (crc32): ${from}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
700
785
|
private async doCleanUp(params: DownloadTaskParams): Promise<void> {
|
|
701
786
|
try {
|
|
702
787
|
await NativePatchCore.cleanupOldEntries(
|
|
@@ -82,6 +82,8 @@ interface NativePatchCoreBindings {
|
|
|
82
82
|
): Promise<void>;
|
|
83
83
|
/** sha256(小写 hex)。同步:输入是已在内存的 rawfile bundle,哈希毫秒级 */
|
|
84
84
|
sha256Hex(data: Uint8Array): string;
|
|
85
|
+
/** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */
|
|
86
|
+
crc32(data: Uint8Array | ArrayBuffer): number;
|
|
85
87
|
/** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */
|
|
86
88
|
getSupportedDiffVersion(): number;
|
|
87
89
|
}
|
|
@@ -20,6 +20,27 @@ type FlushablePreferences = preferences.Preferences & {
|
|
|
20
20
|
flushSync?: () => void;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// 服务端下发的 hash/originHash/fileName 会拼进 rootDir 作为子路径;凡是可能
|
|
24
|
+
// 逃出 rootDir 的值(路径分隔符、".."、".")必须在触碰文件系统前拒绝。
|
|
25
|
+
export function isSafePathComponent(name: string): boolean {
|
|
26
|
+
return (
|
|
27
|
+
typeof name === 'string' &&
|
|
28
|
+
name.length > 0 &&
|
|
29
|
+
name !== '.' &&
|
|
30
|
+
name !== '..' &&
|
|
31
|
+
!name.includes('/') &&
|
|
32
|
+
!name.includes('\\') &&
|
|
33
|
+
!name.includes('\0')
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertSafePathComponent(name: string): string {
|
|
38
|
+
if (!isSafePathComponent(name)) {
|
|
39
|
+
throw Error(`Invalid path component: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
export class UpdateContext {
|
|
24
45
|
private context: common.UIAbilityContext;
|
|
25
46
|
private rootDir: string;
|
|
@@ -296,7 +317,7 @@ export class UpdateContext {
|
|
|
296
317
|
const params = new DownloadTaskParams();
|
|
297
318
|
params.type = type;
|
|
298
319
|
params.url = url;
|
|
299
|
-
params.hash = hash;
|
|
320
|
+
params.hash = assertSafePathComponent(hash);
|
|
300
321
|
return params;
|
|
301
322
|
}
|
|
302
323
|
|
|
@@ -474,7 +495,7 @@ export class UpdateContext {
|
|
|
474
495
|
url,
|
|
475
496
|
hash,
|
|
476
497
|
);
|
|
477
|
-
params.targetFile = this.rootDir + '/' + fileName;
|
|
498
|
+
params.targetFile = this.rootDir + '/' + assertSafePathComponent(fileName);
|
|
478
499
|
await this.executeTask(params);
|
|
479
500
|
}
|
|
480
501
|
|
|
@@ -488,7 +509,7 @@ export class UpdateContext {
|
|
|
488
509
|
url,
|
|
489
510
|
hash,
|
|
490
511
|
);
|
|
491
|
-
params.originHash = originHash;
|
|
512
|
+
params.originHash = assertSafePathComponent(originHash);
|
|
492
513
|
params.targetFile = `${this.rootDir}/${originHash}_${hash}.ppk.patch`;
|
|
493
514
|
params.unzipDirectory = `${this.rootDir}/${hash}`;
|
|
494
515
|
params.originDirectory = `${this.rootDir}/${params.originHash}`;
|
|
@@ -516,7 +537,7 @@ export class UpdateContext {
|
|
|
516
537
|
|
|
517
538
|
public switchVersion(hash: string): void {
|
|
518
539
|
try {
|
|
519
|
-
const bundlePath = this.getBundlePath(hash);
|
|
540
|
+
const bundlePath = this.getBundlePath(assertSafePathComponent(hash));
|
|
520
541
|
if (!fileIo.accessSync(bundlePath)) {
|
|
521
542
|
throw Error(`Bundle version ${hash} not found.`);
|
|
522
543
|
}
|
package/harmony/pushy.har
CHANGED
|
Binary file
|
package/ios/RCTPushy/RCTPushy.mm
CHANGED
|
@@ -139,6 +139,25 @@ static BOOL PushyStringIsBlank(NSString *value) {
|
|
|
139
139
|
return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0;
|
|
140
140
|
}
|
|
141
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
|
+
|
|
142
161
|
static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
|
|
143
162
|
// Prefer the stable cross-platform code (error_codes.h); fall back to the
|
|
144
163
|
// numeric NSError code for system errors that were not classified.
|
|
@@ -162,15 +181,28 @@ static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json
|
|
|
162
181
|
pushy::patch::PatchManifest manifest;
|
|
163
182
|
|
|
164
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
|
+
}
|
|
165
192
|
for (NSString *to in copies) {
|
|
166
193
|
NSString *from = copies[to];
|
|
167
194
|
if (from.length <= 0) {
|
|
168
195
|
from = to;
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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);
|
|
174
206
|
}
|
|
175
207
|
|
|
176
208
|
NSDictionary *deletes = json[@"deletes"];
|
|
@@ -248,6 +280,7 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
|
|
|
248
280
|
callback:(void (^)(NSError *error))callback;
|
|
249
281
|
- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error;
|
|
250
282
|
- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path;
|
|
283
|
+
+ (void)excludeFromBackup:(NSString *)path;
|
|
251
284
|
- (void)unzipFileAtPath:(NSString *)path
|
|
252
285
|
toDestination:(NSString *)destination
|
|
253
286
|
completionHandler:(void (^)(NSError *error))completionHandler;
|
|
@@ -694,12 +727,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
694
727
|
NSString *updateUrl = PushyOptionString(options, @"updateUrl");
|
|
695
728
|
NSString *hash = PushyOptionString(options, @"hash");
|
|
696
729
|
|
|
697
|
-
if (PushyStringIsBlank(updateUrl) ||
|
|
730
|
+
if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) {
|
|
698
731
|
callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
|
|
699
732
|
return;
|
|
700
733
|
}
|
|
701
734
|
NSString *originHash = PushyOptionString(options, @"originHash");
|
|
702
|
-
if (type == PushyTypePatchFromPpk &&
|
|
735
|
+
if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) {
|
|
703
736
|
callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
|
|
704
737
|
return;
|
|
705
738
|
}
|
|
@@ -893,7 +926,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
893
926
|
|
|
894
927
|
- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error
|
|
895
928
|
{
|
|
896
|
-
if (
|
|
929
|
+
if (!PushyIsSafePathComponent(hash)) {
|
|
897
930
|
if (error != NULL) {
|
|
898
931
|
*error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
|
|
899
932
|
}
|
|
@@ -922,6 +955,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
922
955
|
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
923
956
|
BOOL isDirectory = NO;
|
|
924
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
|
+
}
|
|
925
962
|
return isDirectory;
|
|
926
963
|
}
|
|
927
964
|
|
|
@@ -933,10 +970,27 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
|
|
|
933
970
|
if (!success && error != nil) {
|
|
934
971
|
RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
|
|
935
972
|
}
|
|
973
|
+
if (success) {
|
|
974
|
+
[RCTPushy excludeFromBackup:path];
|
|
975
|
+
}
|
|
936
976
|
|
|
937
977
|
return success;
|
|
938
978
|
}
|
|
939
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
|
+
|
|
940
994
|
- (void)unzipFileAtPath:(NSString *)path
|
|
941
995
|
toDestination:(NSString *)destination
|
|
942
996
|
completionHandler:(void (^)(NSError *error))completionHandler
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -133,6 +133,13 @@ export const sharedState: {
|
|
|
133
133
|
applyingUpdate: false,
|
|
134
134
|
};
|
|
135
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
|
+
|
|
136
143
|
const assertHash = (hash: string) => {
|
|
137
144
|
if (!sharedState.downloadedHash) {
|
|
138
145
|
log(`no downloaded hash yet, ignore switch to ${hash}`);
|
|
@@ -169,6 +176,23 @@ export class Pushy {
|
|
|
169
176
|
|
|
170
177
|
constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
|
|
171
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
|
+
}
|
|
172
196
|
this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
|
|
173
197
|
|
|
174
198
|
i18n.setLocale(
|
|
@@ -193,8 +217,28 @@ export class Pushy {
|
|
|
193
217
|
},
|
|
194
218
|
});
|
|
195
219
|
}
|
|
220
|
+
activeClient = this;
|
|
196
221
|
}
|
|
197
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
|
+
|
|
198
242
|
setOptions = (options: Partial<ClientOptions>) => {
|
|
199
243
|
for (const [key, value] of Object.entries(options)) {
|
|
200
244
|
if (value !== undefined) {
|
|
@@ -207,6 +251,34 @@ export class Pushy {
|
|
|
207
251
|
}
|
|
208
252
|
}
|
|
209
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
|
+
};
|
|
210
282
|
};
|
|
211
283
|
|
|
212
284
|
/**
|
|
@@ -452,11 +524,12 @@ export class Pushy {
|
|
|
452
524
|
};
|
|
453
525
|
requestCheckResult = async (
|
|
454
526
|
endpoint: string,
|
|
455
|
-
fetchPayload: Parameters<typeof fetch>[1]
|
|
527
|
+
fetchPayload: Parameters<typeof fetch>[1],
|
|
528
|
+
signal?: AbortSignal
|
|
456
529
|
) => {
|
|
457
530
|
const resp = await fetchWithTimeout(
|
|
458
531
|
this.getCheckUrl(endpoint),
|
|
459
|
-
fetchPayload,
|
|
532
|
+
signal ? { ...fetchPayload, signal } : fetchPayload,
|
|
460
533
|
DEFAULT_FETCH_TIMEOUT_MS
|
|
461
534
|
);
|
|
462
535
|
|
|
@@ -478,9 +551,13 @@ export class Pushy {
|
|
|
478
551
|
const { endpoint, value } = await executeEndpointFallback<CheckResult>({
|
|
479
552
|
configuredEndpoints: this.getConfiguredCheckEndpoints(),
|
|
480
553
|
getRemoteEndpoints: this.getRemoteEndpoints,
|
|
481
|
-
tryEndpoint: async (currentEndpoint) => {
|
|
554
|
+
tryEndpoint: async (currentEndpoint, signal) => {
|
|
482
555
|
try {
|
|
483
|
-
return await this.requestCheckResult(
|
|
556
|
+
return await this.requestCheckResult(
|
|
557
|
+
currentEndpoint,
|
|
558
|
+
fetchPayload,
|
|
559
|
+
signal
|
|
560
|
+
);
|
|
484
561
|
} catch (e) {
|
|
485
562
|
log('check endpoint failed', currentEndpoint, e);
|
|
486
563
|
throw e;
|
|
@@ -904,6 +981,23 @@ export class Pushy {
|
|
|
904
981
|
delete sharedState.progressHandlers[hash];
|
|
905
982
|
}
|
|
906
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
|
+
}
|
|
907
1001
|
if (!succeeded) {
|
|
908
1002
|
const message = errorMessages.join(';');
|
|
909
1003
|
if (lastError) {
|