react-native-update 10.50.0 → 10.51.1

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 (50) hide show
  1. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  2. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  3. package/README-CN.md +14 -1
  4. package/README.md +14 -1
  5. package/android/build.gradle +10 -0
  6. package/android/jni/Android.mk +5 -1
  7. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  8. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  9. package/android/lib/x86/librnupdate.so +0 -0
  10. package/android/lib/x86_64/librnupdate.so +0 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +76 -21
  12. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +160 -42
  16. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +44 -0
  17. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  18. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  19. package/cpp/update_flow_core/flow_json.cpp +522 -0
  20. package/cpp/update_flow_core/flow_json.h +103 -0
  21. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  22. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  23. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  24. package/cpp/update_flow_core/update_flow_core.h +77 -0
  25. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  26. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  27. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -1
  28. package/harmony/pushy/src/main/ets/DownloadTask.ts +48 -9
  29. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  30. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  31. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  32. package/harmony/pushy/src/main/ets/NativePatchCore.ts +14 -0
  33. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  34. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  35. package/harmony/pushy/src/main/ets/UpdateContext.ts +148 -69
  36. package/harmony/pushy.har +0 -0
  37. package/ios/RCTPushy/RCTPushy.mm +779 -61
  38. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  39. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  40. package/package.json +3 -1
  41. package/react-native-update.podspec +2 -0
  42. package/src/NativePushy.ts +14 -0
  43. package/src/client.ts +341 -118
  44. package/src/endpoint.ts +7 -29
  45. package/src/provider.tsx +38 -2
  46. package/src/type.ts +26 -0
  47. package/src/updateFlowCore.ts +320 -0
  48. package/src/utils.ts +1 -12
  49. package/src/isInRollout.ts +0 -87
  50. package/src/resolveCheckResult.ts +0 -30
@@ -3,6 +3,7 @@
3
3
  @interface RCTPushyDownloader : NSObject
4
4
 
5
5
  + (void)download:(NSString *)downloadPath savePath:(NSString *)savePath
6
+ timeoutInterval:(NSTimeInterval)timeoutInterval
6
7
  progressHandler:(void (^)(long long, long long))progressHandler
7
8
  completionHandler:(void (^)(NSString *path, NSError *error))completionHandler;
8
9
 
@@ -17,6 +17,7 @@ static NSString *const RCTPushyDownloaderErrorDomain = @"cn.reactnative.pushy";
17
17
  @implementation RCTPushyDownloader
18
18
 
19
19
  + (void)download:(NSString *)downloadPath savePath:(NSString *)savePath
20
+ timeoutInterval:(NSTimeInterval)timeoutInterval
20
21
  progressHandler:(void (^)(long long receivedBytes, long long totalBytes))progressHandler
21
22
  completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
22
23
  {
@@ -28,10 +29,10 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
28
29
  downloader.completionHandler = completionHandler;
29
30
  downloader.savePath = savePath;
30
31
 
31
- [downloader startDownload:downloadPath];
32
+ [downloader startDownload:downloadPath timeoutInterval:timeoutInterval];
32
33
  }
33
34
 
34
- - (void)startDownload:(NSString *)path
35
+ - (void)startDownload:(NSString *)path timeoutInterval:(NSTimeInterval)timeoutInterval
35
36
  {
36
37
  NSURL *url = [NSURL URLWithString:path];
37
38
  if (url == nil) {
@@ -50,7 +51,7 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
50
51
  // Android's 10min callTimeout — 300s made a 30MB full package on a slow
51
52
  // (<100KB/s) network fail on iOS while succeeding on Android.
52
53
  sessionConfig.timeoutIntervalForRequest = 30;
53
- sessionConfig.timeoutIntervalForResource = 600;
54
+ sessionConfig.timeoutIntervalForResource = MAX(1, timeoutInterval);
54
55
  self.session = [NSURLSession sessionWithConfiguration:sessionConfig
55
56
  delegate:self
56
57
  delegateQueue:nil];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.50.0",
3
+ "version": "10.51.1",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
@@ -14,6 +14,8 @@
14
14
  "submodule": "git submodule update --init --recursive",
15
15
  "test": "bun test src/__tests__",
16
16
  "test:patch-core": "./scripts/test-patch-core.sh",
17
+ "test:flow-core": "./scripts/test-update-flow-core.sh",
18
+ "generate:flow-vectors": "bun scripts/generate-flow-vectors.ts",
17
19
  "build:harmony-har": "node scripts/build-harmony-har.js",
18
20
  "build:harmony-e2e": "bash Example/e2etest/scripts/build-harmony-e2e.sh",
19
21
  "build:so": "bun submodule && bash scripts/build-android-so.sh",
@@ -110,6 +110,8 @@ Pod::Spec.new do |s|
110
110
 
111
111
  s.subspec 'RCTPushy' do |ss|
112
112
  ss.source_files = ['ios/RCTPushy/*.{h,m,mm}',
113
+ 'cpp/update_flow_core/flow_json.{h,cpp}',
114
+ 'cpp/update_flow_core/update_flow_core.{h,cpp}',
113
115
  'cpp/patch_core/archive_patch_core.{h,cpp}',
114
116
  'cpp/patch_core/digest.{h,cpp}',
115
117
  'cpp/patch_core/hbc_transform.{h,cpp}',
@@ -16,6 +16,20 @@ export interface Spec extends TurboModule {
16
16
  setLocalHashInfo(hash: string, info: string): Promise<void>;
17
17
  getLocalHashInfo(hash: string): Promise<string>;
18
18
  setUuid(uuid: string): Promise<void>;
19
+ /**
20
+ * Persist the config subset the native cold-start update check consumes
21
+ * (appKey, endpoints, afterDownload policy; NATIVE_CHECKUPDATE_DESIGN
22
+ * §10.1). Stored as a raw JSON string, parsed natively on read. JS is the
23
+ * single config source — a native side without persisted config silently
24
+ * skips its check, which doubles as the feature's rollout gate.
25
+ */
26
+ syncNativeConfig(config: string): Promise<void>;
27
+ /**
28
+ * Raw response cached by the native cold-start check, including the request
29
+ * and config fingerprints that scope reuse (§10.3). Resolves to
30
+ * an empty string when absent; never rejects.
31
+ */
32
+ getNativeCheckCache(): Promise<string>;
19
33
  reloadUpdate(options: { hash: string }): Promise<void>;
20
34
  restartApp(): Promise<void>;
21
35
  setNeedUpdate(options: { hash: string }): Promise<void>;
package/src/client.ts CHANGED
@@ -41,13 +41,18 @@ import type {
41
41
  UpdateCheckState,
42
42
  UpdateServerConfig,
43
43
  } from './type';
44
+ import {
45
+ buildCheckRequestBody,
46
+ type DownloadPlan,
47
+ type DownloadStrategyType,
48
+ decideDownload,
49
+ } from './updateFlowCore';
44
50
  import {
45
51
  assertWeb,
46
52
  computeProgress,
47
53
  DEFAULT_FETCH_TIMEOUT_MS,
48
54
  fetchWithTimeout,
49
55
  info,
50
- joinUrls,
51
56
  log,
52
57
  noop,
53
58
  promiseAny,
@@ -91,6 +96,11 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({
91
96
  queryUrls: server.queryUrls ? [...server.queryUrls] : undefined,
92
97
  });
93
98
 
99
+ // Persist an object (rather than an empty string) so every native bridge keeps
100
+ // accepting the payload while the orchestrators read `disabled` and skip the
101
+ // cold-start check entirely.
102
+ const NATIVE_CONFIG_DISABLED_JSON = '{"disabled":true}';
103
+
94
104
  const excludeConfiguredEndpoints = (
95
105
  endpoints: string[],
96
106
  configuredEndpoints: string[]
@@ -161,6 +171,10 @@ export class Pushy {
161
171
  // Endpoint that most recently served a successful checkUpdate; telemetry
162
172
  // reuses it instead of re-running the fallback race.
163
173
  private lastWorkingEndpoint?: string;
174
+ private syncedNativeConfigJson?: string;
175
+ private pendingNativeConfigJson?: string;
176
+ private nativeConfigSyncInFlight = false;
177
+ private reportedInvalidUpdates = new Set<string>();
164
178
 
165
179
  version = cInfo.rnu;
166
180
  loggerPromise = (() => {
@@ -259,6 +273,164 @@ export class Pushy {
259
273
  log('onOptionsChange listener error:', e?.message || e);
260
274
  }
261
275
  }
276
+ this.syncNativeConfig();
277
+ };
278
+
279
+ /** Build the subset of options used by the native cold-start check. */
280
+ private getNativeConfig = (): Record<string, unknown> | undefined => {
281
+ if (
282
+ Platform.OS === 'web' ||
283
+ typeof PushyModule.syncNativeConfig !== 'function'
284
+ ) {
285
+ // Older natives lack the method; on web PushyModule is a noop Proxy
286
+ // and the feature-detect would false-positive.
287
+ return undefined;
288
+ }
289
+ const {
290
+ appKey,
291
+ server,
292
+ updateStrategy,
293
+ checkStrategy,
294
+ disableNativeCheck,
295
+ } = this.options;
296
+ if (disableNativeCheck) {
297
+ // Explicit opt-out: keep the identity fields so the persisted config
298
+ // still describes this app, and let the orchestrators bail on `disabled`
299
+ // before any IO.
300
+ return { disabled: true, appKey, rnu: cInfo.rnu, rn: cInfo.rn };
301
+ }
302
+ if (!appKey || !server?.main?.length) {
303
+ // Unusable rather than merely absent: the JS check would fail on these
304
+ // options too (NO_ENDPOINTS / APPKEY_REQUIRED). The caller turns this
305
+ // into an explicit disabled state.
306
+ return undefined;
307
+ }
308
+ // An app that turned automatic checks off (checkStrategy: null) must not
309
+ // be handed a version switch it never asked for. The cold-start check
310
+ // still runs and still downloads — that is what keeps a bricked device
311
+ // rescuable — but activation waits for the JS side, or for the server's
312
+ // explicit per-version forceBoot directive (shouldActivateAfterDownload).
313
+ const autoCheckEnabled = checkStrategy != null;
314
+ return {
315
+ appKey,
316
+ packageVersion: this.getEffectivePackageVersion(),
317
+ endpoints: server.main,
318
+ queryUrls: server.queryUrls ?? [],
319
+ // The native check may activate a downloaded version (next launch)
320
+ // only under the silent strategies; alert-style strategies keep
321
+ // activation with the JS side (§6/§10.1).
322
+ afterDownload:
323
+ autoCheckEnabled &&
324
+ (updateStrategy === 'silentAndNow' ||
325
+ updateStrategy === 'silentAndLater')
326
+ ? 'setNeedUpdate'
327
+ : 'none',
328
+ rnu: cInfo.rnu,
329
+ rn: cInfo.rn,
330
+ };
331
+ };
332
+
333
+ private getNativeConfigJson = (): string | undefined => {
334
+ const config = this.getNativeConfig();
335
+ return config ? JSON.stringify(config) : undefined;
336
+ };
337
+
338
+ private flushNativeConfig = () => {
339
+ if (this.nativeConfigSyncInFlight) {
340
+ return;
341
+ }
342
+ const configJson = this.pendingNativeConfigJson;
343
+ this.pendingNativeConfigJson = undefined;
344
+ if (!configJson || configJson === this.syncedNativeConfigJson) {
345
+ return;
346
+ }
347
+ this.nativeConfigSyncInFlight = true;
348
+ let syncResult: Promise<void>;
349
+ try {
350
+ syncResult = Promise.resolve(PushyModule.syncNativeConfig(configJson));
351
+ } catch (e: any) {
352
+ this.nativeConfigSyncInFlight = false;
353
+ log('syncNativeConfig failed:', e?.message || e);
354
+ return;
355
+ }
356
+ syncResult
357
+ .then(() => {
358
+ this.syncedNativeConfigJson = configJson;
359
+ })
360
+ .catch((e: any) => {
361
+ log('syncNativeConfig failed:', e?.message || e);
362
+ })
363
+ .finally(() => {
364
+ this.nativeConfigSyncInFlight = false;
365
+ this.flushNativeConfig();
366
+ });
367
+ };
368
+
369
+ private syncNativeConfig = () => {
370
+ if (
371
+ Platform.OS === 'web' ||
372
+ typeof PushyModule.syncNativeConfig !== 'function'
373
+ ) {
374
+ return;
375
+ }
376
+ const configJson =
377
+ this.getNativeConfigJson() ?? NATIVE_CONFIG_DISABLED_JSON;
378
+ // Always record the latest desired value, even when it matches the last
379
+ // completed write. Example: A synced -> B in flight -> options revert to
380
+ // A. Comparing only with synced(A) would drop the revert and leave native
381
+ // storage at B after that in-flight write completes.
382
+ // Coalesce rapid setOptions calls, but serialize bridge writes so an older
383
+ // completion can never overwrite the newest desired configuration.
384
+ this.pendingNativeConfigJson = configJson;
385
+ this.flushNativeConfig();
386
+ };
387
+
388
+ /** Package version used by every server-side update decision. */
389
+ getEffectivePackageVersion = () =>
390
+ this.options.overridePackageVersion || packageVersion;
391
+
392
+ private jsonValuesEqual = (left: unknown, right: unknown): boolean => {
393
+ const compare = (a: unknown, b: unknown): boolean => {
394
+ if (a === b) {
395
+ return true;
396
+ }
397
+ if (Array.isArray(a) || Array.isArray(b)) {
398
+ return (
399
+ Array.isArray(a) &&
400
+ Array.isArray(b) &&
401
+ a.length === b.length &&
402
+ a.every((value, index) => compare(value, b[index]))
403
+ );
404
+ }
405
+ if (
406
+ a === null ||
407
+ b === null ||
408
+ typeof a !== 'object' ||
409
+ typeof b !== 'object'
410
+ ) {
411
+ return false;
412
+ }
413
+ const leftObject = a as Record<string, unknown>;
414
+ const rightObject = b as Record<string, unknown>;
415
+ // Match JSON.stringify semantics for request extras: object properties
416
+ // whose value is undefined are omitted from the wire fingerprint.
417
+ const leftKeys = Object.keys(leftObject)
418
+ .filter((key) => leftObject[key] !== undefined)
419
+ .sort();
420
+ const rightKeys = Object.keys(rightObject)
421
+ .filter((key) => rightObject[key] !== undefined)
422
+ .sort();
423
+ return (
424
+ leftKeys.length === rightKeys.length &&
425
+ leftKeys.every(
426
+ (key, index) =>
427
+ key === rightKeys[index] &&
428
+ compare(leftObject[key], rightObject[key])
429
+ )
430
+ );
431
+ };
432
+
433
+ return compare(left, right);
262
434
  };
263
435
 
264
436
  private providerMounted = false;
@@ -291,6 +463,25 @@ export class Pushy {
291
463
  return i18n.t(key as any, values);
292
464
  };
293
465
 
466
+ reportInvalidUpdateOnce = (
467
+ reason: 'missingHash' | 'noArtifact',
468
+ hash = ''
469
+ ) => {
470
+ const key = `${this.options.appKey}:${reason}:${hash}`;
471
+ if (this.reportedInvalidUpdates.has(key)) {
472
+ return;
473
+ }
474
+ this.reportedInvalidUpdates.add(key);
475
+ this.report({
476
+ type: 'errorUpdate',
477
+ message:
478
+ reason === 'missingHash'
479
+ ? 'update response is missing a version hash'
480
+ : 'update response contains no downloadable artifact',
481
+ ...(hash ? { data: { newVersion: hash } } : {}),
482
+ });
483
+ };
484
+
294
485
  report = async ({
295
486
  type,
296
487
  message = '',
@@ -385,8 +576,7 @@ export class Pushy {
385
576
  body: JSON.stringify({
386
577
  type: payloadType,
387
578
  hash,
388
- packageVersion:
389
- this.options.overridePackageVersion || packageVersion,
579
+ packageVersion: this.getEffectivePackageVersion(),
390
580
  cInfo,
391
581
  detail: truncateDetail(detail),
392
582
  }),
@@ -577,6 +767,57 @@ export class Pushy {
577
767
  this.lastWorkingEndpoint = endpoint;
578
768
  return value;
579
769
  };
770
+ /**
771
+ * Reuse the native cold-start check's cached response when fresh
772
+ * (NATIVE_CHECKUPDATE_DESIGN §10.3) instead of re-checking. Returns
773
+ * undefined whenever the cache is absent, stale, or unreadable — any
774
+ * failure falls through to a normal network check.
775
+ */
776
+ private readNativeCheckCache = async (
777
+ requestBody: Record<string, any>
778
+ ): Promise<CheckResult | undefined> => {
779
+ try {
780
+ if (__DEV__ || typeof PushyModule.getNativeCheckCache !== 'function') {
781
+ return undefined;
782
+ }
783
+ const raw = await Promise.resolve(PushyModule.getNativeCheckCache());
784
+ if (!raw || typeof raw !== 'string') {
785
+ return undefined;
786
+ }
787
+ const entry = JSON.parse(raw);
788
+ const config = this.getNativeConfig();
789
+ const cachedRequest =
790
+ typeof entry?.request === 'string'
791
+ ? JSON.parse(entry.request)
792
+ : undefined;
793
+ const cachedConfig =
794
+ typeof entry?.config === 'string'
795
+ ? JSON.parse(entry.config)
796
+ : undefined;
797
+ if (
798
+ typeof entry?.ts !== 'number' ||
799
+ typeof entry?.body !== 'string' ||
800
+ !this.jsonValuesEqual(cachedRequest, requestBody) ||
801
+ !config ||
802
+ !this.jsonValuesEqual(cachedConfig, config)
803
+ ) {
804
+ return undefined;
805
+ }
806
+ const ageSeconds = Date.now() / 1000 - entry.ts;
807
+ if (ageSeconds < 0 || ageSeconds > 120) {
808
+ return undefined;
809
+ }
810
+ const result = JSON.parse(entry.body);
811
+ if (!result || typeof result !== 'object') {
812
+ return undefined;
813
+ }
814
+ log('reusing native check response cache');
815
+ return result as CheckResult;
816
+ } catch {
817
+ return undefined;
818
+ }
819
+ };
820
+
580
821
  assertDebug = (matter: string) => {
581
822
  if (__DEV__ && !this.options.debug) {
582
823
  info(this.t('dev_debug_disabled', { matter }));
@@ -697,19 +938,16 @@ export class Pushy {
697
938
  }
698
939
  }
699
940
  this.lastChecking = now;
700
- const fetchBody: Record<string, any> = {
701
- packageVersion: this.options.overridePackageVersion || packageVersion,
702
- hash: currentVersion,
941
+ const fetchBody = buildCheckRequestBody({
942
+ packageVersion: this.getEffectivePackageVersion(),
943
+ currentVersion,
703
944
  buildTime,
704
945
  cInfo,
705
- // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
706
- ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}),
707
- ...(bundleHash ? { bundleHash } : {}),
708
- ...extra,
709
- };
710
- if (__DEV__) {
711
- delete fetchBody.buildTime;
712
- }
946
+ supportedDiffVersion,
947
+ bundleHash,
948
+ isDev: __DEV__,
949
+ extra,
950
+ });
713
951
  const stringifyBody = JSON.stringify(fetchBody);
714
952
  // harmony fetch body is not string
715
953
  let body: any = fetchBody;
@@ -730,7 +968,18 @@ export class Pushy {
730
968
  type: 'checking',
731
969
  message: `${this.options.appKey}: ${stringifyBody}`,
732
970
  });
733
- const respJsonPromise = this.fetchCheckResult(fetchPayload);
971
+ // The native cold-start check may have a fresh response on disk
972
+ // (§10.3); reuse it instead of re-checking. The read happens INSIDE
973
+ // the promise so no await lands between the dedup window above and the
974
+ // lastRespJson assignment below (the JS2-1 double-send lesson).
975
+ const respJsonPromise = (async (): Promise<CheckResult> => {
976
+ // While bundleHash prefetch is still pending, the JS request omits
977
+ // that key whereas the native request always includes its synchronously
978
+ // computed value. That narrow first-launch window intentionally misses
979
+ // the cache rather than delaying checkUpdate for hashing.
980
+ const cached = await this.readNativeCheckCache(fetchBody);
981
+ return cached ?? (await this.fetchCheckResult(fetchPayload));
982
+ })();
734
983
  this.lastRespJson = respJsonPromise;
735
984
  const result: CheckResult = await respJsonPromise;
736
985
 
@@ -766,7 +1015,6 @@ export class Pushy {
766
1015
  updateInfo: CheckResult,
767
1016
  onDownloadProgress?: (data: ProgressData) => void
768
1017
  ) => {
769
- const { hash } = updateInfo;
770
1018
  if (
771
1019
  this.options.beforeDownloadUpdate &&
772
1020
  (await this.options.beforeDownloadUpdate(updateInfo)) === false
@@ -774,17 +1022,26 @@ export class Pushy {
774
1022
  log('beforeDownloadUpdate returned false, skipping download');
775
1023
  return;
776
1024
  }
777
- if (!updateInfo.update || !hash) {
778
- return;
779
- }
780
- if (hash === currentVersion) {
781
- log(`current hash ${currentVersion}, ignored`);
782
- return;
783
- }
784
- if (rolledBackVersion === hash) {
785
- log(`rolledback hash ${rolledBackVersion}, ignored`);
1025
+ const decision = decideDownload(
1026
+ updateInfo,
1027
+ { currentVersion, rolledBackVersion },
1028
+ __DEV__
1029
+ );
1030
+ if (decision.action === 'none') {
1031
+ if (decision.reason === 'alreadyCurrent') {
1032
+ log(`current hash ${currentVersion}, ignored`);
1033
+ } else if (decision.reason === 'rolledBack') {
1034
+ log(`rolledback hash ${rolledBackVersion}, ignored`);
1035
+ } else if (decision.reason === 'noArtifact') {
1036
+ // A server response that advertises an update but provides no usable
1037
+ // artifact is a bad release signal, not an ordinary no-update result.
1038
+ // Keep the user flow silent and report at most once per bad release in
1039
+ // this process: repeated checks must not inflate download_fail health.
1040
+ this.reportInvalidUpdateOnce('noArtifact', updateInfo.hash || '');
1041
+ }
786
1042
  return;
787
1043
  }
1044
+ const { hash } = decision;
788
1045
  if (sharedState.downloadedHash === hash) {
789
1046
  log(`duplicated downloaded hash ${sharedState.downloadedHash}, ignored`);
790
1047
  return sharedState.downloadedHash;
@@ -801,7 +1058,7 @@ export class Pushy {
801
1058
  }
802
1059
  return existingTask;
803
1060
  }
804
- const task = this.performDownload(updateInfo, onDownloadProgress);
1061
+ const task = this.performDownload(updateInfo, decision, onDownloadProgress);
805
1062
  sharedState.downloadingTasks[hash] = task;
806
1063
  try {
807
1064
  return await task;
@@ -811,21 +1068,11 @@ export class Pushy {
811
1068
  };
812
1069
  private performDownload = async (
813
1070
  updateInfo: CheckResult,
1071
+ plan: DownloadPlan,
814
1072
  onDownloadProgress?: (data: ProgressData) => void
815
1073
  ) => {
816
- const {
817
- hash,
818
- diff,
819
- pdiff,
820
- full,
821
- paths = [],
822
- name,
823
- description = '',
824
- metaInfo,
825
- } = updateInfo;
826
- if (!hash) {
827
- return;
828
- }
1074
+ const { name, description = '', metaInfo } = updateInfo;
1075
+ const { hash, attempts, devNoop } = plan;
829
1076
  const patchStartTime = Date.now();
830
1077
  // One native listener per hash dispatching to a callback set, so
831
1078
  // concurrent callers deduped onto this task can each observe progress
@@ -870,61 +1117,35 @@ export class Pushy {
870
1117
  let lastError: any;
871
1118
  const errorMessages: string[] = [];
872
1119
 
873
- // Ordered download strategies, tried in sequence until one succeeds. Each
874
- // resolves its candidate URL lazily (testUrls) and runs the matching native
875
- // download. diff/pdiff are incremental and skipped entirely in dev; full is
876
- // attempted whenever a URL exists, and in dev with no URL it is treated as a
877
- // no-op success so the flow can proceed.
878
- type DownloadStrategy = {
879
- name: string;
880
- candidate: string | undefined;
881
- errorKey:
882
- | 'error_diff_failed'
883
- | 'error_pdiff_failed'
884
- | 'error_full_patch_failed';
885
- skipInDev: boolean;
886
- devNoopWhenNoUrl: boolean;
887
- run: (url: string) => Promise<void>;
1120
+ // The ordered attempts come from decideDownload (the pure decision layer);
1121
+ // this side only executes them: probe candidate URLs, run the matching
1122
+ // native download, fall through to the next attempt on failure.
1123
+ const runners: Record<
1124
+ DownloadStrategyType,
1125
+ (url: string) => Promise<void>
1126
+ > = {
1127
+ diff: (url) =>
1128
+ PushyModule.downloadPatchFromPpk({
1129
+ updateUrl: url,
1130
+ hash,
1131
+ originHash: currentVersion,
1132
+ }),
1133
+ pdiff: (url) =>
1134
+ PushyModule.downloadPatchFromPackage({
1135
+ updateUrl: url,
1136
+ hash,
1137
+ }),
1138
+ full: (url) =>
1139
+ PushyModule.downloadFullUpdate({
1140
+ updateUrl: url,
1141
+ hash,
1142
+ }),
888
1143
  };
889
- const strategies: DownloadStrategy[] = [
890
- {
891
- name: 'diff',
892
- candidate: diff,
893
- errorKey: 'error_diff_failed',
894
- skipInDev: true,
895
- devNoopWhenNoUrl: false,
896
- run: (url) =>
897
- PushyModule.downloadPatchFromPpk({
898
- updateUrl: url,
899
- hash,
900
- originHash: currentVersion,
901
- }),
902
- },
903
- {
904
- name: 'pdiff',
905
- candidate: pdiff,
906
- errorKey: 'error_pdiff_failed',
907
- skipInDev: true,
908
- devNoopWhenNoUrl: false,
909
- run: (url) =>
910
- PushyModule.downloadPatchFromPackage({
911
- updateUrl: url,
912
- hash,
913
- }),
914
- },
915
- {
916
- name: 'full',
917
- candidate: full,
918
- errorKey: 'error_full_patch_failed',
919
- skipInDev: false,
920
- devNoopWhenNoUrl: true,
921
- run: (url) =>
922
- PushyModule.downloadFullUpdate({
923
- updateUrl: url,
924
- hash,
925
- }),
926
- },
927
- ];
1144
+ const errorKeys = {
1145
+ diff: 'error_diff_failed',
1146
+ pdiff: 'error_pdiff_failed',
1147
+ full: 'error_full_patch_failed',
1148
+ } as const;
928
1149
 
929
1150
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
930
1151
  if (attempt > 0) {
@@ -942,34 +1163,36 @@ export class Pushy {
942
1163
  attempt,
943
1164
  },
944
1165
  });
945
- for (const strategy of strategies) {
1166
+ if (devNoop) {
1167
+ log(this.t('dev_incremental_update_disabled'));
1168
+ succeeded = 'full';
1169
+ }
1170
+ for (const { type, urls } of attempts) {
946
1171
  if (succeeded) {
947
1172
  break;
948
1173
  }
949
- const url = await testUrls(joinUrls(paths, strategy.candidate));
950
- if (url && !(strategy.skipInDev && __DEV__)) {
951
- log(`downloading ${strategy.name}`);
952
- try {
953
- await strategy.run(url);
954
- succeeded = strategy.name;
955
- } catch (e: any) {
956
- const errorMessage = this.t(strategy.errorKey, {
957
- message: e.message,
958
- });
959
- errorMessages.push(errorMessage);
960
- // Keep the i18n message for display, but preserve the native
961
- // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
962
- // telemetry classifies on it) and the original error as cause.
963
- lastError = new UpdateError(
964
- errorMessage,
965
- asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
966
- { cause: e }
967
- );
968
- log(errorMessage);
969
- }
970
- } else if (!url && strategy.devNoopWhenNoUrl && __DEV__) {
971
- log(this.t('dev_incremental_update_disabled'));
972
- succeeded = strategy.name;
1174
+ const url = await testUrls(urls);
1175
+ if (!url) {
1176
+ continue;
1177
+ }
1178
+ log(`downloading ${type}`);
1179
+ try {
1180
+ await runners[type](url);
1181
+ succeeded = type;
1182
+ } catch (e: any) {
1183
+ const errorMessage = this.t(errorKeys[type], {
1184
+ message: e.message,
1185
+ });
1186
+ errorMessages.push(errorMessage);
1187
+ // Keep the i18n message for display, but preserve the native
1188
+ // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
1189
+ // telemetry classifies on it) and the original error as cause.
1190
+ lastError = new UpdateError(
1191
+ errorMessage,
1192
+ asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
1193
+ { cause: e }
1194
+ );
1195
+ log(errorMessage);
973
1196
  }
974
1197
  }
975
1198
  if (succeeded) {