react-native-update 10.49.0 → 10.51.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 (61) hide show
  1. package/BUNDLEHASH_MIGRATION.md +22 -2
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  3. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  4. package/README-CN.md +14 -1
  5. package/README.md +14 -1
  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/BundledResourceCopier.java +42 -9
  12. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -22
  13. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  16. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +196 -42
  18. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -3
  19. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  20. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  21. package/cpp/patch_core/digest.cpp +53 -0
  22. package/cpp/patch_core/digest.h +18 -0
  23. package/cpp/patch_core/patch_core.cpp +17 -1
  24. package/cpp/patch_core/patch_core.h +8 -0
  25. package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
  26. package/cpp/update_flow_core/flow_json.cpp +522 -0
  27. package/cpp/update_flow_core/flow_json.h +103 -0
  28. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  29. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  30. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  31. package/cpp/update_flow_core/update_flow_core.h +77 -0
  32. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  33. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  34. package/harmony/pushy/src/main/cpp/pushy.cpp +150 -1
  35. package/harmony/pushy/src/main/ets/DownloadTask.ts +133 -9
  36. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  37. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  38. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  39. package/harmony/pushy/src/main/ets/NativePatchCore.ts +16 -0
  40. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  41. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  42. package/harmony/pushy/src/main/ets/UpdateContext.ts +152 -52
  43. package/harmony/pushy.har +0 -0
  44. package/ios/RCTPushy/RCTPushy.mm +840 -68
  45. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  46. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  47. package/package.json +3 -1
  48. package/react-native-update.podspec +2 -0
  49. package/src/NativePushy.ts +14 -0
  50. package/src/client.ts +439 -122
  51. package/src/endpoint.ts +105 -80
  52. package/src/error.ts +4 -0
  53. package/src/locales/en.ts +4 -0
  54. package/src/locales/zh.ts +4 -0
  55. package/src/provider.tsx +74 -9
  56. package/src/telemetry.ts +8 -0
  57. package/src/type.ts +31 -0
  58. package/src/updateFlowCore.ts +320 -0
  59. package/src/utils.ts +14 -12
  60. package/src/isInRollout.ts +0 -86
  61. package/src/resolveCheckResult.ts +0 -30
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[]
@@ -133,6 +143,13 @@ export const sharedState: {
133
143
  applyingUpdate: false,
134
144
  };
135
145
 
146
+ // The SDK is a process-level singleton: module-level sharedState, the global
147
+ // i18n locale and the native update state are all per-process, so a second
148
+ // client would silently share (and fight over) them. Constructing one is a
149
+ // hard integration error, except for the idempotent re-creation of the same
150
+ // client (same type + appKey), which dev fast-refresh triggers legitimately.
151
+ let activeClient: Pushy | undefined;
152
+
136
153
  const assertHash = (hash: string) => {
137
154
  if (!sharedState.downloadedHash) {
138
155
  log(`no downloaded hash yet, ignore switch to ${hash}`);
@@ -154,6 +171,10 @@ export class Pushy {
154
171
  // Endpoint that most recently served a successful checkUpdate; telemetry
155
172
  // reuses it instead of re-running the fallback race.
156
173
  private lastWorkingEndpoint?: string;
174
+ private syncedNativeConfigJson?: string;
175
+ private pendingNativeConfigJson?: string;
176
+ private nativeConfigSyncInFlight = false;
177
+ private reportedInvalidUpdates = new Set<string>();
157
178
 
158
179
  version = cInfo.rnu;
159
180
  loggerPromise = (() => {
@@ -169,6 +190,23 @@ export class Pushy {
169
190
 
170
191
  constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
171
192
  this.clientType = clientType || 'Pushy';
193
+ if (activeClient) {
194
+ if (
195
+ activeClient.clientType === this.clientType &&
196
+ activeClient.options.appKey === options.appKey
197
+ ) {
198
+ // Same client re-created (e.g. fast refresh re-running the module
199
+ // that builds it): apply the latest options and hand back the
200
+ // existing instance instead of forking process-level state.
201
+ activeClient.setOptions(options);
202
+ // biome-ignore lint/correctness/noConstructorReturn: intentional singleton — identical re-creation must yield the existing instance
203
+ return activeClient;
204
+ }
205
+ throw new UpdateError(
206
+ i18n.t('error_client_singleton'),
207
+ 'SINGLETON_VIOLATION'
208
+ );
209
+ }
172
210
  this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
173
211
 
174
212
  i18n.setLocale(
@@ -193,8 +231,28 @@ export class Pushy {
193
231
  },
194
232
  });
195
233
  }
234
+ activeClient = this;
196
235
  }
197
236
 
237
+ /**
238
+ * Bumped on every setOptions call. `options` is mutated in place (its
239
+ * identity never changes), so reactive consumers (the UpdateProvider)
240
+ * subscribe via onOptionsChange and re-read using this version as the
241
+ * change signal.
242
+ */
243
+ optionsVersion = 0;
244
+ private optionsListeners = new Set<() => void>();
245
+ /**
246
+ * Subscribe to option changes (any setOptions call). Returns an
247
+ * unsubscribe function.
248
+ */
249
+ onOptionsChange = (listener: () => void) => {
250
+ this.optionsListeners.add(listener);
251
+ return () => {
252
+ this.optionsListeners.delete(listener);
253
+ };
254
+ };
255
+
198
256
  setOptions = (options: Partial<ClientOptions>) => {
199
257
  for (const [key, value] of Object.entries(options)) {
200
258
  if (value !== undefined) {
@@ -207,6 +265,192 @@ export class Pushy {
207
265
  }
208
266
  }
209
267
  }
268
+ this.optionsVersion++;
269
+ for (const listener of this.optionsListeners) {
270
+ try {
271
+ listener();
272
+ } catch (e: any) {
273
+ log('onOptionsChange listener error:', e?.message || e);
274
+ }
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);
434
+ };
435
+
436
+ private providerMounted = false;
437
+ /**
438
+ * Called by UpdateProvider on mount. A second concurrently mounted
439
+ * provider is the same integration error as a second client — fail hard
440
+ * instead of double-subscribing app-state listeners and update checks.
441
+ * Returns the release function used on unmount.
442
+ */
443
+ claimProviderMount = () => {
444
+ if (this.providerMounted) {
445
+ throw new UpdateError(
446
+ this.t('error_provider_singleton'),
447
+ 'SINGLETON_VIOLATION'
448
+ );
449
+ }
450
+ this.providerMounted = true;
451
+ return () => {
452
+ this.providerMounted = false;
453
+ };
210
454
  };
211
455
 
212
456
  /**
@@ -219,6 +463,25 @@ export class Pushy {
219
463
  return i18n.t(key as any, values);
220
464
  };
221
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
+
222
485
  report = async ({
223
486
  type,
224
487
  message = '',
@@ -313,8 +576,7 @@ export class Pushy {
313
576
  body: JSON.stringify({
314
577
  type: payloadType,
315
578
  hash,
316
- packageVersion:
317
- this.options.overridePackageVersion || packageVersion,
579
+ packageVersion: this.getEffectivePackageVersion(),
318
580
  cInfo,
319
581
  detail: truncateDetail(detail),
320
582
  }),
@@ -452,11 +714,12 @@ export class Pushy {
452
714
  };
453
715
  requestCheckResult = async (
454
716
  endpoint: string,
455
- fetchPayload: Parameters<typeof fetch>[1]
717
+ fetchPayload: Parameters<typeof fetch>[1],
718
+ signal?: AbortSignal
456
719
  ) => {
457
720
  const resp = await fetchWithTimeout(
458
721
  this.getCheckUrl(endpoint),
459
- fetchPayload,
722
+ signal ? { ...fetchPayload, signal } : fetchPayload,
460
723
  DEFAULT_FETCH_TIMEOUT_MS
461
724
  );
462
725
 
@@ -478,9 +741,13 @@ export class Pushy {
478
741
  const { endpoint, value } = await executeEndpointFallback<CheckResult>({
479
742
  configuredEndpoints: this.getConfiguredCheckEndpoints(),
480
743
  getRemoteEndpoints: this.getRemoteEndpoints,
481
- tryEndpoint: async (currentEndpoint) => {
744
+ tryEndpoint: async (currentEndpoint, signal) => {
482
745
  try {
483
- return await this.requestCheckResult(currentEndpoint, fetchPayload);
746
+ return await this.requestCheckResult(
747
+ currentEndpoint,
748
+ fetchPayload,
749
+ signal
750
+ );
484
751
  } catch (e) {
485
752
  log('check endpoint failed', currentEndpoint, e);
486
753
  throw e;
@@ -500,6 +767,57 @@ export class Pushy {
500
767
  this.lastWorkingEndpoint = endpoint;
501
768
  return value;
502
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
+
503
821
  assertDebug = (matter: string) => {
504
822
  if (__DEV__ && !this.options.debug) {
505
823
  info(this.t('dev_debug_disabled', { matter }));
@@ -620,19 +938,16 @@ export class Pushy {
620
938
  }
621
939
  }
622
940
  this.lastChecking = now;
623
- const fetchBody: Record<string, any> = {
624
- packageVersion: this.options.overridePackageVersion || packageVersion,
625
- hash: currentVersion,
941
+ const fetchBody = buildCheckRequestBody({
942
+ packageVersion: this.getEffectivePackageVersion(),
943
+ currentVersion,
626
944
  buildTime,
627
945
  cInfo,
628
- // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发
629
- ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}),
630
- ...(bundleHash ? { bundleHash } : {}),
631
- ...extra,
632
- };
633
- if (__DEV__) {
634
- delete fetchBody.buildTime;
635
- }
946
+ supportedDiffVersion,
947
+ bundleHash,
948
+ isDev: __DEV__,
949
+ extra,
950
+ });
636
951
  const stringifyBody = JSON.stringify(fetchBody);
637
952
  // harmony fetch body is not string
638
953
  let body: any = fetchBody;
@@ -653,7 +968,18 @@ export class Pushy {
653
968
  type: 'checking',
654
969
  message: `${this.options.appKey}: ${stringifyBody}`,
655
970
  });
656
- 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
+ })();
657
983
  this.lastRespJson = respJsonPromise;
658
984
  const result: CheckResult = await respJsonPromise;
659
985
 
@@ -689,7 +1015,6 @@ export class Pushy {
689
1015
  updateInfo: CheckResult,
690
1016
  onDownloadProgress?: (data: ProgressData) => void
691
1017
  ) => {
692
- const { hash } = updateInfo;
693
1018
  if (
694
1019
  this.options.beforeDownloadUpdate &&
695
1020
  (await this.options.beforeDownloadUpdate(updateInfo)) === false
@@ -697,17 +1022,26 @@ export class Pushy {
697
1022
  log('beforeDownloadUpdate returned false, skipping download');
698
1023
  return;
699
1024
  }
700
- if (!updateInfo.update || !hash) {
701
- return;
702
- }
703
- if (hash === currentVersion) {
704
- log(`current hash ${currentVersion}, ignored`);
705
- return;
706
- }
707
- if (rolledBackVersion === hash) {
708
- 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
+ }
709
1042
  return;
710
1043
  }
1044
+ const { hash } = decision;
711
1045
  if (sharedState.downloadedHash === hash) {
712
1046
  log(`duplicated downloaded hash ${sharedState.downloadedHash}, ignored`);
713
1047
  return sharedState.downloadedHash;
@@ -724,7 +1058,7 @@ export class Pushy {
724
1058
  }
725
1059
  return existingTask;
726
1060
  }
727
- const task = this.performDownload(updateInfo, onDownloadProgress);
1061
+ const task = this.performDownload(updateInfo, decision, onDownloadProgress);
728
1062
  sharedState.downloadingTasks[hash] = task;
729
1063
  try {
730
1064
  return await task;
@@ -734,21 +1068,11 @@ export class Pushy {
734
1068
  };
735
1069
  private performDownload = async (
736
1070
  updateInfo: CheckResult,
1071
+ plan: DownloadPlan,
737
1072
  onDownloadProgress?: (data: ProgressData) => void
738
1073
  ) => {
739
- const {
740
- hash,
741
- diff,
742
- pdiff,
743
- full,
744
- paths = [],
745
- name,
746
- description = '',
747
- metaInfo,
748
- } = updateInfo;
749
- if (!hash) {
750
- return;
751
- }
1074
+ const { name, description = '', metaInfo } = updateInfo;
1075
+ const { hash, attempts, devNoop } = plan;
752
1076
  const patchStartTime = Date.now();
753
1077
  // One native listener per hash dispatching to a callback set, so
754
1078
  // concurrent callers deduped onto this task can each observe progress
@@ -793,61 +1117,35 @@ export class Pushy {
793
1117
  let lastError: any;
794
1118
  const errorMessages: string[] = [];
795
1119
 
796
- // Ordered download strategies, tried in sequence until one succeeds. Each
797
- // resolves its candidate URL lazily (testUrls) and runs the matching native
798
- // download. diff/pdiff are incremental and skipped entirely in dev; full is
799
- // attempted whenever a URL exists, and in dev with no URL it is treated as a
800
- // no-op success so the flow can proceed.
801
- type DownloadStrategy = {
802
- name: string;
803
- candidate: string | undefined;
804
- errorKey:
805
- | 'error_diff_failed'
806
- | 'error_pdiff_failed'
807
- | 'error_full_patch_failed';
808
- skipInDev: boolean;
809
- devNoopWhenNoUrl: boolean;
810
- 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
+ }),
811
1143
  };
812
- const strategies: DownloadStrategy[] = [
813
- {
814
- name: 'diff',
815
- candidate: diff,
816
- errorKey: 'error_diff_failed',
817
- skipInDev: true,
818
- devNoopWhenNoUrl: false,
819
- run: (url) =>
820
- PushyModule.downloadPatchFromPpk({
821
- updateUrl: url,
822
- hash,
823
- originHash: currentVersion,
824
- }),
825
- },
826
- {
827
- name: 'pdiff',
828
- candidate: pdiff,
829
- errorKey: 'error_pdiff_failed',
830
- skipInDev: true,
831
- devNoopWhenNoUrl: false,
832
- run: (url) =>
833
- PushyModule.downloadPatchFromPackage({
834
- updateUrl: url,
835
- hash,
836
- }),
837
- },
838
- {
839
- name: 'full',
840
- candidate: full,
841
- errorKey: 'error_full_patch_failed',
842
- skipInDev: false,
843
- devNoopWhenNoUrl: true,
844
- run: (url) =>
845
- PushyModule.downloadFullUpdate({
846
- updateUrl: url,
847
- hash,
848
- }),
849
- },
850
- ];
1144
+ const errorKeys = {
1145
+ diff: 'error_diff_failed',
1146
+ pdiff: 'error_pdiff_failed',
1147
+ full: 'error_full_patch_failed',
1148
+ } as const;
851
1149
 
852
1150
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
853
1151
  if (attempt > 0) {
@@ -865,34 +1163,36 @@ export class Pushy {
865
1163
  attempt,
866
1164
  },
867
1165
  });
868
- 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) {
869
1171
  if (succeeded) {
870
1172
  break;
871
1173
  }
872
- const url = await testUrls(joinUrls(paths, strategy.candidate));
873
- if (url && !(strategy.skipInDev && __DEV__)) {
874
- log(`downloading ${strategy.name}`);
875
- try {
876
- await strategy.run(url);
877
- succeeded = strategy.name;
878
- } catch (e: any) {
879
- const errorMessage = this.t(strategy.errorKey, {
880
- message: e.message,
881
- });
882
- errorMessages.push(errorMessage);
883
- // Keep the i18n message for display, but preserve the native
884
- // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
885
- // telemetry classifies on it) and the original error as cause.
886
- lastError = new UpdateError(
887
- errorMessage,
888
- asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
889
- { cause: e }
890
- );
891
- log(errorMessage);
892
- }
893
- } else if (!url && strategy.devNoopWhenNoUrl && __DEV__) {
894
- log(this.t('dev_incremental_update_disabled'));
895
- 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);
896
1196
  }
897
1197
  }
898
1198
  if (succeeded) {
@@ -904,6 +1204,23 @@ export class Pushy {
904
1204
  delete sharedState.progressHandlers[hash];
905
1205
  }
906
1206
  delete sharedState.progressCallbacks[hash];
1207
+ if (succeeded && errorMessages.length > 0) {
1208
+ // An earlier strategy failed and a later one rescued the download
1209
+ // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface
1210
+ // the degradation: it is invisible to the end user but tells the
1211
+ // platform that incremental delivery is failing for this binary.
1212
+ this.report({
1213
+ type: 'downloadFallback',
1214
+ // UpdateError.code already carries the classified native rejection
1215
+ // code (PATCH_FAILED vs DOWNLOAD_FAILED).
1216
+ code: lastError?.code,
1217
+ data: {
1218
+ newVersion: hash,
1219
+ succeeded,
1220
+ },
1221
+ message: errorMessages.join(';'),
1222
+ });
1223
+ }
907
1224
  if (!succeeded) {
908
1225
  const message = errorMessages.join(';');
909
1226
  if (lastError) {