react-native-update 10.45.0 → 10.46.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.
@@ -0,0 +1,24 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ /**
4
+ * Stable, machine-readable error codes used as the promise rejection code so
5
+ * the JS layer and user loggers can aggregate errors across platforms.
6
+ *
7
+ * MUST stay in sync with cpp/patch_core/error_codes.h (the single source of
8
+ * truth) and src/error.ts (UpdateErrorCode). Messages are free-form; only the
9
+ * codes are part of the contract.
10
+ */
11
+ final class ErrorCodes {
12
+ static final String INVALID_OPTIONS = "INVALID_OPTIONS";
13
+ static final String DOWNLOAD_FAILED = "DOWNLOAD_FAILED";
14
+ static final String PATCH_FAILED = "PATCH_FAILED";
15
+ static final String FILE_OPERATION_FAILED = "FILE_OPERATION_FAILED";
16
+ static final String SWITCH_VERSION_FAILED = "SWITCH_VERSION_FAILED";
17
+ static final String MARK_SUCCESS_FAILED = "MARK_SUCCESS_FAILED";
18
+ static final String RESTART_FAILED = "RESTART_FAILED";
19
+ static final String INVALID_HASH_INFO = "INVALID_HASH_INFO";
20
+ static final String UNSUPPORTED_PLATFORM = "UNSUPPORTED_PLATFORM";
21
+
22
+ private ErrorCodes() {
23
+ }
24
+ }
@@ -43,6 +43,7 @@ final class StateSerialRunner {
43
43
 
44
44
  static void run(
45
45
  @Nullable final Promise promise,
46
+ final String errorCode,
46
47
  final String operationName,
47
48
  final Operation operation
48
49
  ) {
@@ -53,7 +54,7 @@ final class StateSerialRunner {
53
54
  operation.run();
54
55
  } catch (Throwable error) {
55
56
  if (promise != null) {
56
- promise.reject(operationName + " failed", error);
57
+ promise.reject(errorCode, operationName + " failed", error);
57
58
  } else {
58
59
  Log.e(UpdateContext.TAG, operationName + " failed", error);
59
60
  }
@@ -15,6 +15,7 @@ final class UiThreadRunner {
15
15
 
16
16
  static void run(
17
17
  @Nullable final Promise promise,
18
+ final String errorCode,
18
19
  final String operationName,
19
20
  final Operation operation
20
21
  ) {
@@ -25,7 +26,7 @@ final class UiThreadRunner {
25
26
  operation.run();
26
27
  } catch (Throwable error) {
27
28
  if (promise != null) {
28
- promise.reject(operationName + " failed", error);
29
+ promise.reject(errorCode, operationName + " failed", error);
29
30
  } else {
30
31
  Log.e(UpdateContext.TAG, operationName + " failed", error);
31
32
  }
@@ -207,8 +207,10 @@ public class UpdateContext {
207
207
  }
208
208
 
209
209
  private void persistEditor(SharedPreferences.Editor editor, String reason) {
210
- if (!editor.commit() && DEBUG) {
211
- Log.w(TAG, "Failed to persist update state for " + reason);
210
+ // A lost state write can mean a missed rollback or a version switch
211
+ // that silently never happens, so this must be visible in release too.
212
+ if (!editor.commit()) {
213
+ Log.e(TAG, "Failed to persist update state for " + reason);
212
214
  }
213
215
  }
214
216
 
@@ -367,6 +369,13 @@ public class UpdateContext {
367
369
  ignoreRollback,
368
370
  true
369
371
  );
372
+ if (launchState.didRollback) {
373
+ // The crash-protection rollback: the new version never called
374
+ // markSuccess. Keep this visible in release logs.
375
+ Log.e(TAG, "Version " + currentState.currentVersion
376
+ + " was not marked as successful, rolling back to "
377
+ + launchState.currentVersion);
378
+ }
370
379
  if (launchState.didRollback || launchState.consumedFirstTime) {
371
380
  SharedPreferences.Editor editor = sp.edit();
372
381
  applyState(editor, launchState);
@@ -411,6 +420,8 @@ public class UpdateContext {
411
420
  false,
412
421
  false
413
422
  );
423
+ Log.e(TAG, "Rolling back version " + currentState.currentVersion
424
+ + " to " + nextState.currentVersion);
414
425
  SharedPreferences.Editor editor = sp.edit();
415
426
  applyState(editor, nextState);
416
427
  persistEditor(editor, "rollback");
@@ -41,7 +41,7 @@ public class UpdateModuleImpl {
41
41
 
42
42
  @Override
43
43
  public void onDownloadFailed(Throwable error) {
44
- promise.reject(error);
44
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
45
45
  }
46
46
  });
47
47
  }
@@ -64,7 +64,7 @@ public class UpdateModuleImpl {
64
64
 
65
65
  @Override
66
66
  public void onDownloadFailed(Throwable error) {
67
- promise.reject(error);
67
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
68
68
  }
69
69
  });
70
70
  }
@@ -84,7 +84,7 @@ public class UpdateModuleImpl {
84
84
 
85
85
  @Override
86
86
  public void onDownloadFailed(Throwable error) {
87
- promise.reject(error);
87
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
88
88
  }
89
89
  });
90
90
  }
@@ -107,11 +107,11 @@ public class UpdateModuleImpl {
107
107
 
108
108
  @Override
109
109
  public void onDownloadFailed(Throwable error) {
110
- promise.reject(error);
110
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
111
111
  }
112
112
  });
113
113
  } catch (Exception e) {
114
- promise.reject("downloadPatchFromPpk failed: " + e.getMessage());
114
+ promise.reject(ErrorCodes.INVALID_OPTIONS, "downloadPatchFromPpk failed: " + e.getMessage(), e);
115
115
  }
116
116
  }
117
117
 
@@ -130,7 +130,7 @@ public class UpdateModuleImpl {
130
130
  @Nullable final String hash,
131
131
  final Promise promise
132
132
  ) {
133
- UiThreadRunner.run(promise, "restartApp", new UiThreadRunner.Operation() {
133
+ UiThreadRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new UiThreadRunner.Operation() {
134
134
  @Override
135
135
  public void run() throws Throwable {
136
136
  ReactReloadManager.restartApp(updateContext, reactContext, hash);
@@ -149,7 +149,7 @@ public class UpdateModuleImpl {
149
149
  final Promise promise
150
150
  ) {
151
151
  final String hash = options.getString("hash");
152
- StateSerialRunner.run(promise, "switchVersionLater", new StateSerialRunner.Operation() {
152
+ StateSerialRunner.run(promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() {
153
153
  @Override
154
154
  public void run() {
155
155
  setNeedUpdateInternal(updateContext, hash);
@@ -160,7 +160,7 @@ public class UpdateModuleImpl {
160
160
 
161
161
  public static void setNeedUpdate(final UpdateContext updateContext, final ReadableMap options) {
162
162
  final String hash = options.getString("hash");
163
- StateSerialRunner.run(null, "switchVersionLater", new StateSerialRunner.Operation() {
163
+ StateSerialRunner.run(null, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() {
164
164
  @Override
165
165
  public void run() {
166
166
  setNeedUpdateInternal(updateContext, hash);
@@ -173,7 +173,7 @@ public class UpdateModuleImpl {
173
173
  }
174
174
 
175
175
  public static void markSuccess(final UpdateContext updateContext, final Promise promise) {
176
- StateSerialRunner.run(promise, "markSuccess", new StateSerialRunner.Operation() {
176
+ StateSerialRunner.run(promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() {
177
177
  @Override
178
178
  public void run() {
179
179
  markSuccessInternal(updateContext);
@@ -183,7 +183,7 @@ public class UpdateModuleImpl {
183
183
  }
184
184
 
185
185
  public static void markSuccess(final UpdateContext updateContext) {
186
- StateSerialRunner.run(null, "markSuccess", new StateSerialRunner.Operation() {
186
+ StateSerialRunner.run(null, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() {
187
187
  @Override
188
188
  public void run() {
189
189
  markSuccessInternal(updateContext);
@@ -200,7 +200,7 @@ public class UpdateModuleImpl {
200
200
  final String uuid,
201
201
  final Promise promise
202
202
  ) {
203
- StateSerialRunner.run(promise, "setUuid", new StateSerialRunner.Operation() {
203
+ StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() {
204
204
  @Override
205
205
  public void run() {
206
206
  setUuidInternal(updateContext, uuid);
@@ -210,7 +210,7 @@ public class UpdateModuleImpl {
210
210
  }
211
211
 
212
212
  public static void setUuid(final UpdateContext updateContext, final String uuid) {
213
- StateSerialRunner.run(null, "setUuid", new StateSerialRunner.Operation() {
213
+ StateSerialRunner.run(null, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() {
214
214
  @Override
215
215
  public void run() {
216
216
  setUuidInternal(updateContext, uuid);
@@ -235,7 +235,7 @@ public class UpdateModuleImpl {
235
235
  final String info,
236
236
  final Promise promise
237
237
  ) {
238
- StateSerialRunner.run(promise, "setLocalHashInfo", new StateSerialRunner.Operation() {
238
+ StateSerialRunner.run(promise, ErrorCodes.INVALID_HASH_INFO, "setLocalHashInfo", new StateSerialRunner.Operation() {
239
239
  @Override
240
240
  public void run() {
241
241
  setLocalHashInfoInternal(updateContext, hash, info);
@@ -249,7 +249,7 @@ public class UpdateModuleImpl {
249
249
  final String hash,
250
250
  final String info
251
251
  ) {
252
- StateSerialRunner.run(null, "setLocalHashInfo", new StateSerialRunner.Operation() {
252
+ StateSerialRunner.run(null, ErrorCodes.INVALID_HASH_INFO, "setLocalHashInfo", new StateSerialRunner.Operation() {
253
253
  @Override
254
254
  public void run() {
255
255
  setLocalHashInfoInternal(updateContext, hash, info);
@@ -264,7 +264,7 @@ public class UpdateModuleImpl {
264
264
  ) {
265
265
  String value = updateContext.getKv("hash_" + hash);
266
266
  if (!isValidHashInfo(value)) {
267
- promise.reject("getLocalHashInfo failed: invalid json string");
267
+ promise.reject(ErrorCodes.INVALID_HASH_INFO, "getLocalHashInfo failed: invalid json string");
268
268
  return;
269
269
  }
270
270
 
@@ -0,0 +1,43 @@
1
+ #ifndef PUSHY_PATCH_CORE_ERROR_CODES_H_
2
+ #define PUSHY_PATCH_CORE_ERROR_CODES_H_
3
+
4
+ // Single source of truth for the stable, machine-readable error codes shared
5
+ // by every platform. Native modules reject promises with one of these codes
6
+ // so the JS layer (src/error.ts UpdateErrorCode) and user loggers can
7
+ // aggregate errors across platforms and locales.
8
+ //
9
+ // Mirrors that cannot include this header MUST stay in sync by hand:
10
+ // - src/error.ts (UpdateErrorCode union, JS layer)
11
+ // - android/.../ErrorCodes.java (Java constants)
12
+ // iOS (RCTPushy.mm) includes this header directly.
13
+ //
14
+ // Human-readable messages are NOT part of this contract: they may differ per
15
+ // platform and locale. Only the codes are stable.
16
+
17
+ namespace pushy {
18
+ namespace error_codes {
19
+
20
+ // Method options missing or malformed (blank hash/url, wrong types).
21
+ constexpr const char* kInvalidOptions = "INVALID_OPTIONS";
22
+ // Native download failed (network error, bad HTTP status, truncated body).
23
+ constexpr const char* kDownloadFailed = "DOWNLOAD_FAILED";
24
+ // Unzip or hdiff patch application failed.
25
+ constexpr const char* kPatchFailed = "PATCH_FAILED";
26
+ // Local file or state persistence operation failed.
27
+ constexpr const char* kFileOperationFailed = "FILE_OPERATION_FAILED";
28
+ // switchVersion / setNeedUpdate state transition failed.
29
+ constexpr const char* kSwitchVersionFailed = "SWITCH_VERSION_FAILED";
30
+ // markSuccess state transition failed.
31
+ constexpr const char* kMarkSuccessFailed = "MARK_SUCCESS_FAILED";
32
+ // reloadUpdate / restartApp failed.
33
+ constexpr const char* kRestartFailed = "RESTART_FAILED";
34
+ // Stored or provided hash info is not a valid JSON object.
35
+ constexpr const char* kInvalidHashInfo = "INVALID_HASH_INFO";
36
+ // The method is not supported on this platform (e.g. downloadAndInstallApk
37
+ // outside Android).
38
+ constexpr const char* kUnsupportedPlatform = "UNSUPPORTED_PLATFORM";
39
+
40
+ } // namespace error_codes
41
+ } // namespace pushy
42
+
43
+ #endif // PUSHY_PATCH_CORE_ERROR_CODES_H_
@@ -468,13 +468,22 @@ export class UpdateContext {
468
468
  public getBundleUrl() {
469
469
  UpdateContext.isUsingBundleUrl = true;
470
470
  this.trace('getBundleUrl:enter');
471
+ const stateBeforeLaunch = this.getStateSnapshot();
471
472
  const launchState = NativePatchCore.runStateCore(
472
473
  STATE_OP_RESOLVE_LAUNCH,
473
- this.getStateSnapshot(),
474
+ stateBeforeLaunch,
474
475
  '',
475
476
  UpdateContext.ignoreRollback,
476
477
  true,
477
478
  );
479
+ if (launchState.didRollback) {
480
+ // The crash-protection rollback: the new version never called
481
+ // markSuccess. Keep this visible in release logs.
482
+ console.error(
483
+ `Version ${stateBeforeLaunch.currentVersion} was not marked as successful,` +
484
+ ` rolled back to ${launchState.currentVersion}`,
485
+ );
486
+ }
478
487
  if (launchState.didRollback || launchState.consumedFirstTime) {
479
488
  this.persistState(launchState, {
480
489
  markFirstLoadMarker: launchState.consumedFirstTime,
@@ -490,7 +499,12 @@ export class UpdateContext {
490
499
  );
491
500
 
492
501
  let version = launchState.loadVersion || '';
493
- while (version) {
502
+ // Guard the rollback chain against cycles: a corrupted state returning an
503
+ // already-visited version would otherwise spin this loop forever during
504
+ // startup (Android has the same guard).
505
+ const visitedVersions = new Set<string>();
506
+ while (version && !visitedVersions.has(version)) {
507
+ visitedVersions.add(version);
494
508
  const bundleFile = this.getBundlePath(version);
495
509
  try {
496
510
  if (!fileIo.accessSync(bundleFile)) {
@@ -514,7 +528,11 @@ export class UpdateContext {
514
528
  }
515
529
 
516
530
  private rollBack(): string {
531
+ const stateBefore = this.getStateSnapshot();
517
532
  const nextState = this.runStateOperation(STATE_OP_ROLLBACK);
533
+ console.error(
534
+ `Rolling back version ${stateBefore.currentVersion} to ${nextState.currentVersion}`,
535
+ );
518
536
  return nextState.currentVersion || '';
519
537
  }
520
538
 
package/harmony/pushy.har CHANGED
Binary file
@@ -2,6 +2,7 @@
2
2
  #import "RCTPushyDownloader.h"
3
3
  #import "ZipArchive.h"
4
4
  #include "../../cpp/patch_core/archive_patch_core.h"
5
+ #include "../../cpp/patch_core/error_codes.h"
5
6
  #include "../../cpp/patch_core/patch_core.h"
6
7
  #include "../../cpp/patch_core/state_core.h"
7
8
 
@@ -37,9 +38,15 @@ static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs";
37
38
  static NSString * const SOURCE_PATCH_NAME = @"__diff.json";
38
39
  static NSString * const BUNDLE_PATCH_NAME = @"index.bundlejs.patch";
39
40
 
40
- // error def
41
+ // error def — messages are human-readable; the stable cross-platform codes
42
+ // live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey.
41
43
  static NSString * const ERROR_OPTIONS = @"options error";
42
44
  static NSString * const ERROR_FILE_OPERATION = @"file operation error";
45
+ static NSString * const PushyErrorCodeKey = @"PushyErrorCode";
46
+
47
+ static NSString *PushyCode(const char *code) {
48
+ return [NSString stringWithUTF8String:code];
49
+ }
43
50
 
44
51
  // event def
45
52
  static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress";
@@ -80,7 +87,10 @@ static std::string PushyToStdString(NSString *value) {
80
87
  static NSError *PushyNSErrorFromStatus(const pushy::patch::Status &status) {
81
88
  return [NSError errorWithDomain:PushyErrorDomain
82
89
  code:-1
83
- userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()] }];
90
+ userInfo:@{
91
+ NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()],
92
+ PushyErrorCodeKey: PushyCode(pushy::error_codes::kPatchFailed),
93
+ }];
84
94
  }
85
95
 
86
96
  static NSUserDefaults *PushyDefaults(void) {
@@ -118,21 +128,24 @@ static BOOL PushyStringIsBlank(NSString *value) {
118
128
  }
119
129
 
120
130
  static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
121
- reject([NSString stringWithFormat:@"%ld", (long)error.code], error.localizedDescription, error);
131
+ // Prefer the stable cross-platform code (error_codes.h); fall back to the
132
+ // numeric NSError code for system errors that were not classified.
133
+ NSString *code = error.userInfo[PushyErrorCodeKey];
134
+ if (code == nil) {
135
+ code = [NSString stringWithFormat:@"%ld", (long)error.code];
136
+ }
137
+ reject(code, error.localizedDescription, error);
122
138
  }
123
139
 
124
- static NSError *PushyErrorWithMessage(NSString *message) {
140
+ static NSError *PushyErrorWithCode(const char *code, NSString *message) {
125
141
  return [NSError errorWithDomain:PushyErrorDomain
126
142
  code:-1
127
143
  userInfo:@{
128
144
  NSLocalizedDescriptionKey: message ?: @"unknown error",
145
+ PushyErrorCodeKey: PushyCode(code),
129
146
  }];
130
147
  }
131
148
 
132
- static void PushyRejectMessage(RCTPromiseRejectBlock reject, NSString *message) {
133
- PushyRejectError(reject, PushyErrorWithMessage(message));
134
- }
135
-
136
149
  static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json) {
137
150
  pushy::patch::PatchManifest manifest;
138
151
 
@@ -257,6 +270,7 @@ RCT_EXPORT_MODULE(RCTPushy);
257
270
  }
258
271
 
259
272
  if (!state.current_version.empty()) {
273
+ std::string const versionBeforeLaunch = state.current_version;
260
274
  pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(
261
275
  state,
262
276
  ignoreRollback.load(),
@@ -264,6 +278,13 @@ RCT_EXPORT_MODULE(RCTPushy);
264
278
  );
265
279
  state = decision.state;
266
280
 
281
+ if (decision.did_rollback) {
282
+ // The crash-protection rollback: the new version never called
283
+ // markSuccess. Keep this visible in release logs.
284
+ RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@",
285
+ PushyFromStdString(versionBeforeLaunch),
286
+ PushyFromStdString(state.current_version));
287
+ }
267
288
  if (decision.did_rollback || decision.consumed_first_time) {
268
289
  PushyApplyStateToDefaults(defaults, state);
269
290
  }
@@ -275,13 +296,18 @@ RCT_EXPORT_MODULE(RCTPushy);
275
296
 
276
297
  NSString *loadVersion = PushyFromStdString(decision.load_version);
277
298
  NSString *downloadDir = [RCTPushy downloadDir];
278
- while (loadVersion.length) {
299
+ // Guard the rollback chain against cycles: a corrupted state
300
+ // returning an already-visited version would otherwise spin this
301
+ // loop forever during startup (Android has the same guard).
302
+ NSMutableSet<NSString *> *visitedVersions = [NSMutableSet set];
303
+ while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) {
304
+ [visitedVersions addObject:loadVersion];
279
305
  NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME];
280
306
  if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
281
307
  resolvedURL = [NSURL fileURLWithPath:bundlePath];
282
308
  return;
283
309
  } else {
284
- RCTLogError(@"RCTPushy -- bundle version %@ not found", loadVersion);
310
+ RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion);
285
311
  state = pushy::state::Rollback(state);
286
312
  PushyApplyStateToDefaults(defaults, state);
287
313
  loadVersion = PushyFromStdString(state.current_version);
@@ -359,7 +385,7 @@ RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)res
359
385
  rejecter:(RCTPromiseRejectBlock)reject)
360
386
  {
361
387
  if (PushyStringIsBlank(uuid)) {
362
- PushyRejectError(reject, PushyErrorWithMessage(ERROR_OPTIONS));
388
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
363
389
  return;
364
390
  }
365
391
 
@@ -373,7 +399,7 @@ RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
373
399
  rejecter:(RCTPromiseRejectBlock)reject)
374
400
  {
375
401
  if (PushyStringIsBlank(hash) || PushyStringIsBlank(value)) {
376
- PushyRejectMessage(reject, ERROR_OPTIONS);
402
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
377
403
  return;
378
404
  }
379
405
 
@@ -386,7 +412,9 @@ RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
386
412
 
387
413
  resolve(@true);
388
414
  } else {
389
- PushyRejectError(reject, error ?: PushyErrorWithMessage(@"json格式校验报错"));
415
+ PushyRejectError(reject, PushyErrorWithCode(
416
+ pushy::error_codes::kInvalidHashInfo,
417
+ error != nil ? error.localizedDescription : @"invalid json string"));
390
418
  }
391
419
  }
392
420
 
@@ -421,6 +449,15 @@ RCT_EXPORT_METHOD(downloadPatchFromPpk:(NSDictionary *)options
421
449
  [self downloadUpdate:PushyTypePatchFromPpk options:options resolver:resolve rejecter:reject];
422
450
  }
423
451
 
452
+ RCT_EXPORT_METHOD(downloadAndInstallApk:(NSDictionary *)options
453
+ resolver:(RCTPromiseResolveBlock)resolve
454
+ rejecter:(RCTPromiseRejectBlock)reject)
455
+ {
456
+ PushyRejectError(reject, PushyErrorWithCode(
457
+ pushy::error_codes::kUnsupportedPlatform,
458
+ @"downloadAndInstallApk is only supported on Android"));
459
+ }
460
+
424
461
  RCT_EXPORT_METHOD(setNeedUpdate:(NSDictionary *)options
425
462
  resolver:(RCTPromiseResolveBlock)resolve
426
463
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -502,6 +539,12 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
502
539
  {
503
540
  [self performUpdate:type options:options callback:^(NSError *error) {
504
541
  if (error != nil) {
542
+ if (error.userInfo[PushyErrorCodeKey] == nil) {
543
+ // Unclassified (system/network) errors from the download
544
+ // pipeline; keep the original message.
545
+ error = PushyErrorWithCode(pushy::error_codes::kDownloadFailed,
546
+ error.localizedDescription);
547
+ }
505
548
  PushyRejectError(reject, error);
506
549
  return;
507
550
  }
@@ -527,24 +570,38 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
527
570
  NSString *hash = PushyOptionString(options, @"hash");
528
571
 
529
572
  if (PushyStringIsBlank(updateUrl) || PushyStringIsBlank(hash)) {
530
- callback(PushyErrorWithMessage(ERROR_OPTIONS));
573
+ callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
531
574
  return;
532
575
  }
533
576
  NSString *originHash = PushyOptionString(options, @"originHash");
534
577
  if (type == PushyTypePatchFromPpk && PushyStringIsBlank(originHash)) {
535
- callback(PushyErrorWithMessage(ERROR_OPTIONS));
578
+ callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
536
579
  return;
537
580
  }
538
581
 
539
582
  NSString *dir = [RCTPushy downloadDir];
540
583
  BOOL success = [self ensureDirectoryExistsAtPath:dir];
541
584
  if (!success) {
542
- callback(PushyErrorWithMessage(ERROR_FILE_OPERATION));
585
+ callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION));
543
586
  return;
544
587
  }
545
588
 
546
589
  NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]];
547
590
 
591
+ // On failure, remove the partial version directory like Android/Harmony
592
+ // do: a half-unzipped/half-patched dir leaks disk and could later be
593
+ // mistaken for a complete version. hash is validated non-blank above, so
594
+ // this can never resolve to the download root itself.
595
+ NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
596
+ void (^completion)(NSError *) = ^(NSError *error) {
597
+ if (error != nil) {
598
+ dispatch_async(self->_fileQueue, ^{
599
+ [[NSFileManager defaultManager] removeItemAtPath:unzipDir error:nil];
600
+ });
601
+ }
602
+ callback(error);
603
+ };
604
+
548
605
  RCTLogInfo(@"RCTPushy -- download file %@", updateUrl);
549
606
  [RCTPushyDownloader download:updateUrl savePath:zipFilePath progressHandler:^(long long receivedBytes, long long totalBytes) {
550
607
  if (self->hasListeners) {
@@ -556,14 +613,14 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
556
613
  }
557
614
  } completionHandler:^(NSString *path, NSError *error) {
558
615
  if (error != nil) {
559
- callback(error);
616
+ completion(error);
560
617
  return;
561
618
  }
562
619
  [self unzipDownloadedPackage:zipFilePath
563
620
  hash:hash
564
621
  type:type
565
622
  originHash:originHash
566
- callback:callback];
623
+ callback:completion];
567
624
  }];
568
625
  }
569
626
 
@@ -629,18 +686,21 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
629
686
  NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
630
687
  NSData *data = [NSData dataWithContentsOfFile:sourcePatch];
631
688
  if (data == nil) {
632
- callback(PushyErrorWithMessage(@"missing patch manifest"));
689
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"missing patch manifest"));
633
690
  return;
634
691
  }
635
692
 
636
693
  NSError *error = nil;
637
694
  id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error];
638
695
  if (error != nil) {
639
- callback(error);
696
+ // Classify as a patch failure like the sibling manifest branches;
697
+ // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the
698
+ // downloadUpdate fallback even though the download itself succeeded.
699
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, error.localizedDescription));
640
700
  return;
641
701
  }
642
702
  if (![jsonObject isKindOfClass:[NSDictionary class]]) {
643
- callback(PushyErrorWithMessage(@"invalid patch manifest"));
703
+ callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"invalid patch manifest"));
644
704
  return;
645
705
  }
646
706
  NSDictionary *json = (NSDictionary *)jsonObject;
@@ -695,7 +755,7 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
695
755
  {
696
756
  if (PushyStringIsBlank(hash)) {
697
757
  if (error != NULL) {
698
- *error = PushyErrorWithMessage(ERROR_OPTIONS);
758
+ *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
699
759
  }
700
760
  return NO;
701
761
  }
@@ -758,7 +818,7 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
758
818
 
759
819
  NSError *unzipError = error;
760
820
  if (!succeeded && unzipError == nil) {
761
- unzipError = PushyErrorWithMessage(@"unzip failed");
821
+ unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"unzip failed");
762
822
  }
763
823
  completionHandler(unzipError);
764
824
  }];
@@ -113,7 +113,8 @@ didFinishDownloadingToURL:(NSURL *)location
113
113
  // before touching savePath so an existing valid package is not destroyed.
114
114
  NSURLResponse *response = downloadTask.response;
115
115
  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
116
- NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode;
116
+ NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
117
+ NSInteger statusCode = httpResponse.statusCode;
117
118
  if (statusCode < 200 || statusCode >= 300) {
118
119
  self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain
119
120
  code:statusCode
@@ -122,6 +123,26 @@ didFinishDownloadingToURL:(NSURL *)location
122
123
  }];
123
124
  return;
124
125
  }
126
+
127
+ // Reject truncated transfers like Android/Harmony do. Skip the check
128
+ // for encoded responses (e.g. gzip): NSURLSession decompresses them
129
+ // transparently, so the on-disk size legitimately differs from the
130
+ // Content-Length of the encoded body.
131
+ NSString *contentEncoding = [httpResponse.allHeaderFields[@"Content-Encoding"] lowercaseString];
132
+ BOOL isEncodedBody = contentEncoding.length > 0 && ![contentEncoding isEqualToString:@"identity"];
133
+ long long expectedLength = httpResponse.expectedContentLength;
134
+ if (!isEncodedBody && expectedLength > 0) {
135
+ NSNumber *fileSize = [[NSFileManager defaultManager] attributesOfItemAtPath:location.path
136
+ error:nil][NSFileSize];
137
+ if (fileSize != nil && fileSize.longLongValue != expectedLength) {
138
+ self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain
139
+ code:-1
140
+ userInfo:@{
141
+ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"download incomplete: expected %lld bytes, got %lld", expectedLength, fileSize.longLongValue],
142
+ }];
143
+ return;
144
+ }
145
+ }
125
146
  }
126
147
 
127
148
  NSFileManager *fileManager = [NSFileManager defaultManager];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.45.0",
3
+ "version": "10.46.0",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
@@ -15,6 +15,7 @@
15
15
  "test": "bun test src/__tests__",
16
16
  "test:patch-core": "./scripts/test-patch-core.sh",
17
17
  "build:harmony-har": "node scripts/build-harmony-har.js",
18
+ "build:harmony-e2e": "bash Example/e2etest/scripts/build-harmony-e2e.sh",
18
19
  "build:so": "bun submodule && bash scripts/build-android-so.sh",
19
20
  "build:ios-debug": "cd Example/e2etest && bun && detox build --configuration ios.sim.debug",
20
21
  "build:ios-release": "cd Example/e2etest && bun && detox build --configuration ios.sim.release",
package/src/client.ts CHANGED
@@ -39,8 +39,19 @@ 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';
43
44
 
45
+ /**
46
+ * Receives every error the client reports, alongside the report event type.
47
+ * The UpdateProvider subscribes to surface errors as lastError/Alert; user
48
+ * code can subscribe too. Listeners run before any throwError rethrow.
49
+ */
50
+ export type UpdateErrorListener = (
51
+ error: UpdateError,
52
+ eventType: EventType,
53
+ ) => void;
54
+
44
55
  const SERVER_PRESETS = {
45
56
  // cn
46
57
  Pushy: {
@@ -142,7 +153,10 @@ export class Pushy {
142
153
 
143
154
  if (Platform.OS === 'ios' || Platform.OS === 'android') {
144
155
  if (!options.appKey) {
145
- throw Error(i18n.t('error_appkey_required'));
156
+ throw new UpdateError(
157
+ i18n.t('error_appkey_required'),
158
+ 'APPKEY_REQUIRED',
159
+ );
146
160
  }
147
161
  }
148
162
 
@@ -184,13 +198,15 @@ export class Pushy {
184
198
  report = async ({
185
199
  type,
186
200
  message = '',
201
+ code,
187
202
  data = {},
188
203
  }: {
189
204
  type: EventType;
190
205
  message?: string;
206
+ code?: UpdateErrorCode;
191
207
  data?: Record<string, string | number>;
192
208
  }) => {
193
- log(`${type} ${message}`);
209
+ log(`${type} ${code ? `[${code}] ` : ''}${message}`);
194
210
  if (this.options.logger === noop) {
195
211
  // Wait briefly for a logger to arrive via setOptions (e.g. the rollback
196
212
  // report fires in the constructor before the user configures one), but
@@ -214,6 +230,7 @@ export class Pushy {
214
230
  overridePackageVersion,
215
231
  buildTime,
216
232
  message,
233
+ code,
217
234
  ...currentVersionInfo,
218
235
  ...data,
219
236
  },
@@ -230,6 +247,39 @@ export class Pushy {
230
247
  throw e;
231
248
  }
232
249
  };
250
+ private errorListeners = new Set<UpdateErrorListener>();
251
+ /**
252
+ * Subscribe to every error the client reports (regardless of throwError).
253
+ * Returns an unsubscribe function.
254
+ */
255
+ onError = (listener: UpdateErrorListener) => {
256
+ this.errorListeners.add(listener);
257
+ return () => {
258
+ this.errorListeners.delete(listener);
259
+ };
260
+ };
261
+ /**
262
+ * Single exit point for errors: reports to the logger (with the stable
263
+ * code) and notifies onError listeners. Whether to also throw stays with
264
+ * the caller (throwIfEnabled or an unconditional rethrow).
265
+ */
266
+ private emitError = (
267
+ error: UpdateError,
268
+ type: EventType,
269
+ {
270
+ message = error.message,
271
+ data,
272
+ }: { message?: string; data?: Record<string, string | number> } = {},
273
+ ) => {
274
+ this.report({ type, message, code: error.code, data });
275
+ for (const listener of this.errorListeners) {
276
+ try {
277
+ listener(error, type);
278
+ } catch (e: any) {
279
+ log('onError listener error:', e?.message || e);
280
+ }
281
+ }
282
+ };
233
283
  notifyAfterCheckUpdate = (state: UpdateCheckState) => {
234
284
  const { afterCheckUpdate } = this.options;
235
285
  if (!afterCheckUpdate) {
@@ -302,11 +352,13 @@ export class Pushy {
302
352
 
303
353
  if (!resp.ok) {
304
354
  const respText = await resp.text();
305
- throw Error(
355
+ throw new UpdateError(
306
356
  this.t('error_http_status', {
307
357
  status: resp.status,
308
358
  statusText: respText,
309
359
  }),
360
+ 'HTTP_STATUS',
361
+ { extra: { status: resp.status } },
310
362
  );
311
363
  }
312
364
 
@@ -348,7 +400,13 @@ export class Pushy {
348
400
  if (sharedState.marked || __DEV__ || !isFirstTime) {
349
401
  return;
350
402
  }
351
- await Promise.resolve(PushyModule.markSuccess());
403
+ try {
404
+ await Promise.resolve(PushyModule.markSuccess());
405
+ } catch (e) {
406
+ const err = toUpdateError(e, 'MARK_SUCCESS_FAILED');
407
+ this.emitError(err, 'errorMarkSuccess');
408
+ throw err;
409
+ }
352
410
  sharedState.marked = true;
353
411
  this.report({ type: 'markSuccess' });
354
412
  };
@@ -366,7 +424,11 @@ export class Pushy {
366
424
  }
367
425
  } catch (e) {
368
426
  sharedState.applyingUpdate = false;
369
- throw e;
427
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
428
+ this.emitError(err, 'errorSwitchVersion', {
429
+ data: { newVersion: hash },
430
+ });
431
+ throw err;
370
432
  }
371
433
  try {
372
434
  return await PushyModule.reloadUpdate({ hash });
@@ -374,7 +436,11 @@ export class Pushy {
374
436
  // reloadUpdate can reject (e.g. bundle missing); reset the flag so a
375
437
  // later retry is not permanently blocked by a stuck applyingUpdate.
376
438
  sharedState.applyingUpdate = false;
377
- throw e;
439
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
440
+ this.emitError(err, 'errorSwitchVersion', {
441
+ data: { newVersion: hash },
442
+ });
443
+ throw err;
378
444
  }
379
445
  }
380
446
  };
@@ -385,7 +451,15 @@ export class Pushy {
385
451
  }
386
452
  if (assertHash(hash)) {
387
453
  log(`switchVersionLater: ${hash}`);
388
- return PushyModule.setNeedUpdate({ hash });
454
+ try {
455
+ return await PushyModule.setNeedUpdate({ hash });
456
+ } catch (e) {
457
+ const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
458
+ this.emitError(err, 'errorSwitchVersion', {
459
+ data: { newVersion: hash },
460
+ });
461
+ throw err;
462
+ }
389
463
  }
390
464
  };
391
465
  checkUpdate = async (extra?: Record<string, any>) => {
@@ -456,14 +530,12 @@ export class Pushy {
456
530
  return result;
457
531
  } catch (e: any) {
458
532
  this.lastRespJson = previousRespJson;
459
- const errorMessage =
460
- e?.message || this.t('error_cannot_connect_server');
461
- this.report({
462
- type: 'errorChecking',
463
- message: errorMessage,
533
+ const err = toUpdateError(e, 'CHECK_FAILED');
534
+ this.emitError(err, 'errorChecking', {
535
+ message: err.message || this.t('error_cannot_connect_server'),
464
536
  });
465
- this.notifyAfterCheckUpdate({ status: 'error', error: e });
466
- this.throwIfEnabled(e);
537
+ this.notifyAfterCheckUpdate({ status: 'error', error: err });
538
+ this.throwIfEnabled(err);
467
539
  // Fall back to the previous successful response if we have one; otherwise
468
540
  // return undefined so callers can distinguish "check failed" from a real
469
541
  // empty result and avoid overwriting the last good updateInfo.
@@ -669,14 +741,22 @@ export class Pushy {
669
741
  delete sharedState.progressHandlers[hash];
670
742
  }
671
743
  if (!succeeded) {
744
+ const message = errorMessages.join(';');
745
+ if (lastError) {
746
+ const err = toUpdateError(lastError, 'DOWNLOAD_FAILED');
747
+ this.emitError(err, 'errorUpdate', {
748
+ message,
749
+ data: { newVersion: hash },
750
+ });
751
+ throw err;
752
+ }
753
+ // No download URL was even attempted (e.g. dev without a full URL):
754
+ // report for diagnostics but there is no error object to surface.
672
755
  this.report({
673
756
  type: 'errorUpdate',
674
757
  data: { newVersion: hash },
675
- message: errorMessages.join(';'),
758
+ message,
676
759
  });
677
- if (lastError) {
678
- throw lastError;
679
- }
680
760
  return;
681
761
  } else {
682
762
  const duration = Date.now() - patchStartTime;
@@ -717,8 +797,12 @@ export class Pushy {
717
797
  return;
718
798
  }
719
799
  if (sharedState.apkStatus === 'downloaded') {
720
- this.report({ type: 'errorInstallApk' });
721
- this.throwIfEnabled(Error('errorInstallApk'));
800
+ const err = new UpdateError(
801
+ this.t('error_apk_pending_install'),
802
+ 'APK_INSTALL_PENDING',
803
+ );
804
+ this.emitError(err, 'errorInstallApk');
805
+ this.throwIfEnabled(err);
722
806
  return;
723
807
  }
724
808
  if (Platform.Version <= 23) {
@@ -727,13 +811,18 @@ export class Pushy {
727
811
  PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
728
812
  );
729
813
  if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
730
- this.report({ type: 'rejectStoragePermission' });
731
- this.throwIfEnabled(Error('rejectStoragePermission'));
814
+ const err = new UpdateError(
815
+ this.t('error_storage_permission_rejected'),
816
+ 'STORAGE_PERMISSION_REJECTED',
817
+ );
818
+ this.emitError(err, 'rejectStoragePermission');
819
+ this.throwIfEnabled(err);
732
820
  return;
733
821
  }
734
822
  } catch (e: any) {
735
- this.report({ type: 'errorStoragePermission' });
736
- this.throwIfEnabled(e);
823
+ const err = toUpdateError(e, 'STORAGE_PERMISSION_ERROR');
824
+ this.emitError(err, 'errorStoragePermission');
825
+ this.throwIfEnabled(err);
737
826
  return;
738
827
  }
739
828
  }
@@ -761,10 +850,14 @@ export class Pushy {
761
850
  hash: progressKey,
762
851
  });
763
852
  sharedState.apkStatus = 'downloaded';
764
- } catch {
853
+ } catch (e) {
765
854
  sharedState.apkStatus = null;
766
- this.report({ type: 'errorDownloadAndInstallApk' });
767
- this.throwIfEnabled(Error('errorDownloadAndInstallApk'));
855
+ // Keep the native error (message/stack) instead of discarding it.
856
+ const err = toUpdateError(e, 'APK_DOWNLOAD_FAILED');
857
+ this.emitError(err, 'errorDownloadAndInstallApk', {
858
+ message: err.message || this.t('error_apk_download_failed'),
859
+ });
860
+ this.throwIfEnabled(err);
768
861
  } finally {
769
862
  if (sharedState.progressHandlers[progressKey]) {
770
863
  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
  },
package/src/type.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { UpdateErrorCode } from './error';
2
+
1
3
  export interface VersionInfo {
2
4
  name: string;
3
5
  hash: string;
@@ -52,6 +54,8 @@ export type EventType =
52
54
  | 'downloadSuccess'
53
55
  | 'errorUpdate'
54
56
  | 'markSuccess'
57
+ | 'errorMarkSuccess'
58
+ | 'errorSwitchVersion'
55
59
  | 'downloadingApk'
56
60
  | 'rejectStoragePermission'
57
61
  | 'errorStoragePermission'
@@ -59,6 +63,8 @@ export type EventType =
59
63
  | 'errorInstallApk';
60
64
 
61
65
  export interface EventData {
66
+ /** Stable machine-readable error code; present on error events */
67
+ code?: UpdateErrorCode;
62
68
  currentVersion: string;
63
69
  cInfo: {
64
70
  rnu: string;