react-native-update 10.45.0 → 10.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -39,7 +39,23 @@ import {
39
39
  testUrls,
40
40
  } from './utils';
41
41
  import i18n from './i18n';
42
+ import { toUpdateError, UpdateError, UpdateErrorCode } from './error';
42
43
  import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
44
+ import {
45
+ resolveServerEventHash,
46
+ resolveServerEventType,
47
+ truncateDetail,
48
+ } from './telemetry';
49
+
50
+ /**
51
+ * Receives every error the client reports, alongside the report event type.
52
+ * The UpdateProvider subscribes to surface errors as lastError/Alert; user
53
+ * code can subscribe too. Listeners run before any throwError rethrow.
54
+ */
55
+ export type UpdateErrorListener = (
56
+ error: UpdateError,
57
+ eventType: EventType,
58
+ ) => void;
43
59
 
44
60
  const SERVER_PRESETS = {
45
61
  // cn
@@ -119,6 +135,9 @@ export class Pushy {
119
135
  clientType: 'Pushy' | 'Cresc' = 'Pushy';
120
136
  lastChecking?: number;
121
137
  lastRespJson?: Promise<CheckResult>;
138
+ // Endpoint that most recently served a successful checkUpdate; telemetry
139
+ // reuses it instead of re-running the fallback race.
140
+ private lastWorkingEndpoint?: string;
122
141
 
123
142
  version = cInfo.rnu;
124
143
  loggerPromise = (() => {
@@ -142,7 +161,10 @@ export class Pushy {
142
161
 
143
162
  if (Platform.OS === 'ios' || Platform.OS === 'android') {
144
163
  if (!options.appKey) {
145
- throw Error(i18n.t('error_appkey_required'));
164
+ throw new UpdateError(
165
+ i18n.t('error_appkey_required'),
166
+ 'APPKEY_REQUIRED',
167
+ );
146
168
  }
147
169
  }
148
170
 
@@ -184,13 +206,17 @@ export class Pushy {
184
206
  report = async ({
185
207
  type,
186
208
  message = '',
209
+ code,
187
210
  data = {},
188
211
  }: {
189
212
  type: EventType;
190
213
  message?: string;
214
+ code?: UpdateErrorCode;
191
215
  data?: Record<string, string | number>;
192
216
  }) => {
193
- log(`${type} ${message}`);
217
+ log(`${type} ${code ? `[${code}] ` : ''}${message}`);
218
+ // Fire-and-forget server telemetry; must not wait for the logger below.
219
+ this.reportToServer({ type, message, code, data });
194
220
  if (this.options.logger === noop) {
195
221
  // Wait briefly for a logger to arrive via setOptions (e.g. the rollback
196
222
  // report fires in the constructor before the user configures one), but
@@ -214,6 +240,7 @@ export class Pushy {
214
240
  overridePackageVersion,
215
241
  buildTime,
216
242
  message,
243
+ code,
217
244
  ...currentVersionInfo,
218
245
  ...data,
219
246
  },
@@ -225,11 +252,110 @@ export class Pushy {
225
252
  log('logger error:', e?.message || e);
226
253
  }
227
254
  };
255
+ /**
256
+ * Best-effort lifecycle event reporting to the update server (aggregate
257
+ * counts + sampled failure details power the version health view and the
258
+ * rollback safety net server-side). Single POST to the last known working
259
+ * endpoint, no retry, no fallback race; any failure is swallowed — telemetry
260
+ * must never affect the update flow. Opt out with disableTelemetry.
261
+ */
262
+ private reportToServer = ({
263
+ type,
264
+ message = '',
265
+ code,
266
+ data = {},
267
+ }: {
268
+ type: EventType;
269
+ message?: string;
270
+ code?: UpdateErrorCode;
271
+ data?: Record<string, string | number>;
272
+ }) => {
273
+ try {
274
+ if (__DEV__ || this.options.disableTelemetry) {
275
+ return;
276
+ }
277
+ const serverType = resolveServerEventType(type, code);
278
+ if (!serverType) {
279
+ return;
280
+ }
281
+ const { appKey } = this.options;
282
+ const endpoint =
283
+ this.lastWorkingEndpoint || this.options.server?.main?.[0];
284
+ if (!appKey || !endpoint) {
285
+ return;
286
+ }
287
+ const hash = resolveServerEventHash({ serverType, data, currentVersion });
288
+ if (!hash) {
289
+ return;
290
+ }
291
+ const send = (payloadType: typeof serverType, detail?: string) =>
292
+ fetchWithTimeout(
293
+ `${endpoint}/report/${appKey}`,
294
+ {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({
298
+ type: payloadType,
299
+ hash,
300
+ packageVersion:
301
+ this.options.overridePackageVersion || packageVersion,
302
+ cInfo,
303
+ detail: truncateDetail(detail),
304
+ }),
305
+ },
306
+ DEFAULT_FETCH_TIMEOUT_MS,
307
+ ).catch((e: any) => {
308
+ log('telemetry report failed:', e?.message || e);
309
+ });
310
+ send(serverType, message || undefined);
311
+ // A download that only succeeded after an incremental patch failed is
312
+ // still a patch_fail signal server-side (diff quality), carried in
313
+ // data.error alongside the downloadSuccess event.
314
+ if (serverType === 'download_success' && data.error) {
315
+ send('patch_fail', String(data.error));
316
+ }
317
+ } catch (e: any) {
318
+ log('telemetry error:', e?.message || e);
319
+ }
320
+ };
228
321
  throwIfEnabled = (e: Error) => {
229
322
  if (this.options.throwError) {
230
323
  throw e;
231
324
  }
232
325
  };
326
+ private errorListeners = new Set<UpdateErrorListener>();
327
+ /**
328
+ * Subscribe to every error the client reports (regardless of throwError).
329
+ * Returns an unsubscribe function.
330
+ */
331
+ onError = (listener: UpdateErrorListener) => {
332
+ this.errorListeners.add(listener);
333
+ return () => {
334
+ this.errorListeners.delete(listener);
335
+ };
336
+ };
337
+ /**
338
+ * Single exit point for errors: reports to the logger (with the stable
339
+ * code) and notifies onError listeners. Whether to also throw stays with
340
+ * the caller (throwIfEnabled or an unconditional rethrow).
341
+ */
342
+ private emitError = (
343
+ error: UpdateError,
344
+ type: EventType,
345
+ {
346
+ message = error.message,
347
+ data,
348
+ }: { message?: string; data?: Record<string, string | number> } = {},
349
+ ) => {
350
+ this.report({ type, message, code: error.code, data });
351
+ for (const listener of this.errorListeners) {
352
+ try {
353
+ listener(error, type);
354
+ } catch (e: any) {
355
+ log('onError listener error:', e?.message || e);
356
+ }
357
+ }
358
+ };
233
359
  notifyAfterCheckUpdate = (state: UpdateCheckState) => {
234
360
  const { afterCheckUpdate } = this.options;
235
361
  if (!afterCheckUpdate) {
@@ -302,11 +428,13 @@ export class Pushy {
302
428
 
303
429
  if (!resp.ok) {
304
430
  const respText = await resp.text();
305
- throw Error(
431
+ throw new UpdateError(
306
432
  this.t('error_http_status', {
307
433
  status: resp.status,
308
434
  statusText: respText,
309
435
  }),
436
+ 'HTTP_STATUS',
437
+ { extra: { status: resp.status } },
310
438
  );
311
439
  }
312
440
 
@@ -335,6 +463,7 @@ export class Pushy {
335
463
  });
336
464
 
337
465
  log('check endpoint success', endpoint);
466
+ this.lastWorkingEndpoint = endpoint;
338
467
  return value;
339
468
  };
340
469
  assertDebug = (matter: string) => {
@@ -348,7 +477,13 @@ export class Pushy {
348
477
  if (sharedState.marked || __DEV__ || !isFirstTime) {
349
478
  return;
350
479
  }
351
- await Promise.resolve(PushyModule.markSuccess());
480
+ try {
481
+ await Promise.resolve(PushyModule.markSuccess());
482
+ } catch (e) {
483
+ const err = toUpdateError(e, 'MARK_SUCCESS_FAILED');
484
+ this.emitError(err, 'errorMarkSuccess');
485
+ throw err;
486
+ }
352
487
  sharedState.marked = true;
353
488
  this.report({ type: 'markSuccess' });
354
489
  };
@@ -366,7 +501,11 @@ export class Pushy {
366
501
  }
367
502
  } catch (e) {
368
503
  sharedState.applyingUpdate = false;
369
- throw e;
504
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
505
+ this.emitError(err, 'errorSwitchVersion', {
506
+ data: { newVersion: hash },
507
+ });
508
+ throw err;
370
509
  }
371
510
  try {
372
511
  return await PushyModule.reloadUpdate({ hash });
@@ -374,7 +513,11 @@ export class Pushy {
374
513
  // reloadUpdate can reject (e.g. bundle missing); reset the flag so a
375
514
  // later retry is not permanently blocked by a stuck applyingUpdate.
376
515
  sharedState.applyingUpdate = false;
377
- throw e;
516
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
517
+ this.emitError(err, 'errorSwitchVersion', {
518
+ data: { newVersion: hash },
519
+ });
520
+ throw err;
378
521
  }
379
522
  }
380
523
  };
@@ -385,7 +528,15 @@ export class Pushy {
385
528
  }
386
529
  if (assertHash(hash)) {
387
530
  log(`switchVersionLater: ${hash}`);
388
- return PushyModule.setNeedUpdate({ hash });
531
+ try {
532
+ return await PushyModule.setNeedUpdate({ hash });
533
+ } catch (e) {
534
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
535
+ this.emitError(err, 'errorSwitchVersion', {
536
+ data: { newVersion: hash },
537
+ });
538
+ throw err;
539
+ }
389
540
  }
390
541
  };
391
542
  checkUpdate = async (extra?: Record<string, any>) => {
@@ -456,14 +607,12 @@ export class Pushy {
456
607
  return result;
457
608
  } catch (e: any) {
458
609
  this.lastRespJson = previousRespJson;
459
- const errorMessage =
460
- e?.message || this.t('error_cannot_connect_server');
461
- this.report({
462
- type: 'errorChecking',
463
- message: errorMessage,
610
+ const err = toUpdateError(e, 'CHECK_FAILED');
611
+ this.emitError(err, 'errorChecking', {
612
+ message: err.message || this.t('error_cannot_connect_server'),
464
613
  });
465
- this.notifyAfterCheckUpdate({ status: 'error', error: e });
466
- this.throwIfEnabled(e);
614
+ this.notifyAfterCheckUpdate({ status: 'error', error: err });
615
+ this.throwIfEnabled(err);
467
616
  // Fall back to the previous successful response if we have one; otherwise
468
617
  // return undefined so callers can distinguish "check failed" from a real
469
618
  // empty result and avoid overwriting the last good updateInfo.
@@ -669,14 +818,22 @@ export class Pushy {
669
818
  delete sharedState.progressHandlers[hash];
670
819
  }
671
820
  if (!succeeded) {
821
+ const message = errorMessages.join(';');
822
+ if (lastError) {
823
+ const err = toUpdateError(lastError, 'DOWNLOAD_FAILED');
824
+ this.emitError(err, 'errorUpdate', {
825
+ message,
826
+ data: { newVersion: hash },
827
+ });
828
+ throw err;
829
+ }
830
+ // No download URL was even attempted (e.g. dev without a full URL):
831
+ // report for diagnostics but there is no error object to surface.
672
832
  this.report({
673
833
  type: 'errorUpdate',
674
834
  data: { newVersion: hash },
675
- message: errorMessages.join(';'),
835
+ message,
676
836
  });
677
- if (lastError) {
678
- throw lastError;
679
- }
680
837
  return;
681
838
  } else {
682
839
  const duration = Date.now() - patchStartTime;
@@ -717,8 +874,12 @@ export class Pushy {
717
874
  return;
718
875
  }
719
876
  if (sharedState.apkStatus === 'downloaded') {
720
- this.report({ type: 'errorInstallApk' });
721
- this.throwIfEnabled(Error('errorInstallApk'));
877
+ const err = new UpdateError(
878
+ this.t('error_apk_pending_install'),
879
+ 'APK_INSTALL_PENDING',
880
+ );
881
+ this.emitError(err, 'errorInstallApk');
882
+ this.throwIfEnabled(err);
722
883
  return;
723
884
  }
724
885
  if (Platform.Version <= 23) {
@@ -727,13 +888,18 @@ export class Pushy {
727
888
  PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
728
889
  );
729
890
  if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
730
- this.report({ type: 'rejectStoragePermission' });
731
- this.throwIfEnabled(Error('rejectStoragePermission'));
891
+ const err = new UpdateError(
892
+ this.t('error_storage_permission_rejected'),
893
+ 'STORAGE_PERMISSION_REJECTED',
894
+ );
895
+ this.emitError(err, 'rejectStoragePermission');
896
+ this.throwIfEnabled(err);
732
897
  return;
733
898
  }
734
899
  } catch (e: any) {
735
- this.report({ type: 'errorStoragePermission' });
736
- this.throwIfEnabled(e);
900
+ const err = toUpdateError(e, 'STORAGE_PERMISSION_ERROR');
901
+ this.emitError(err, 'errorStoragePermission');
902
+ this.throwIfEnabled(err);
737
903
  return;
738
904
  }
739
905
  }
@@ -761,10 +927,14 @@ export class Pushy {
761
927
  hash: progressKey,
762
928
  });
763
929
  sharedState.apkStatus = 'downloaded';
764
- } catch {
930
+ } catch (e) {
765
931
  sharedState.apkStatus = null;
766
- this.report({ type: 'errorDownloadAndInstallApk' });
767
- this.throwIfEnabled(Error('errorDownloadAndInstallApk'));
932
+ // Keep the native error (message/stack) instead of discarding it.
933
+ const err = toUpdateError(e, 'APK_DOWNLOAD_FAILED');
934
+ this.emitError(err, 'errorDownloadAndInstallApk', {
935
+ message: err.message || this.t('error_apk_download_failed'),
936
+ });
937
+ this.throwIfEnabled(err);
768
938
  } finally {
769
939
  if (sharedState.progressHandlers[progressKey]) {
770
940
  sharedState.progressHandlers[progressKey].remove();
package/src/core.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
2
2
  import i18n from './i18n';
3
+ import { UpdateError } from './error';
3
4
  import { emptyModule, error, log } from './utils';
4
5
 
5
6
  /* eslint-disable @react-native/no-deep-imports */
@@ -21,8 +22,9 @@ export const PushyModule =
21
22
  export const UpdateModule = PushyModule;
22
23
 
23
24
  if (!PushyModule) {
24
- throw Error(
25
+ throw new UpdateError(
25
26
  'Failed to load react-native-update native module, please try to recompile',
27
+ 'MODULE_NOT_LOADED',
26
28
  );
27
29
  }
28
30
 
package/src/endpoint.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { UpdateError } from './error';
2
+
1
3
  export interface EndpointAttemptSuccess<T> {
2
4
  endpoint: string;
3
5
  value: T;
@@ -47,7 +49,7 @@ export const pickRandomEndpoint = (
47
49
  random: () => number = Math.random,
48
50
  ) => {
49
51
  if (!endpoints.length) {
50
- throw new Error('No endpoints configured');
52
+ throw new UpdateError('No endpoints configured', 'NO_ENDPOINTS');
51
53
  }
52
54
  return endpoints[Math.floor(random() * endpoints.length)];
53
55
  };
@@ -120,7 +122,7 @@ export async function executeEndpointFallback<T>({
120
122
  let candidates = dedupeEndpoints(configuredEndpoints);
121
123
 
122
124
  if (!candidates.length) {
123
- throw new Error('No endpoints configured');
125
+ throw new UpdateError('No endpoints configured', 'NO_ENDPOINTS');
124
126
  }
125
127
 
126
128
  const firstEndpoint = pickRandomEndpoint(candidates, random);
package/src/error.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Stable, machine-readable error codes. Unlike messages (which are localized
3
+ * and vary across platforms), codes are safe to aggregate on in a logger.
4
+ *
5
+ * The native-originated codes (second group) are defined in
6
+ * cpp/patch_core/error_codes.h — the single source of truth shared by the
7
+ * Android/iOS/Harmony modules — and flow through promise rejections onto the
8
+ * `code` property, which toUpdateError() preserves.
9
+ */
10
+ export type UpdateErrorCode =
11
+ // JS-layer codes
12
+ | 'MODULE_NOT_LOADED'
13
+ | 'APPKEY_REQUIRED'
14
+ | 'NO_ENDPOINTS'
15
+ | 'HTTP_STATUS'
16
+ | 'CHECK_FAILED'
17
+ | 'DOWNLOAD_FAILED'
18
+ | 'SWITCH_VERSION_FAILED'
19
+ | 'MARK_SUCCESS_FAILED'
20
+ | 'APK_INSTALL_PENDING'
21
+ | 'STORAGE_PERMISSION_REJECTED'
22
+ | 'STORAGE_PERMISSION_ERROR'
23
+ | 'APK_DOWNLOAD_FAILED'
24
+ // Native codes (see cpp/patch_core/error_codes.h)
25
+ | 'INVALID_OPTIONS'
26
+ | 'PATCH_FAILED'
27
+ | 'FILE_OPERATION_FAILED'
28
+ | 'RESTART_FAILED'
29
+ | 'INVALID_HASH_INFO'
30
+ | 'UNSUPPORTED_PLATFORM';
31
+
32
+ export class UpdateError extends Error {
33
+ code: UpdateErrorCode;
34
+ cause?: unknown;
35
+ extra?: Record<string, string | number>;
36
+
37
+ constructor(
38
+ message: string,
39
+ code: UpdateErrorCode,
40
+ options?: { cause?: unknown; extra?: Record<string, string | number> },
41
+ ) {
42
+ super(message);
43
+ this.name = 'UpdateError';
44
+ this.code = code;
45
+ this.cause = options?.cause;
46
+ this.extra = options?.extra;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Attach a code to an unknown thrown value. An existing Error keeps its
52
+ * identity (message, stack, and any code already assigned upstream) so callers
53
+ * comparing the caught error to the original still match; non-Error values are
54
+ * wrapped.
55
+ */
56
+ export const toUpdateError = (
57
+ e: unknown,
58
+ code: UpdateErrorCode,
59
+ ): UpdateError => {
60
+ if (e instanceof Error) {
61
+ const err = e as UpdateError;
62
+ if (!err.code) {
63
+ err.code = code;
64
+ }
65
+ return err;
66
+ }
67
+ return new UpdateError(String(e ?? code), code);
68
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { Pushy, Cresc } from './client';
2
+ export type { UpdateErrorListener } from './client';
3
+ export { UpdateError } from './error';
4
+ export type { UpdateErrorCode } from './error';
2
5
  export {
3
6
  ProgressContext,
4
7
  UpdateContext,
package/src/locales/en.ts CHANGED
@@ -19,7 +19,6 @@ export default {
19
19
  time_remaining: 'Time remaining: {{time}}',
20
20
 
21
21
  // Error messages
22
- error_code: 'Error code: {{code}}',
23
22
  error_message: 'Error message: {{message}}',
24
23
  retry_count: 'Retry attempt: {{count}}/{{max}}',
25
24
 
@@ -57,6 +56,11 @@ export default {
57
56
  error_ping_failed: 'Ping failed',
58
57
  error_ping_timeout: 'Ping timeout',
59
58
  error_http_status: '{{status}} {{statusText}}',
59
+ error_apk_pending_install:
60
+ 'The APK has been downloaded, please complete the installation in the system installer',
61
+ error_storage_permission_rejected:
62
+ 'Storage permission denied, unable to download the APK',
63
+ error_apk_download_failed: 'Failed to download or install the APK',
60
64
 
61
65
  // Development messages
62
66
  dev_debug_disabled:
package/src/locales/zh.ts CHANGED
@@ -19,7 +19,6 @@ export default {
19
19
  time_remaining: '剩余时间: {{time}}',
20
20
 
21
21
  // Error messages
22
- error_code: '错误代码: {{code}}',
23
22
  error_message: '错误信息: {{message}}',
24
23
  retry_count: '重试次数: {{count}}/{{max}}',
25
24
 
@@ -54,6 +53,9 @@ export default {
54
53
  error_ping_failed: 'Ping 失败',
55
54
  error_ping_timeout: 'Ping 超时',
56
55
  error_http_status: '{{status}} {{statusText}}',
56
+ error_apk_pending_install: '安装包已下载完成,请在系统安装界面完成安装',
57
+ error_storage_permission_rejected: '存储权限被拒绝,无法下载安装包',
58
+ error_apk_download_failed: '安装包下载或安装失败',
57
59
 
58
60
  // Development messages
59
61
  dev_debug_disabled:
package/src/provider.tsx CHANGED
@@ -46,15 +46,6 @@ export const UpdateProvider = ({
46
46
  const [progress, setProgress] = useState<ProgressData>();
47
47
  const [lastError, setLastError] = useState<Error>();
48
48
 
49
- const throwErrorIfEnabled = useCallback(
50
- (e: Error) => {
51
- if (options.throwError) {
52
- throw e;
53
- }
54
- },
55
- [options.throwError],
56
- );
57
-
58
49
  const dismissError = useCallback(() => {
59
50
  setLastError(undefined);
60
51
  }, []);
@@ -80,6 +71,25 @@ export const UpdateProvider = ({
80
71
  [options.updateStrategy],
81
72
  );
82
73
 
74
+ // All client errors flow through this single subscription (regardless of
75
+ // throwError), so the catches below only handle flow control and never
76
+ // duplicate the lastError/Alert surfacing.
77
+ useEffect(
78
+ () =>
79
+ client.onError((e, eventType) => {
80
+ setLastError(e);
81
+ alertError(
82
+ client.t(
83
+ eventType === 'errorChecking'
84
+ ? 'error_update_check_failed'
85
+ : 'update_failed',
86
+ ),
87
+ e.message,
88
+ );
89
+ }),
90
+ [client, alertError],
91
+ );
92
+
83
93
  const switchVersion = useCallback(
84
94
  async (info: CheckResult | undefined = updateInfoRef.current) => {
85
95
  if (info && info.hash) {
@@ -117,10 +127,11 @@ export const UpdateProvider = ({
117
127
  return false;
118
128
  }
119
129
  if (options.updateStrategy === 'silentAndNow') {
120
- client.switchVersion(hash);
130
+ // Failures are surfaced via the onError subscription above.
131
+ client.switchVersion(hash).catch(noop);
121
132
  return true;
122
133
  } else if (options.updateStrategy === 'silentAndLater') {
123
- client.switchVersionLater(hash);
134
+ client.switchVersionLater(hash).catch(noop);
124
135
  return true;
125
136
  }
126
137
  alertUpdate(client.t('alert_title'), client.t('alert_update_ready'), [
@@ -128,26 +139,33 @@ export const UpdateProvider = ({
128
139
  text: client.t('alert_next_time'),
129
140
  style: 'cancel',
130
141
  onPress: () => {
131
- client.switchVersionLater(hash);
142
+ client.switchVersionLater(hash).catch(noop);
132
143
  },
133
144
  },
134
145
  {
135
146
  text: client.t('alert_update_now'),
136
147
  style: 'default',
137
148
  onPress: () => {
138
- client.switchVersion(hash);
149
+ client.switchVersion(hash).catch(noop);
139
150
  },
140
151
  },
141
152
  ]);
142
153
  return true;
143
154
  } catch (e: any) {
144
- setLastError(e);
145
- alertError(client.t('update_failed'), e.message);
146
- throwErrorIfEnabled(e);
155
+ // Client pipeline errors carry a code and were already surfaced via
156
+ // the onError subscription; errors thrown by user hooks
157
+ // (afterDownloadUpdate) bypass the pipeline and are surfaced here.
158
+ if (!e?.code) {
159
+ setLastError(e);
160
+ alertError(client.t('update_failed'), e.message);
161
+ }
162
+ if (options.throwError) {
163
+ throw e;
164
+ }
147
165
  return false;
148
166
  }
149
167
  },
150
- [client, options, alertUpdate, alertError, throwErrorIfEnabled],
168
+ [client, options, alertUpdate, alertError],
151
169
  );
152
170
 
153
171
  const downloadAndInstallApk = useCallback(
@@ -168,9 +186,16 @@ export const UpdateProvider = ({
168
186
  try {
169
187
  rootInfo = await client.checkUpdate(extra);
170
188
  } catch (e: any) {
171
- setLastError(e);
172
- alertError(client.t('error_update_check_failed'), e.message);
173
- throwErrorIfEnabled(e);
189
+ // Client pipeline errors carry a code and were already surfaced via
190
+ // the onError subscription; errors thrown by user hooks
191
+ // (beforeCheckUpdate) bypass the pipeline and are surfaced here.
192
+ if (!e?.code) {
193
+ setLastError(e);
194
+ alertError(client.t('error_update_check_failed'), e.message);
195
+ }
196
+ if (options.throwError) {
197
+ throw e;
198
+ }
174
199
  return;
175
200
  }
176
201
  if (!rootInfo) {
@@ -252,10 +277,9 @@ export const UpdateProvider = ({
252
277
  },
253
278
  [
254
279
  client,
255
- alertError,
256
- throwErrorIfEnabled,
257
280
  options,
258
281
  alertUpdate,
282
+ alertError,
259
283
  downloadAndInstallApk,
260
284
  downloadUpdate,
261
285
  ],
@@ -274,7 +298,8 @@ export const UpdateProvider = ({
274
298
  let markSuccessTimer: ReturnType<typeof setTimeout> | undefined;
275
299
  if (autoMarkSuccess) {
276
300
  markSuccessTimer = setTimeout(() => {
277
- markSuccess();
301
+ // Failures are reported and surfaced via the onError subscription.
302
+ Promise.resolve(markSuccess()).catch(noop);
278
303
  }, 1000);
279
304
  }
280
305
  if (checkStrategy === 'both' || checkStrategy === 'onAppResume') {
@@ -351,7 +376,8 @@ export const UpdateProvider = ({
351
376
  try {
352
377
  const payload = typeof code === 'string' ? JSON.parse(code) : code;
353
378
  return parseTestPayload(payload);
354
- } catch {
379
+ } catch (e: any) {
380
+ log('parseTestQrCode: invalid payload', e?.message || e);
355
381
  return false;
356
382
  }
357
383
  },