react-native-update 10.55.0 → 10.56.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 (76) hide show
  1. package/README-CN.md +35 -0
  2. package/README.md +37 -0
  3. package/android/build.gradle +53 -27
  4. package/android/jni/Application.mk +6 -1
  5. package/android/jni/hpatch.c +59 -4
  6. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  7. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  8. package/android/lib/x86/librnupdate.so +0 -0
  9. package/android/lib/x86_64/librnupdate.so +0 -0
  10. package/android/proguard.pro +70 -14
  11. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +15 -8
  12. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +1 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +1 -2
  14. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +25 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -34
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +29 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +15 -0
  18. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +59 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +27 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +57 -16
  21. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +38 -46
  22. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +279 -183
  23. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +14 -0
  24. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +222 -215
  25. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +5 -0
  26. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +23 -25
  27. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +29 -29
  28. package/cpp/patch_core/archive_limits.h +6 -0
  29. package/cpp/patch_core/jni_util.h +6 -2
  30. package/cpp/patch_core/patch_core.cpp +237 -84
  31. package/cpp/patch_core/patch_core.h +11 -0
  32. package/cpp/patch_core/patch_core_android.cpp +49 -33
  33. package/cpp/patch_core/update_core_android.cpp +134 -103
  34. package/cpp/update_flow_core/flow_json.cpp +150 -17
  35. package/cpp/update_flow_core/flow_json.h +13 -1
  36. package/cpp/update_flow_core/update_flow_core.cpp +78 -8
  37. package/cpp/update_flow_core/update_flow_core.h +9 -0
  38. package/cpp/update_flow_core/update_flow_jni.cpp +44 -25
  39. package/harmony/hvigor-plugin.ts +168 -21
  40. package/harmony/pushy/src/README.md +74 -0
  41. package/harmony/pushy/src/main/cpp/CMakeLists.txt +13 -3
  42. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +11 -5
  43. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -18
  44. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +61 -5
  45. package/harmony/pushy/src/main/ets/DownloadTask.ts +255 -166
  46. package/harmony/pushy/src/main/ets/ErrorCodes.ts +75 -0
  47. package/harmony/pushy/src/main/ets/EventHub.ts +22 -20
  48. package/harmony/pushy/src/main/ets/InstallRecord.ts +79 -21
  49. package/harmony/pushy/src/main/ets/Logger.ts +42 -16
  50. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +98 -17
  51. package/harmony/pushy/src/main/ets/NativePatchCore.ts +10 -3
  52. package/harmony/pushy/src/main/ets/PathUtils.ts +9 -3
  53. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +139 -53
  54. package/harmony/pushy/src/main/ets/UpdateContext.ts +269 -159
  55. package/harmony/pushy/src/main/module.json5 +5 -1
  56. package/harmony/pushy.har +0 -0
  57. package/ios/PrivacyInfo.xcprivacy +79 -0
  58. package/ios/RCTPushy/RCTPushy.mm +406 -170
  59. package/ios/RCTPushy/RCTPushyDownloader.h +14 -0
  60. package/ios/RCTPushy/RCTPushyDownloader.mm +136 -11
  61. package/package.json +14 -24
  62. package/react-native-update.podspec +36 -3
  63. package/src/client.ts +244 -86
  64. package/src/context.ts +40 -27
  65. package/src/core.ts +41 -16
  66. package/src/endpoint.ts +2 -1
  67. package/src/error.ts +49 -11
  68. package/src/index.ts +14 -0
  69. package/src/locales/en.ts +1 -23
  70. package/src/locales/zh.ts +1 -22
  71. package/src/provider.tsx +159 -111
  72. package/src/type.ts +0 -2
  73. package/src/updateFlowCore.ts +10 -2
  74. package/src/utils.ts +61 -4
  75. package/android/src/main/java/cn/reactnative/modules/update/ReactNativeHostHandler.java +0 -13
  76. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +0 -45
package/src/client.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from './core';
21
21
  import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
22
22
  import {
23
- asUpdateErrorCode,
23
+ readErrorCode,
24
24
  toUpdateError,
25
25
  UpdateError,
26
26
  type UpdateErrorCode,
@@ -65,6 +65,7 @@ import {
65
65
  log,
66
66
  noop,
67
67
  promiseAny,
68
+ setDebugLogging,
68
69
  testUrls,
69
70
  warn,
70
71
  } from './utils';
@@ -105,10 +106,13 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({
105
106
  queryUrls: server.queryUrls ? [...server.queryUrls] : undefined,
106
107
  });
107
108
 
108
- // Persist an object (rather than an empty string) so every native bridge keeps
109
- // accepting the payload while the orchestrators read `disabled` and skip the
110
- // cold-start check entirely.
111
- const NATIVE_CONFIG_DISABLED_JSON = '{"disabled":true}';
109
+ // How long after a resolved reloadUpdate the process may still be alive before
110
+ // the switch is considered not to have restarted the app (see switchVersion).
111
+ const RELOAD_WATCHDOG_MS = 5000;
112
+
113
+ // Non-2xx check responses are usually HTML error pages; keep only the head
114
+ // for the error message (it can end up in an alert) and the logger.
115
+ const MAX_HTTP_ERROR_TEXT = 200;
112
116
 
113
117
  const excludeConfiguredEndpoints = (
114
118
  endpoints: string[],
@@ -153,6 +157,59 @@ export const sharedState: {
153
157
  applyingUpdate: false,
154
158
  };
155
159
 
160
+ // A reset/new switch invalidates ownership of older async completions and
161
+ // watchdogs before the process-wide applyingUpdate flag can be reused.
162
+ let applyingUpdateGeneration = 0;
163
+ let reloadWatchdogTimer: ReturnType<typeof setTimeout> | undefined;
164
+
165
+ const clearReloadWatchdog = () => {
166
+ if (reloadWatchdogTimer !== undefined) {
167
+ clearTimeout(reloadWatchdogTimer);
168
+ reloadWatchdogTimer = undefined;
169
+ }
170
+ };
171
+
172
+ const beginApplyingUpdate = (): number => {
173
+ clearReloadWatchdog();
174
+ sharedState.applyingUpdate = true;
175
+ return ++applyingUpdateGeneration;
176
+ };
177
+
178
+ const ownsApplyingUpdate = (generation: number): boolean =>
179
+ generation === applyingUpdateGeneration && sharedState.applyingUpdate;
180
+
181
+ const finishApplyingUpdate = (generation: number): boolean => {
182
+ if (!ownsApplyingUpdate(generation)) {
183
+ return false;
184
+ }
185
+ clearReloadWatchdog();
186
+ sharedState.applyingUpdate = false;
187
+ return true;
188
+ };
189
+
190
+ const invalidateApplyingUpdate = () => {
191
+ applyingUpdateGeneration++;
192
+ clearReloadWatchdog();
193
+ sharedState.applyingUpdate = false;
194
+ };
195
+
196
+ const armReloadWatchdog = (generation: number, onTimeout: () => void) => {
197
+ if (!ownsApplyingUpdate(generation)) {
198
+ return;
199
+ }
200
+ clearReloadWatchdog();
201
+ const timer = setTimeout(() => {
202
+ // A cancelled callback may already be queued; both identities must match.
203
+ if (reloadWatchdogTimer !== timer || !ownsApplyingUpdate(generation)) {
204
+ return;
205
+ }
206
+ reloadWatchdogTimer = undefined;
207
+ sharedState.applyingUpdate = false;
208
+ onTimeout();
209
+ }, RELOAD_WATCHDOG_MS);
210
+ reloadWatchdogTimer = timer;
211
+ };
212
+
156
213
  // The SDK is a process-level singleton: module-level sharedState, the global
157
214
  // i18n locale and the native update state are all per-process, so a second
158
215
  // client would silently share (and fight over) them. Constructing one is a
@@ -226,13 +283,11 @@ export class Pushy {
226
283
  options.locale ?? (this.clientType === 'Pushy' ? 'zh' : 'en')
227
284
  );
228
285
 
229
- if (Platform.OS === 'ios' || Platform.OS === 'android') {
230
- if (!options.appKey) {
231
- throw new UpdateError(
232
- i18n.t('error_appkey_required'),
233
- 'APPKEY_REQUIRED'
234
- );
235
- }
286
+ // Every native platform (Harmony included) needs the appKey; without it
287
+ // the check URL is `/checkUpdate/` and the failure would surface as an
288
+ // opaque 404 instead of this explicit code.
289
+ if (Platform.OS !== 'web' && !options.appKey) {
290
+ throw new UpdateError(i18n.t('error_appkey_required'), 'APPKEY_REQUIRED');
236
291
  }
237
292
 
238
293
  this.setOptions(options);
@@ -276,8 +331,15 @@ export class Pushy {
276
331
  if (key === 'logger') {
277
332
  this.loggerPromise.resolve();
278
333
  }
334
+ if (key === 'locale') {
335
+ // The constructor applies the initial locale; a runtime switch (or
336
+ // the fast-refresh re-creation path, which only goes through here)
337
+ // must apply just the same.
338
+ i18n.setLocale(value as 'zh' | 'en');
339
+ }
279
340
  }
280
341
  }
342
+ setDebugLogging(!!this.options.debug);
281
343
  this.optionsVersion++;
282
344
  for (const listener of this.optionsListeners) {
283
345
  try {
@@ -424,8 +486,16 @@ export class Pushy {
424
486
  ) {
425
487
  return;
426
488
  }
427
- const configJson =
428
- this.getNativeConfigJson() ?? NATIVE_CONFIG_DISABLED_JSON;
489
+ const configJson = this.getNativeConfigJson();
490
+ if (!configJson) {
491
+ // No usable config (empty appKey / no endpoints) is almost always a
492
+ // transient state while options are being assembled. Persisting a
493
+ // disabled config over the last good one would switch off the
494
+ // cold-start rescue for every later launch, so keep what native has.
495
+ // (Web and older natives never reach this point — no method.)
496
+ log('native check config unusable, keeping the last synced config');
497
+ return;
498
+ }
429
499
  // Always record the latest desired value, even when it matches the last
430
500
  // completed write. Example: A synced -> B in flight -> options revert to
431
501
  // A. Comparing only with synced(A) would drop the revert and leave native
@@ -661,10 +731,14 @@ export class Pushy {
661
731
  // report fires in the constructor before the user configures one), but
662
732
  // give up after a bound instead of retaining the closure forever when
663
733
  // no logger is ever provided.
734
+ let timer: ReturnType<typeof setTimeout> | undefined;
664
735
  await Promise.race([
665
736
  this.loggerPromise.promise,
666
- new Promise((resolve) => setTimeout(resolve, 10 * 1000)),
737
+ new Promise((resolve) => {
738
+ timer = setTimeout(resolve, 10 * 1000);
739
+ }),
667
740
  ]);
741
+ clearTimeout(timer);
668
742
  }
669
743
  const { logger = noop, appKey } = this.options;
670
744
  const overridePackageVersion = this.options.overridePackageVersion;
@@ -850,23 +924,39 @@ export class Pushy {
850
924
  return [];
851
925
  }
852
926
  try {
853
- const resp = await promiseAny(
854
- server.queryUrls.map((queryUrl) =>
855
- fetchWithTimeout(queryUrl, {}, DEFAULT_FETCH_TIMEOUT_MS)
856
- )
927
+ // Race the parsed lists, not the raw responses: fetch resolves for
928
+ // 404/403/5xx and anti-bot HTML too, and this path only runs once the
929
+ // main endpoints are already failing — a mirror that answers first
930
+ // with an error page must not beat the healthy one.
931
+ const remoteEndpoints = await promiseAny(
932
+ server.queryUrls.map(async (queryUrl) => {
933
+ const resp = await fetchWithTimeout(
934
+ queryUrl,
935
+ {},
936
+ DEFAULT_FETCH_TIMEOUT_MS
937
+ );
938
+ if (!resp.ok) {
939
+ throw Error(`${queryUrl}: ${resp.status}`);
940
+ }
941
+ const list: unknown = await resp.json();
942
+ if (!Array.isArray(list)) {
943
+ throw Error(`${queryUrl}: not an endpoint list`);
944
+ }
945
+ return list;
946
+ })
857
947
  );
858
- const remoteEndpoints = await resp.json();
859
948
  log('fetch endpoints:', remoteEndpoints);
860
- if (Array.isArray(remoteEndpoints)) {
861
- return excludeConfiguredEndpoints(
862
- dedupeEndpoints(
863
- remoteEndpoints.filter(
864
- (endpoint): endpoint is string => typeof endpoint === 'string'
865
- )
866
- ),
867
- this.getConfiguredCheckEndpoints()
868
- );
869
- }
949
+ return excludeConfiguredEndpoints(
950
+ dedupeEndpoints(
951
+ remoteEndpoints.filter(
952
+ // The list is fetched from a public mirror; it may add
953
+ // endpoints but never move the check outside TLS.
954
+ (endpoint): endpoint is string =>
955
+ typeof endpoint === 'string' && /^https:\/\//i.test(endpoint)
956
+ )
957
+ ),
958
+ this.getConfiguredCheckEndpoints()
959
+ );
870
960
  } catch (e) {
871
961
  log('failed to fetch endpoints from: ', server.queryUrls, e);
872
962
  }
@@ -884,7 +974,7 @@ export class Pushy {
884
974
  );
885
975
 
886
976
  if (!resp.ok) {
887
- const respText = await resp.text();
977
+ const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT);
888
978
  throw new UpdateError(
889
979
  this.t('error_http_status', {
890
980
  status: resp.status,
@@ -977,12 +1067,14 @@ export class Pushy {
977
1067
  if (ageSeconds < 0 || ageSeconds > 120) {
978
1068
  return undefined;
979
1069
  }
980
- const result = JSON.parse(entry.body);
981
- if (!result || typeof result !== 'object') {
1070
+ const result: unknown = JSON.parse(entry.body);
1071
+ // Same schema gate as the network path: a native old enough to have
1072
+ // cached a 200 `{"error": ...}` must not turn it into "no update".
1073
+ if (!isValidCheckResult(result)) {
982
1074
  return undefined;
983
1075
  }
984
1076
  log('reusing native check response cache');
985
- return result as CheckResult;
1077
+ return result;
986
1078
  } catch {
987
1079
  return undefined;
988
1080
  }
@@ -1017,42 +1109,69 @@ export class Pushy {
1017
1109
  this.report({ type: 'crashRescue' });
1018
1110
  }
1019
1111
  };
1020
- switchVersion = async (hash: string) => {
1112
+ /**
1113
+ * Reload into a downloaded version. Resolves true once the native reload
1114
+ * was requested, false when the call was ignored (not the downloaded hash,
1115
+ * a switch already in progress, beforeReload declined, dev without debug).
1116
+ */
1117
+ switchVersion = async (hash: string): Promise<boolean> => {
1021
1118
  if (!this.assertDebug('switchVersion()')) {
1022
- return;
1119
+ return false;
1023
1120
  }
1024
- if (assertHash(hash) && !sharedState.applyingUpdate) {
1025
- log(`switchVersion: ${hash}`);
1026
- sharedState.applyingUpdate = true;
1027
- try {
1028
- if (!(await this.runBeforeReload({ type: 'switchVersion', hash }))) {
1029
- sharedState.applyingUpdate = false;
1030
- return;
1031
- }
1032
- } catch (e) {
1033
- sharedState.applyingUpdate = false;
1034
- // A throw from the user's beforeReload hook is business-code failure,
1035
- // not an update-pipeline one: give it a distinct code so telemetry
1036
- // excludes it from the server-side patch-health stats.
1037
- const err = toUpdateError(e, 'USER_HOOK_ERROR');
1038
- this.emitError(err, 'errorSwitchVersion', {
1039
- data: { newVersion: hash },
1040
- });
1041
- throw err;
1042
- }
1043
- try {
1044
- return await PushyModule.reloadUpdate({ hash });
1045
- } catch (e) {
1046
- // reloadUpdate can reject (e.g. bundle missing); reset the flag so a
1047
- // later retry is not permanently blocked by a stuck applyingUpdate.
1048
- sharedState.applyingUpdate = false;
1049
- const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
1050
- this.emitError(err, 'errorSwitchVersion', {
1051
- data: { newVersion: hash },
1052
- });
1053
- throw err;
1121
+ if (!assertHash(hash)) {
1122
+ return false;
1123
+ }
1124
+ if (sharedState.applyingUpdate) {
1125
+ log(`switchVersion: ${hash} ignored, a switch is already in progress`);
1126
+ return false;
1127
+ }
1128
+ log(`switchVersion: ${hash}`);
1129
+ const applyingGeneration = beginApplyingUpdate();
1130
+ try {
1131
+ if (!(await this.runBeforeReload({ type: 'switchVersion', hash }))) {
1132
+ finishApplyingUpdate(applyingGeneration);
1133
+ return false;
1054
1134
  }
1135
+ } catch (e) {
1136
+ finishApplyingUpdate(applyingGeneration);
1137
+ // A throw from the user's beforeReload hook is business-code failure,
1138
+ // not an update-pipeline one: give it a distinct code so telemetry
1139
+ // excludes it from the server-side patch-health stats.
1140
+ const err = toUpdateError(e, 'USER_HOOK_ERROR');
1141
+ this.emitError(err, 'errorSwitchVersion', {
1142
+ data: { newVersion: hash },
1143
+ });
1144
+ throw err;
1145
+ }
1146
+ // resetToPackagedBundle may have completed while beforeReload awaited.
1147
+ if (!ownsApplyingUpdate(applyingGeneration)) {
1148
+ return false;
1055
1149
  }
1150
+ try {
1151
+ await PushyModule.reloadUpdate({ hash });
1152
+ } catch (e) {
1153
+ // Do not let a superseded rejection clear a newer switch.
1154
+ finishApplyingUpdate(applyingGeneration);
1155
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
1156
+ this.emitError(err, 'errorSwitchVersion', {
1157
+ data: { newVersion: hash },
1158
+ });
1159
+ throw err;
1160
+ }
1161
+ // A resolved reloadUpdate normally tears this JS context down right away.
1162
+ // iOS has been seen resolving without restarting (89c638e); a stuck
1163
+ // applyingUpdate would then silently swallow every later switchVersion,
1164
+ // so release it once the process has outlived the reload and tell the
1165
+ // logger (RESTART_FAILED: restart mechanics, not patch health).
1166
+ armReloadWatchdog(applyingGeneration, () => {
1167
+ this.report({
1168
+ type: 'errorSwitchVersion',
1169
+ code: 'RESTART_FAILED',
1170
+ message: 'reloadUpdate resolved but the app did not restart',
1171
+ data: { newVersion: hash },
1172
+ });
1173
+ });
1174
+ return true;
1056
1175
  };
1057
1176
 
1058
1177
  switchVersionLater = async (hash: string) => {
@@ -1153,6 +1272,7 @@ export class Pushy {
1153
1272
  },
1154
1273
  body,
1155
1274
  };
1275
+ let respJsonPromise: Promise<CheckResult> | undefined;
1156
1276
  try {
1157
1277
  this.report({
1158
1278
  type: 'checking',
@@ -1162,7 +1282,7 @@ export class Pushy {
1162
1282
  // (§10.3); reuse it instead of re-checking. The read happens INSIDE
1163
1283
  // the promise so no await lands between the dedup window above and the
1164
1284
  // lastRespJson assignment below (the JS2-1 double-send lesson).
1165
- const respJsonPromise = (async (): Promise<CheckResult> => {
1285
+ respJsonPromise = (async (): Promise<CheckResult> => {
1166
1286
  // While bundleHash prefetch is still pending, the JS request omits
1167
1287
  // that key whereas the native request always includes its synchronously
1168
1288
  // computed value. That narrow first-launch window intentionally misses
@@ -1190,9 +1310,12 @@ export class Pushy {
1190
1310
  return result;
1191
1311
  } catch (e: any) {
1192
1312
  // A failed check must not keep vouching for an older response: clear
1193
- // the dedup slot so the next call re-asks the server.
1194
- this.lastRespJson = undefined;
1195
- this.lastCheckFingerprint = undefined;
1313
+ // the dedup slot so the next call re-asks the server — unless a newer
1314
+ // check already owns the slot, which this failure says nothing about.
1315
+ if (this.lastRespJson === respJsonPromise) {
1316
+ this.lastRespJson = undefined;
1317
+ this.lastCheckFingerprint = undefined;
1318
+ }
1196
1319
  const err = toUpdateError(e, 'CHECK_FAILED');
1197
1320
  this.emitError(err, 'errorChecking', {
1198
1321
  message: err.message || this.t('error_cannot_connect_server'),
@@ -1291,6 +1414,14 @@ export class Pushy {
1291
1414
  ) => {
1292
1415
  const { name, description = '', metaInfo } = updateInfo;
1293
1416
  const { hash, attempts, devNoop } = plan;
1417
+ if (devNoop) {
1418
+ // Dev without a full package URL: nothing can be fetched, let alone
1419
+ // installed. Say so and deliver nothing — faking a success here used to
1420
+ // hand the caller a hash whose switchVersion the native side (rightly)
1421
+ // rejects with SWITCH_VERSION_FAILED.
1422
+ log(this.t('dev_incremental_update_disabled'));
1423
+ return;
1424
+ }
1294
1425
  const patchStartTime = Date.now();
1295
1426
  // One native listener per hash dispatching to a callback set, so
1296
1427
  // concurrent callers deduped onto this task can each observe progress
@@ -1342,8 +1473,17 @@ export class Pushy {
1342
1473
  }
1343
1474
  const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3));
1344
1475
  let succeeded = '';
1345
- let lastError: any;
1476
+ let lastError: UpdateError | undefined;
1346
1477
  const errorMessages: string[] = [];
1478
+ // A strategy whose bytes arrived but could not be applied fails the same
1479
+ // way on every retry (same artifact, same base): remember it — with its
1480
+ // message, so the final report still names it — and skip it on later
1481
+ // attempts instead of downloading and patching it again.
1482
+ const exhaustedStrategies = new Map<DownloadStrategyType, string>();
1483
+ // Whether any strategy, in any attempt, failed at the patch stage. The
1484
+ // downloadFallback report carries that as its code even when a later,
1485
+ // unrelated transport failure happened to be the last error seen.
1486
+ let patchFailed = false;
1347
1487
 
1348
1488
  // The ordered attempts come from decideDownload (the pure decision layer);
1349
1489
  // this side only executes them: probe candidate URLs, run the matching
@@ -1386,6 +1526,7 @@ export class Pushy {
1386
1526
  log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`);
1387
1527
  await new Promise((r) => setTimeout(r, backoffMs));
1388
1528
  errorMessages.length = 0;
1529
+ errorMessages.push(...exhaustedStrategies.values());
1389
1530
  lastError = undefined;
1390
1531
  succeeded = '';
1391
1532
  }
@@ -1396,14 +1537,13 @@ export class Pushy {
1396
1537
  attempt,
1397
1538
  },
1398
1539
  });
1399
- if (devNoop) {
1400
- log(this.t('dev_incremental_update_disabled'));
1401
- succeeded = 'full';
1402
- }
1403
1540
  for (const { type, urls } of attempts) {
1404
1541
  if (succeeded) {
1405
1542
  break;
1406
1543
  }
1544
+ if (exhaustedStrategies.has(type)) {
1545
+ continue;
1546
+ }
1407
1547
  // The HEAD race only ranks mirrors; it is not proof that a mirror can
1408
1548
  // serve the whole artifact (CDNs answer HEAD and GET differently, and
1409
1549
  // a transfer can die mid-stream). Every mirror of the same artifact
@@ -1416,30 +1556,43 @@ export class Pushy {
1416
1556
  await runners[type](url);
1417
1557
  succeeded = type;
1418
1558
  break;
1419
- } catch (e: any) {
1559
+ } catch (e: unknown) {
1560
+ // The native rejection's stable code (e.g. PATCH_FAILED vs
1561
+ // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the
1562
+ // `code` property or, from bridges that cannot set it, as a
1563
+ // `[CODE] ` message prefix. Either way it survives into the
1564
+ // i18n-wrapped error together with the original as cause.
1565
+ const { code, message } = readErrorCode(e);
1420
1566
  const errorMessage = this.t(errorKeys[type], {
1421
- message: e.message,
1567
+ message: message || String(e ?? ''),
1422
1568
  });
1423
1569
  errorMessages.push(errorMessage);
1424
- // Keep the i18n message for display, but preserve the native
1425
- // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
1426
- // telemetry classifies on it) and the original error as cause.
1427
1570
  lastError = new UpdateError(
1428
1571
  errorMessage,
1429
- asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
1430
- { cause: e }
1572
+ code ?? 'DOWNLOAD_FAILED',
1573
+ {
1574
+ cause: e,
1575
+ }
1431
1576
  );
1432
1577
  log(errorMessage);
1578
+ if (lastError.code === 'PATCH_FAILED') {
1579
+ patchFailed = true;
1580
+ }
1433
1581
  if (!isMirrorRetryableCode(lastError.code)) {
1434
1582
  // The bytes arrived but could not be applied (patch/manifest
1435
1583
  // mismatch): every mirror serves the same artifact, so move
1436
1584
  // on to the next strategy instead of re-downloading it.
1585
+ exhaustedStrategies.set(type, errorMessage);
1437
1586
  break;
1438
1587
  }
1439
1588
  }
1440
1589
  }
1441
1590
  }
1442
- if (succeeded) {
1591
+ if (
1592
+ succeeded ||
1593
+ attempts.every(({ type }) => exhaustedStrategies.has(type))
1594
+ ) {
1595
+ // Nothing left that a retry could change.
1443
1596
  break;
1444
1597
  }
1445
1598
  }
@@ -1455,9 +1608,10 @@ export class Pushy {
1455
1608
  // platform that incremental delivery is failing for this binary.
1456
1609
  this.report({
1457
1610
  type: 'downloadFallback',
1458
- // UpdateError.code already carries the classified native rejection
1459
- // code (PATCH_FAILED vs DOWNLOAD_FAILED).
1460
- code: lastError?.code,
1611
+ // The patch-health signal wins over whatever transport failure
1612
+ // happened to come last (diff PATCH_FAILED, then pdiff network
1613
+ // error, then full succeeded is still a patch_fail server-side).
1614
+ code: patchFailed ? 'PATCH_FAILED' : lastError?.code,
1461
1615
  data: {
1462
1616
  newVersion: hash,
1463
1617
  succeeded,
@@ -1643,6 +1797,10 @@ export class Pushy {
1643
1797
  sharedState.downloadedHash = undefined;
1644
1798
  sharedState.toHash = undefined;
1645
1799
  sharedState.marked = false;
1800
+ // Invalidate the private owner too: old promises/timers must not touch
1801
+ // a switch that starts after this reset.
1802
+ invalidateApplyingUpdate();
1803
+ sharedState.apkStatus = null;
1646
1804
  this.report({ type: 'reset' });
1647
1805
  if (options?.restart) {
1648
1806
  try {
package/src/context.ts CHANGED
@@ -1,35 +1,27 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import type { Cresc, Pushy } from './client';
3
3
  import i18n from './i18n';
4
- import type { CheckResult, ProgressData } from './type';
4
+ import type { CheckResult, ProgressData, UpdateTestPayload } from './type';
5
5
 
6
6
  const noop = () => {};
7
7
  const asyncNoop = () => Promise.resolve(undefined);
8
8
 
9
- export const defaultContext = {
10
- checkUpdate: asyncNoop,
11
- switchVersion: asyncNoop,
12
- switchVersionLater: asyncNoop,
13
- markSuccess: noop,
14
- dismissError: noop,
15
- downloadUpdate: asyncNoop,
16
- downloadAndInstallApk: asyncNoop,
17
- restartApp: asyncNoop,
18
- resetToPackagedBundle: asyncNoop,
19
- getCurrentVersionInfo: () => Promise.resolve({}),
20
- parseTestQrCode: () => false,
21
- currentHash: '',
22
- packageVersion: '',
23
- currentVersionInfo: {},
24
- };
25
-
26
- export const UpdateContext = createContext<{
27
- checkUpdate: () => Promise<undefined | CheckResult>;
28
- switchVersion: () => Promise<void>;
29
- switchVersionLater: () => Promise<void>;
30
- markSuccess: () => void;
9
+ /**
10
+ * What UpdateProvider puts into UpdateContext (and useUpdate() returns, plus
11
+ * `progress`). The signatures mirror the provider's callbacks: the `info`
12
+ * parameters default to the last check result, `checkUpdate` accepts request
13
+ * extras, and the mutations resolve once the client is done.
14
+ */
15
+ export interface UpdateContextValue {
16
+ checkUpdate: (params?: {
17
+ extra?: Partial<{ toHash: string }>;
18
+ }) => Promise<undefined | CheckResult>;
19
+ /** Resolves true once the native reload was requested; false when skipped. */
20
+ switchVersion: (info?: CheckResult) => Promise<boolean | undefined>;
21
+ switchVersionLater: (info?: CheckResult) => Promise<void>;
22
+ markSuccess: () => Promise<void>;
31
23
  dismissError: () => void;
32
- downloadUpdate: () => Promise<boolean | undefined>;
24
+ downloadUpdate: (info?: CheckResult) => Promise<boolean | undefined>;
33
25
  downloadAndInstallApk: (url: string) => Promise<void>;
34
26
  // @deprecated use currentVersionInfo instead
35
27
  getCurrentVersionInfo: () => Promise<{
@@ -42,7 +34,7 @@ export const UpdateContext = createContext<{
42
34
  description?: string;
43
35
  metaInfo?: string;
44
36
  } | null;
45
- parseTestQrCode: (code: string) => boolean;
37
+ parseTestQrCode: (code: string | UpdateTestPayload) => boolean;
46
38
  restartApp: () => Promise<void>;
47
39
  resetToPackagedBundle: (options?: {
48
40
  restart?: boolean;
@@ -52,7 +44,26 @@ export const UpdateContext = createContext<{
52
44
  client?: Pushy | Cresc;
53
45
  updateInfo?: CheckResult;
54
46
  lastError?: Error;
55
- }>(defaultContext);
47
+ }
48
+
49
+ export const defaultContext: UpdateContextValue = {
50
+ checkUpdate: asyncNoop,
51
+ switchVersion: asyncNoop,
52
+ switchVersionLater: asyncNoop,
53
+ markSuccess: asyncNoop,
54
+ dismissError: noop,
55
+ downloadUpdate: asyncNoop,
56
+ downloadAndInstallApk: asyncNoop,
57
+ restartApp: asyncNoop,
58
+ resetToPackagedBundle: asyncNoop,
59
+ getCurrentVersionInfo: () => Promise.resolve({}),
60
+ parseTestQrCode: () => false,
61
+ currentHash: '',
62
+ packageVersion: '',
63
+ currentVersionInfo: {},
64
+ };
65
+
66
+ export const UpdateContext = createContext<UpdateContextValue>(defaultContext);
56
67
 
57
68
  // Download progress ticks at high frequency, so it lives in its own context;
58
69
  // otherwise every tick would re-render all useUpdate() consumers even when
@@ -68,7 +79,9 @@ export const ProgressContext = createContext<ProgressData | undefined>(
68
79
  */
69
80
  export const useUpdateProgress = () => useContext(ProgressContext);
70
81
 
71
- export const useUpdate = () => {
82
+ export const useUpdate = (): UpdateContextValue & {
83
+ progress?: ProgressData;
84
+ } => {
72
85
  const context = useContext(UpdateContext);
73
86
  const progress = useContext(ProgressContext);
74
87