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.
Files changed (32) hide show
  1. package/BUNDLEHASH_MIGRATION.md +22 -2
  2. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  3. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  4. package/android/lib/x86/librnupdate.so +0 -0
  5. package/android/lib/x86_64/librnupdate.so +0 -0
  6. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  7. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
  8. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  9. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +36 -0
  10. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +12 -3
  11. package/cpp/patch_core/digest.cpp +53 -0
  12. package/cpp/patch_core/digest.h +18 -0
  13. package/cpp/patch_core/patch_core.cpp +17 -1
  14. package/cpp/patch_core/patch_core.h +8 -0
  15. package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
  16. package/harmony/pushy/src/main/cpp/pushy.cpp +47 -0
  17. package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
  18. package/harmony/pushy/src/main/ets/NativePatchCore.ts +2 -0
  19. package/harmony/pushy/src/main/ets/UpdateContext.ts +25 -4
  20. package/harmony/pushy.har +0 -0
  21. package/ios/RCTPushy/RCTPushy.mm +61 -7
  22. package/package.json +1 -1
  23. package/src/client.ts +98 -4
  24. package/src/endpoint.ts +98 -51
  25. package/src/error.ts +4 -0
  26. package/src/isInRollout.ts +2 -1
  27. package/src/locales/en.ts +4 -0
  28. package/src/locales/zh.ts +4 -0
  29. package/src/provider.tsx +36 -7
  30. package/src/telemetry.ts +8 -0
  31. package/src/type.ts +5 -0
  32. 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
@@ -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
- manifest.copies.push_back(pushy::patch::CopyOperation{
171
- PushyToStdString(from),
172
- PushyToStdString(to),
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) || PushyStringIsBlank(hash)) {
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 && PushyStringIsBlank(originHash)) {
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 (PushyStringIsBlank(hash)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.49.0",
3
+ "version": "10.50.0",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
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(currentEndpoint, fetchPayload);
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) {