react-native-update 10.50.0 → 10.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  2. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  3. package/README-CN.md +14 -1
  4. package/README.md +14 -1
  5. package/android/jni/Android.mk +5 -1
  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/src/main/java/cn/reactnative/modules/update/DownloadTask.java +76 -21
  11. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  12. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +160 -42
  15. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +44 -0
  16. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  17. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  18. package/cpp/update_flow_core/flow_json.cpp +522 -0
  19. package/cpp/update_flow_core/flow_json.h +103 -0
  20. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  21. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  22. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  23. package/cpp/update_flow_core/update_flow_core.h +77 -0
  24. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  25. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  26. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -1
  27. package/harmony/pushy/src/main/ets/DownloadTask.ts +48 -9
  28. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  29. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  30. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  31. package/harmony/pushy/src/main/ets/NativePatchCore.ts +14 -0
  32. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  33. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  34. package/harmony/pushy/src/main/ets/UpdateContext.ts +148 -69
  35. package/harmony/pushy.har +0 -0
  36. package/ios/RCTPushy/RCTPushy.mm +779 -61
  37. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  38. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  39. package/package.json +3 -1
  40. package/react-native-update.podspec +2 -0
  41. package/src/NativePushy.ts +14 -0
  42. package/src/client.ts +341 -118
  43. package/src/endpoint.ts +7 -29
  44. package/src/provider.tsx +38 -2
  45. package/src/type.ts +26 -0
  46. package/src/updateFlowCore.ts +320 -0
  47. package/src/utils.ts +1 -12
  48. package/src/isInRollout.ts +0 -87
  49. package/src/resolveCheckResult.ts +0 -30
@@ -7,6 +7,10 @@
7
7
  #include "../../cpp/patch_core/error_codes.h"
8
8
  #include "../../cpp/patch_core/patch_core.h"
9
9
  #include "../../cpp/patch_core/state_core.h"
10
+ #include "../../cpp/update_flow_core/flow_json.h"
11
+ #include "../../cpp/update_flow_core/update_flow_core.h"
12
+
13
+ #import <UIKit/UIKit.h>
10
14
 
11
15
  #if __has_include("RCTReloadCommand.h")
12
16
  #import "RCTReloadCommand.h"
@@ -37,12 +41,19 @@ static NSString *const KeyPackageUpdatedMarked = @"REACTNATIVECN_PUSHY_ISPACKAGE
37
41
  // installed binary (packageVersion + embedded bundle size + mtime). Recomputed
38
42
  // only when the key changes, i.e. once per install.
39
43
  static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY";
44
+ // Raw JSON persisted by JS (syncNativeConfig) for the native cold-start
45
+ // update check; parsed on read by the orchestrator. Absent = check disabled.
46
+ static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY";
47
+ // Raw response cache written by the native cold-start check for the JS side
48
+ // to reuse (§10.3), scoped to the request and config that produced it.
49
+ static NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY";
40
50
  static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
41
51
 
42
52
  // file def
43
53
  static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs";
44
54
  static NSString * const SOURCE_PATCH_NAME = @"__diff.json";
45
55
  static NSString * const BUNDLE_PATCH_NAME = @"index.bundlejs.patch";
56
+ static NSString * const VERSION_COMPLETE_FILE_NAME = @".pushy-complete";
46
57
 
47
58
  // error def — messages are human-readable; the stable cross-platform codes
48
59
  // live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey.
@@ -60,6 +71,26 @@ static NSString * const PARAM_PROGRESS_HASH = @"hash";
60
71
  static NSString * const PARAM_PROGRESS_RECEIVED = @"received";
61
72
  static NSString * const PARAM_PROGRESS_TOTAL = @"total";
62
73
 
74
+ static NSTimeInterval PushyMonotonicNow(void) {
75
+ return [NSProcessInfo processInfo].systemUptime;
76
+ }
77
+
78
+ static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir) {
79
+ NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
80
+ NSString *markerPath = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
81
+ return [[NSFileManager defaultManager] fileExistsAtPath:bundlePath]
82
+ && [[NSFileManager defaultManager] fileExistsAtPath:markerPath];
83
+ }
84
+
85
+ static NSError *PushyDownloadDeadlineExpiredError(void) {
86
+ return [NSError errorWithDomain:PushyErrorDomain
87
+ code:-1
88
+ userInfo:@{
89
+ NSLocalizedDescriptionKey: @"download deadline expired before start",
90
+ PushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed),
91
+ }];
92
+ }
93
+
63
94
 
64
95
  typedef NS_ENUM(NSInteger, PushyType) {
65
96
  PushyTypeFullDownload = 1,
@@ -69,6 +100,12 @@ typedef NS_ENUM(NSInteger, PushyType) {
69
100
  };
70
101
 
71
102
  static std::atomic<bool> ignoreRollback{false};
103
+ // Bumped by resetToPackagedBundle. The cold-start check runs for minutes and
104
+ // may already hold a decision when the app resets to the packaged bundle; it
105
+ // samples this counter and abandons activation (and its response cache) when
106
+ // the value moved, so an in-flight rescue can never resurrect the version the
107
+ // app just reset away from.
108
+ static std::atomic<uint64_t> pushyResetGeneration{0};
72
109
  // The version whose bundle this process actually loaded (resolved in
73
110
  // +bundleURL). resetToPackagedBundle must not delete its directory: update
74
111
  // assets (images/fonts) are read from it on demand at runtime, so wiping it
@@ -76,6 +113,126 @@ static std::atomic<bool> ignoreRollback{false};
76
113
  // has not loaded yet. Guarded by the state lock.
77
114
  static NSString *pushyLaunchVersion = nil;
78
115
 
116
+ // JS and the bridge-free cold-start engine use different RCTPushy instances,
117
+ // but they must still share one download per target hash. Without this
118
+ // process-wide registry two NSURLSessionDownloadTasks race over the same
119
+ // archive path and the later unzip can delete the first task's valid output.
120
+ static NSMutableDictionary<NSString *, NSMutableDictionary *> *PushyInFlightDownloads(void) {
121
+ static NSMutableDictionary<NSString *, NSMutableDictionary *> *downloads;
122
+ static dispatch_once_t onceToken;
123
+ dispatch_once(&onceToken, ^{
124
+ downloads = [NSMutableDictionary dictionary];
125
+ });
126
+ return downloads;
127
+ }
128
+
129
+ typedef NS_ENUM(NSInteger, PushyDownloadRegistration) {
130
+ PushyDownloadRegistrationOwner,
131
+ PushyDownloadRegistrationJoined,
132
+ PushyDownloadRegistrationDeferred,
133
+ };
134
+
135
+ static PushyDownloadRegistration PushyRegisterDownload(
136
+ NSString *hash,
137
+ NSInteger type,
138
+ NSTimeInterval deadlineUptime,
139
+ void (^callback)(NSError *),
140
+ void (^progress)(long long, long long),
141
+ void (^deferredStart)(void)
142
+ ) {
143
+ NSMutableDictionary *downloads = PushyInFlightDownloads();
144
+ @synchronized (downloads) {
145
+ NSMutableDictionary *entry = downloads[hash];
146
+ if (entry != nil) {
147
+ if ([entry[@"type"] integerValue] == type) {
148
+ NSTimeInterval ownerDeadline = [entry[@"deadlineUptime"] doubleValue];
149
+ // A caller with substantially more time must not inherit an
150
+ // owner's nearly-exhausted timeout: it observes the current
151
+ // transfer and restarts after the owner settles (the
152
+ // completion-marker preflight makes a successful owner free).
153
+ // The comparison is on remaining budget, not on the absolute
154
+ // deadline: a JS caller always computes now+600 a few seconds
155
+ // after the owner did, so a strict `>` would defer every
156
+ // second caller and turn the shared download back into
157
+ // serialized re-downloads. Only a genuinely starved owner
158
+ // (less than half the newcomer's budget left) defers.
159
+ const NSTimeInterval now = PushyMonotonicNow();
160
+ if (2 * (ownerDeadline - now) < (deadlineUptime - now)) {
161
+ if (progress != nil) {
162
+ [entry[@"progress"] addObject:[progress copy]];
163
+ }
164
+ [entry[@"deferred"] addObject:[deferredStart copy]];
165
+ return PushyDownloadRegistrationDeferred;
166
+ }
167
+ [entry[@"callbacks"] addObject:[callback copy]];
168
+ if (progress != nil) {
169
+ [entry[@"progress"] addObject:[progress copy]];
170
+ }
171
+ return PushyDownloadRegistrationJoined;
172
+ }
173
+ // A diff failure must not settle a joined full request. Queue the
174
+ // different artifact type behind the owner; once restarted it
175
+ // registers normally (and re-checks the completion marker).
176
+ if (progress != nil) {
177
+ // The artifact type differs, but it still installs the same
178
+ // target hash. Keep the waiting JS UI moving while its own
179
+ // transfer is queued behind the current owner.
180
+ [entry[@"progress"] addObject:[progress copy]];
181
+ }
182
+ [entry[@"deferred"] addObject:[deferredStart copy]];
183
+ return PushyDownloadRegistrationDeferred;
184
+ }
185
+ NSMutableArray *progressHandlers = [NSMutableArray array];
186
+ if (progress != nil) {
187
+ [progressHandlers addObject:[progress copy]];
188
+ }
189
+ downloads[hash] = [@{
190
+ @"type": @(type),
191
+ @"deadlineUptime": @(deadlineUptime),
192
+ @"callbacks": [NSMutableArray arrayWithObject:[callback copy]],
193
+ @"progress": progressHandlers,
194
+ @"deferred": [NSMutableArray array],
195
+ } mutableCopy];
196
+ return PushyDownloadRegistrationOwner;
197
+ }
198
+ }
199
+
200
+ static void PushyReportDownloadProgress(
201
+ NSString *hash, long long received, long long total
202
+ ) {
203
+ NSMutableDictionary *downloads = PushyInFlightDownloads();
204
+ NSArray *handlers = nil;
205
+ @synchronized (downloads) {
206
+ handlers = [downloads[hash][@"progress"] copy];
207
+ }
208
+ for (id value in handlers) {
209
+ void (^handler)(long long, long long) =
210
+ (void (^)(long long, long long))value;
211
+ handler(received, total);
212
+ }
213
+ }
214
+
215
+ static void PushyFinishDownload(NSString *hash, NSError *error) {
216
+ NSMutableDictionary *downloads = PushyInFlightDownloads();
217
+ NSArray *callbacks = nil;
218
+ NSArray *deferred = nil;
219
+ @synchronized (downloads) {
220
+ callbacks = [downloads[hash][@"callbacks"] copy];
221
+ deferred = [downloads[hash][@"deferred"] copy];
222
+ [downloads removeObjectForKey:hash];
223
+ }
224
+ // Establish the next (different-type) owner before waking the completed
225
+ // owner's callbacks; otherwise its strategy loop could race the waiter.
226
+ for (id value in deferred) {
227
+ void (^start)(void) = (void (^)(void))value;
228
+ start();
229
+ }
230
+ for (id value in callbacks) {
231
+ void (^callback)(NSError *) = (void (^)(NSError *))value;
232
+ callback(error);
233
+ }
234
+ }
235
+
79
236
  // Serializes every read-modify-write of the persisted update state. The state
80
237
  // machine itself is a pure function (state_core), but callers run on different
81
238
  // threads (main thread bundleURL, module method queue, _fileQueue), so the
@@ -85,8 +242,11 @@ static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT;
85
242
 
86
243
  static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) {
87
244
  os_unfair_lock_lock(&pushyStateLock);
88
- block();
89
- os_unfair_lock_unlock(&pushyStateLock);
245
+ @try {
246
+ block();
247
+ } @finally {
248
+ os_unfair_lock_unlock(&pushyStateLock);
249
+ }
90
250
  }
91
251
 
92
252
  static std::string PushyToStdString(NSString *value) {
@@ -255,6 +415,21 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
255
415
  PushyFromStdString(state.rolled_back_version));
256
416
  }
257
417
 
418
+ // Version switch without acquiring the state lock: the caller must already
419
+ // hold it. Lets the cold-start check commit its whole result (version info,
420
+ // switch, response cache) inside one lock acquisition, so resetToPackagedBundle
421
+ // can never interleave between the generation check and the writes.
422
+ static void PushySwitchVersionLocked(NSString *hash) {
423
+ NSUserDefaults *defaults = PushyDefaults();
424
+ pushy::state::State next = pushy::state::SwitchVersion(
425
+ PushyStateFromDefaults(defaults),
426
+ PushyToStdString(hash)
427
+ );
428
+ PushyApplyStateToDefaults(defaults, next);
429
+ // Re-enable first-load consumption and rollback checks for the newly selected bundle.
430
+ ignoreRollback = false;
431
+ }
432
+
258
433
  @interface RCTPushy ()
259
434
  - (void)downloadUpdate:(PushyType)type
260
435
  options:(NSDictionary *)options
@@ -284,8 +459,62 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
284
459
  - (void)unzipFileAtPath:(NSString *)path
285
460
  toDestination:(NSString *)destination
286
461
  completionHandler:(void (^)(NSError *error))completionHandler;
462
+ + (NSString *)downloadDir;
463
+ + (NSURL *)binaryBundleURL;
464
+ + (NSString *)packageVersion;
465
+ + (NSString *)buildTime;
287
466
  @end
288
467
 
468
+ // Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once
469
+ // per process, a few seconds after launch, entirely independent of the app
470
+ // bundle — this is what lets a bricked hot update be replaced on the next
471
+ // launch. Decisions come from cpp/update_flow_core; this class is IO glue.
472
+ @interface RCTPushyOrchestrator : NSObject
473
+ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion;
474
+ + (void)runOnce:(NSString *)launchRolledBackVersion;
475
+ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
476
+ hashInfo:(NSDictionary *)hashInfoEntry
477
+ activate:(NSString *)hashToActivate
478
+ responseText:(NSString *)responseText
479
+ request:(NSString *)requestBody
480
+ config:(NSString *)configJson
481
+ responseAt:(long long)responseAtSeconds;
482
+ @end
483
+
484
+ // Shared by the getBundleHash RCT method and the native cold-start check.
485
+ // Returns @"" when unknown; blocking (sha256 of the embedded bundle on first
486
+ // call per install, cached afterwards) — call off the main thread.
487
+ static NSString *PushyBundleHashSync(void) {
488
+ NSString *path = [[RCTPushy binaryBundleURL] path];
489
+ if (path == nil) {
490
+ return @"";
491
+ }
492
+ NSDictionary *attributes =
493
+ [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
494
+ if (attributes == nil) {
495
+ return @"";
496
+ }
497
+ NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
498
+ [RCTPushy packageVersion],
499
+ attributes.fileSize,
500
+ [attributes.fileModificationDate timeIntervalSince1970]];
501
+
502
+ NSUserDefaults *defaults = PushyDefaults();
503
+ NSString *cached = [defaults stringForKey:keyBundleHashCache];
504
+ NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
505
+ if ([cached hasPrefix:cachedPrefix]) {
506
+ return [cached substringFromIndex:cachedPrefix.length];
507
+ }
508
+
509
+ NSString *hash = PushyFromStdString(
510
+ pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
511
+ if (hash.length > 0) {
512
+ [defaults setObject:[cachedPrefix stringByAppendingString:hash]
513
+ forKey:keyBundleHashCache];
514
+ }
515
+ return hash;
516
+ }
517
+
289
518
  @implementation RCTPushy {
290
519
  dispatch_queue_t _fileQueue;
291
520
  bool hasListeners;
@@ -296,8 +525,10 @@ RCT_EXPORT_MODULE(RCTPushy);
296
525
  + (NSURL *)bundleURL
297
526
  {
298
527
  __block NSURL *resolvedURL = nil;
299
- PushyWithStateLock(^{
300
- NSUserDefaults *defaults = PushyDefaults();
528
+ __block NSString *launchRolledBackVersion = nil;
529
+ @try {
530
+ PushyWithStateLock(^{
531
+ NSUserDefaults *defaults = PushyDefaults();
301
532
 
302
533
  NSString *curPackageVersion = [RCTPushy packageVersion];
303
534
  NSString *curBuildTime = [RCTPushy buildTime];
@@ -351,7 +582,7 @@ RCT_EXPORT_MODULE(RCTPushy);
351
582
  if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
352
583
  pushyLaunchVersion = loadVersion;
353
584
  resolvedURL = [NSURL fileURLWithPath:bundlePath];
354
- return;
585
+ break;
355
586
  } else {
356
587
  RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion);
357
588
  state = pushy::state::Rollback(state);
@@ -360,9 +591,18 @@ RCT_EXPORT_MODULE(RCTPushy);
360
591
  }
361
592
  }
362
593
  }
363
- });
364
-
365
- return resolvedURL ?: [RCTPushy binaryBundleURL];
594
+ // Capture before constantsToExport consumes this one-shot marker.
595
+ // The delayed native check must never forceBoot the version that this
596
+ // launch just rolled back.
597
+ launchRolledBackVersion = PushyFromStdString(state.rolled_back_version);
598
+ });
599
+ return resolvedURL ?: [RCTPushy binaryBundleURL];
600
+ } @finally {
601
+ // State corruption is exactly when the rescue path matters most. If
602
+ // resolution throws before a snapshot exists, nil safely omits only
603
+ // this launch's rollback guard instead of disabling the check.
604
+ [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion];
605
+ }
366
606
  }
367
607
 
368
608
  + (NSString *) rollback {
@@ -452,6 +692,36 @@ RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)res
452
692
  resolve(@true);
453
693
  }
454
694
 
695
+ RCT_EXPORT_METHOD(syncNativeConfig:(NSString *)config
696
+ resolver:(RCTPromiseResolveBlock)resolve
697
+ rejecter:(RCTPromiseRejectBlock)reject)
698
+ {
699
+ // Provisioning for the native cold-start check (NATIVE_CHECKUPDATE_DESIGN
700
+ // §10.1). Validate at write time: a corrupt config would otherwise
701
+ // silently disable the native check forever with no signal.
702
+ if (PushyStringIsBlank(config)) {
703
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
704
+ return;
705
+ }
706
+ NSData *data = [config dataUsingEncoding:NSUTF8StringEncoding];
707
+ NSError *error = nil;
708
+ id object = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
709
+ if (![object isKindOfClass:[NSDictionary class]]) {
710
+ PushyRejectError(reject, PushyErrorWithCode(
711
+ pushy::error_codes::kInvalidOptions,
712
+ error != nil ? error.localizedDescription : ERROR_OPTIONS));
713
+ return;
714
+ }
715
+ [PushyDefaults() setObject:config forKey:keyNativeConfig];
716
+ resolve(@true);
717
+ }
718
+
719
+ RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve
720
+ rejecter:(RCTPromiseRejectBlock)reject)
721
+ {
722
+ resolve([PushyDefaults() stringForKey:keyNativeCheckCache] ?: @"");
723
+ }
724
+
455
725
  RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
456
726
  value:(NSString *)value resolver:(RCTPromiseResolveBlock)resolve
457
727
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -565,37 +835,7 @@ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
565
835
  resolve(@"");
566
836
  #else
567
837
  dispatch_async(_fileQueue, ^{
568
- NSString *path = [[RCTPushy binaryBundleURL] path];
569
- if (path == nil) {
570
- resolve(@"");
571
- return;
572
- }
573
- NSDictionary *attributes =
574
- [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
575
- if (attributes == nil) {
576
- resolve(@"");
577
- return;
578
- }
579
- NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
580
- [RCTPushy packageVersion],
581
- attributes.fileSize,
582
- [attributes.fileModificationDate timeIntervalSince1970]];
583
-
584
- NSUserDefaults *defaults = PushyDefaults();
585
- NSString *cached = [defaults stringForKey:keyBundleHashCache];
586
- NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
587
- if ([cached hasPrefix:cachedPrefix]) {
588
- resolve([cached substringFromIndex:cachedPrefix.length]);
589
- return;
590
- }
591
-
592
- NSString *hash = PushyFromStdString(
593
- pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
594
- if (hash.length > 0) {
595
- [defaults setObject:[cachedPrefix stringByAppendingString:hash]
596
- forKey:keyBundleHashCache];
597
- }
598
- resolve(hash);
838
+ resolve(PushyBundleHashSync());
599
839
  });
600
840
  #endif
601
841
  }
@@ -633,6 +873,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
633
873
  // for gray release bucketing and must not change on reset.
634
874
  __block NSString *keepVersion = nil;
635
875
  PushyWithStateLock(^{
876
+ // Invalidate any in-flight cold-start round before clearing state, so
877
+ // a round that commits under this same lock afterwards always sees the
878
+ // new generation and drops its result.
879
+ pushyResetGeneration.fetch_add(1);
636
880
  NSUserDefaults *defaults = PushyDefaults();
637
881
  keepVersion = pushyLaunchVersion;
638
882
 
@@ -652,6 +896,9 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
652
896
  }
653
897
  [defaults removeObjectForKey:keyFirstLoadMarked];
654
898
  [defaults removeObjectForKey:KeyPackageUpdatedMarked];
899
+ // A cached response still advertises the version this reset just
900
+ // removed; dropping it stops the JS side from reusing that answer.
901
+ [defaults removeObjectForKey:keyNativeCheckCache];
655
902
  ignoreRollback = false;
656
903
  });
657
904
 
@@ -744,31 +991,85 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
744
991
  return;
745
992
  }
746
993
 
994
+ NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
995
+ if (PushyHasCompletedVersionAtPath(unzipDir)) {
996
+ callback(nil);
997
+ return;
998
+ }
999
+
1000
+ NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600;
1001
+ NSNumber *configuredDeadline = options[@"deadlineUptime"];
1002
+ if ([configuredDeadline isKindOfClass:[NSNumber class]]) {
1003
+ deadlineUptime = configuredDeadline.doubleValue;
1004
+ }
1005
+ if (deadlineUptime <= PushyMonotonicNow()) {
1006
+ callback(PushyDownloadDeadlineExpiredError());
1007
+ return;
1008
+ }
1009
+
1010
+ void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) {
1011
+ if (self->hasListeners) {
1012
+ [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{
1013
+ PARAM_PROGRESS_HASH:hash,
1014
+ PARAM_PROGRESS_RECEIVED:@(receivedBytes),
1015
+ PARAM_PROGRESS_TOTAL:@(totalBytes),
1016
+ }];
1017
+ }
1018
+ };
1019
+ void (^deferredStart)(void) = ^{
1020
+ [self performUpdate:type options:options callback:callback];
1021
+ };
1022
+ PushyDownloadRegistration registration = PushyRegisterDownload(
1023
+ hash, type, deadlineUptime, callback, progress, deferredStart);
1024
+ if (registration != PushyDownloadRegistrationOwner) {
1025
+ RCTLogInfo(
1026
+ @"RCTPushy -- %@ in-flight download for %@",
1027
+ registration == PushyDownloadRegistrationJoined ? @"join" : @"defer",
1028
+ hash);
1029
+ return;
1030
+ }
1031
+
747
1032
  NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]];
748
1033
 
749
1034
  // On failure, remove the partial version directory like Android/Harmony
750
1035
  // do: a half-unzipped/half-patched dir leaks disk and could later be
751
1036
  // mistaken for a complete version. hash is validated non-blank above, so
752
1037
  // this can never resolve to the download root itself.
753
- NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
754
1038
  void (^completion)(NSError *) = ^(NSError *error) {
755
- if (error != nil) {
756
- dispatch_async(self->_fileQueue, ^{
1039
+ // Settle every JS/native waiter only after cleanup or the atomic
1040
+ // completion marker write has run on the process-wide file queue.
1041
+ dispatch_async(self->_fileQueue, ^{
1042
+ NSError *finalError = error;
1043
+ if (finalError == nil) {
1044
+ NSString *marker = [unzipDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
1045
+ NSError *markerError = nil;
1046
+ BOOL marked = [[NSData data] writeToFile:marker
1047
+ options:NSDataWritingAtomic
1048
+ error:&markerError];
1049
+ if (!marked) {
1050
+ finalError = markerError ?: PushyErrorWithCode(
1051
+ pushy::error_codes::kFileOperationFailed,
1052
+ @"failed to mark completed update");
1053
+ }
1054
+ }
1055
+ if (finalError != nil) {
757
1056
  [[NSFileManager defaultManager] removeItemAtPath:unzipDir error:nil];
758
- });
759
- }
760
- callback(error);
1057
+ }
1058
+ PushyFinishDownload(hash, finalError);
1059
+ });
761
1060
  };
762
1061
 
763
1062
  RCTLogInfo(@"RCTPushy -- download file %@", updateUrl);
764
- [RCTPushyDownloader download:updateUrl savePath:zipFilePath progressHandler:^(long long receivedBytes, long long totalBytes) {
765
- if (self->hasListeners) {
766
- [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{
767
- PARAM_PROGRESS_HASH:hash,
768
- PARAM_PROGRESS_RECEIVED:[NSNumber numberWithLongLong:receivedBytes],
769
- PARAM_PROGRESS_TOTAL:[NSNumber numberWithLongLong:totalBytes]
770
- }];
771
- }
1063
+ NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow();
1064
+ if (timeoutSeconds <= 0) {
1065
+ completion(PushyDownloadDeadlineExpiredError());
1066
+ return;
1067
+ }
1068
+ [RCTPushyDownloader download:updateUrl
1069
+ savePath:zipFilePath
1070
+ timeoutInterval:timeoutSeconds
1071
+ progressHandler:^(long long receivedBytes, long long totalBytes) {
1072
+ PushyReportDownloadProgress(hash, receivedBytes, totalBytes);
772
1073
  } completionHandler:^(NSString *path, NSError *error) {
773
1074
  if (error != nil) {
774
1075
  completion(error);
@@ -934,14 +1235,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
934
1235
  }
935
1236
 
936
1237
  PushyWithStateLock(^{
937
- NSUserDefaults *defaults = PushyDefaults();
938
- pushy::state::State next = pushy::state::SwitchVersion(
939
- PushyStateFromDefaults(defaults),
940
- PushyToStdString(hash)
941
- );
942
- PushyApplyStateToDefaults(defaults, next);
943
- // Re-enable first-load consumption and rollback checks for the newly selected bundle.
944
- ignoreRollback = false;
1238
+ PushySwitchVersionLocked(hash);
945
1239
  });
946
1240
  return YES;
947
1241
  }
@@ -1110,3 +1404,427 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1110
1404
  #endif
1111
1405
 
1112
1406
  @end
1407
+
1408
+ #pragma mark - native cold-start check orchestration
1409
+
1410
+ // Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns
1411
+ // the response body on 2xx, nil on any failure. The semaphore timeout is a
1412
+ // backstop over the request's own timeoutInterval.
1413
+ static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
1414
+ NSString *body, NSTimeInterval timeout) {
1415
+ NSURL *url = [NSURL URLWithString:urlString];
1416
+ if (url == nil) {
1417
+ return nil;
1418
+ }
1419
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
1420
+ request.HTTPMethod = method;
1421
+ request.timeoutInterval = timeout;
1422
+ [request setValue:@"application/json" forHTTPHeaderField:@"Accept"];
1423
+ if (body != nil) {
1424
+ [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
1425
+ request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
1426
+ }
1427
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
1428
+ __block NSString *result = nil;
1429
+ NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
1430
+ completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
1431
+ NSHTTPURLResponse *httpResponse =
1432
+ [response isKindOfClass:[NSHTTPURLResponse class]]
1433
+ ? (NSHTTPURLResponse *)response
1434
+ : nil;
1435
+ NSInteger status = httpResponse.statusCode;
1436
+ if (error == nil && httpResponse != nil && status >= 200 &&
1437
+ status < 300 && data != nil) {
1438
+ result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
1439
+ }
1440
+ dispatch_semaphore_signal(sem);
1441
+ }];
1442
+ [task resume];
1443
+ if (dispatch_semaphore_wait(
1444
+ sem, dispatch_time(DISPATCH_TIME_NOW,
1445
+ (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) {
1446
+ [task cancel];
1447
+ return nil;
1448
+ }
1449
+ return result;
1450
+ }
1451
+
1452
+ static NSString *PushyNormalizeEndpointBase(NSString *base) {
1453
+ while ([base hasSuffix:@"/"]) {
1454
+ base = [base substringToIndex:base.length - 1];
1455
+ }
1456
+ return base;
1457
+ }
1458
+
1459
+ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
1460
+ if (responseText == nil) {
1461
+ return NO;
1462
+ }
1463
+ bool ok = false;
1464
+ flowjson::Value parsed = flowjson::Parse(PushyToStdString(responseText), &ok);
1465
+ return ok && parsed.IsObject();
1466
+ }
1467
+
1468
+ @implementation RCTPushyOrchestrator
1469
+
1470
+ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion {
1471
+ #if !DEBUG
1472
+ // Once per process; a few seconds of delay keeps the check away from the
1473
+ // cold-start critical path (§7 R5) — its result targets the NEXT launch.
1474
+ static dispatch_once_t onceToken;
1475
+ dispatch_once(&onceToken, ^{
1476
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
1477
+ dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
1478
+ @try {
1479
+ [self runOnce:launchRolledBackVersion];
1480
+ } @catch (NSException *exception) {
1481
+ // The rescue path must never take the app down with it.
1482
+ RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);
1483
+ }
1484
+ });
1485
+ });
1486
+ #endif
1487
+ }
1488
+
1489
+ // A bare module instance drives the existing download/patch pipeline without
1490
+ // a bridge: the file queue is process-global and progress events are gated on
1491
+ // hasListeners (never set without a bridge).
1492
+ + (RCTPushy *)engine {
1493
+ static RCTPushy *engine;
1494
+ static dispatch_once_t onceToken;
1495
+ dispatch_once(&onceToken, ^{
1496
+ engine = [[RCTPushy alloc] init];
1497
+ });
1498
+ return engine;
1499
+ }
1500
+
1501
+ + (void)runOnce:(NSString *)launchRolledBackVersion {
1502
+ // Sampled before any IO: resetToPackagedBundle bumps it, and a reset that
1503
+ // lands while this round is running must win over the round's decision.
1504
+ const uint64_t resetGeneration = pushyResetGeneration.load();
1505
+ NSUserDefaults *defaults = PushyDefaults();
1506
+ NSString *configJson = [defaults stringForKey:keyNativeConfig];
1507
+ if (configJson.length == 0) {
1508
+ // No persisted config (old integration / first ever launch): the
1509
+ // native check silently does not run — this is the rollout gate.
1510
+ return;
1511
+ }
1512
+ bool ok = false;
1513
+ flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok);
1514
+ if (!ok || !config.IsObject() || config.Get("disabled").Truthy()) {
1515
+ return;
1516
+ }
1517
+ NSString *appKey = PushyFromStdString(config.Get("appKey").AsString());
1518
+ if (appKey.length == 0) {
1519
+ return;
1520
+ }
1521
+ NSString *packageVersion =
1522
+ PushyFromStdString(config.Get("packageVersion").AsString());
1523
+ if (packageVersion.length == 0) {
1524
+ packageVersion = [RCTPushy packageVersion];
1525
+ }
1526
+
1527
+ __block NSString *currentVersion = nil;
1528
+ PushyWithStateLock(^{
1529
+ pushy::state::State state = PushyStateFromDefaults(PushyDefaults());
1530
+ currentVersion = PushyFromStdString(state.current_version);
1531
+ });
1532
+ NSString *rolledBackVersion = launchRolledBackVersion;
1533
+ NSString *uuid = [defaults stringForKey:keyUuid] ?: @"";
1534
+
1535
+ flowjson::Value identity = flowjson::Value::Object();
1536
+ identity.Set("packageVersion",
1537
+ flowjson::Value::String(PushyToStdString(packageVersion)));
1538
+ if (currentVersion != nil) {
1539
+ identity.Set("currentVersion",
1540
+ flowjson::Value::String(PushyToStdString(currentVersion)));
1541
+ }
1542
+ identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid)));
1543
+ if (rolledBackVersion != nil) {
1544
+ identity.Set("rolledBackVersion",
1545
+ flowjson::Value::String(PushyToStdString(rolledBackVersion)));
1546
+ }
1547
+
1548
+ flowjson::Value cInfo = flowjson::Value::Object();
1549
+ cInfo.Set("rnu", config.Get("rnu"));
1550
+ cInfo.Set("rn", config.Get("rn"));
1551
+ cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString
1552
+ stringWithFormat:@"ios %@", [[UIDevice currentDevice] systemVersion]])));
1553
+ cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid)));
1554
+
1555
+ flowjson::Value input = flowjson::Value::Object();
1556
+ input.Set("packageVersion", identity.Get("packageVersion"));
1557
+ if (currentVersion != nil) {
1558
+ input.Set("currentVersion", identity.Get("currentVersion"));
1559
+ }
1560
+ input.Set("buildTime",
1561
+ flowjson::Value::String(PushyToStdString([RCTPushy buildTime])));
1562
+ input.Set("cInfo", cInfo);
1563
+ input.Set("supportedDiffVersion",
1564
+ flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion));
1565
+ input.Set("bundleHash",
1566
+ flowjson::Value::String(PushyToStdString(PushyBundleHashSync())));
1567
+
1568
+ std::string bodyJson =
1569
+ flowjson::Stringify(updateflow::BuildCheckRequestBody(input));
1570
+ NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()];
1571
+ if (body == nil) {
1572
+ RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");
1573
+ return;
1574
+ }
1575
+
1576
+ NSString *responseText = [self runCheckRequest:config appKey:appKey body:body];
1577
+ if (responseText == nil) {
1578
+ RCTLogInfo(@"RCTPushy -- native check: no endpoint reachable, giving up until next launch");
1579
+ return;
1580
+ }
1581
+ long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970];
1582
+
1583
+ flowjson::Value decision = updateflow::HandleCheckResponse(
1584
+ PushyToStdString(responseText), identity, false,
1585
+ config.Get("afterDownload").AsString());
1586
+ if (decision.Get("action").AsString() != "download") {
1587
+ [self commitRoundWithGeneration:resetGeneration
1588
+ hashInfo:nil
1589
+ activate:nil
1590
+ responseText:responseText
1591
+ request:body
1592
+ config:configJson
1593
+ responseAt:responseAtSeconds];
1594
+ RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",
1595
+ decision.Get("reason").AsString().c_str());
1596
+ return;
1597
+ }
1598
+ NSString *hash = PushyFromStdString(decision.Get("hash").AsString());
1599
+ if (!PushyIsSafePathComponent(hash)) {
1600
+ return;
1601
+ }
1602
+
1603
+ NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1604
+ BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir);
1605
+ if (!downloaded) {
1606
+ downloaded = [self performAttempts:decision.Get("attempts")
1607
+ hash:hash
1608
+ originHash:currentVersion];
1609
+ }
1610
+ if (!downloaded) {
1611
+ [self commitRoundWithGeneration:resetGeneration
1612
+ hashInfo:nil
1613
+ activate:nil
1614
+ responseText:responseText
1615
+ request:body
1616
+ config:configJson
1617
+ responseAt:responseAtSeconds];
1618
+ return;
1619
+ }
1620
+
1621
+ // Persist name/description/metaInfo alongside the version, mirroring the
1622
+ // JS side's setLocalHashInfo after a successful download.
1623
+ const flowjson::Value &info = decision.Get("info");
1624
+ NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary];
1625
+ for (const char *key : {"name", "description", "metaInfo"}) {
1626
+ if (info.Get(key).IsString()) {
1627
+ versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @"";
1628
+ }
1629
+ }
1630
+ // Silent strategies or a server-marked forceBoot version (per-version
1631
+ // remote override — the brick rescue) activate for the next launch;
1632
+ // otherwise activation stays with the JS side (§6/§10.1).
1633
+ BOOL activate = decision.Get("activate").Truthy();
1634
+ BOOL committed = [self commitRoundWithGeneration:resetGeneration
1635
+ hashInfo:@{@"hash": hash, @"info": versionInfo}
1636
+ activate:activate ? hash : nil
1637
+ responseText:responseText
1638
+ request:body
1639
+ config:configJson
1640
+ responseAt:responseAtSeconds];
1641
+ if (!committed) {
1642
+ RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result");
1643
+ } else if (activate) {
1644
+ RCTLogInfo(@"RCTPushy -- native check: downloaded %@ and set for next launch", hash);
1645
+ } else {
1646
+ RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash);
1647
+ }
1648
+ }
1649
+
1650
+ // Everything a round persists — version info, the activation, the response
1651
+ // cache — is written inside ONE state-lock acquisition that first re-checks the
1652
+ // reset generation. resetToPackagedBundle bumps that generation under the same
1653
+ // lock, so there is no compare-and-act window: either the whole round commits,
1654
+ // or the reset wins and none of it does.
1655
+ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
1656
+ hashInfo:(NSDictionary *)hashInfoEntry
1657
+ activate:(NSString *)hashToActivate
1658
+ responseText:(NSString *)responseText
1659
+ request:(NSString *)requestBody
1660
+ config:(NSString *)configJson
1661
+ responseAt:(long long)responseAtSeconds {
1662
+ NSDictionary *cacheEntry = @{
1663
+ @"ts": @(responseAtSeconds),
1664
+ @"body": responseText,
1665
+ @"request": requestBody,
1666
+ @"config": configJson,
1667
+ };
1668
+ NSData *cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil];
1669
+ __block BOOL committed = NO;
1670
+ PushyWithStateLock(^{
1671
+ if (pushyResetGeneration.load() != generation) {
1672
+ return;
1673
+ }
1674
+ NSUserDefaults *defaults = PushyDefaults();
1675
+ if (hashInfoEntry != nil) {
1676
+ NSData *infoData = [NSJSONSerialization dataWithJSONObject:hashInfoEntry[@"info"]
1677
+ options:0
1678
+ error:nil];
1679
+ if (infoData != nil) {
1680
+ [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding]
1681
+ forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])];
1682
+ }
1683
+ }
1684
+ if (hashToActivate != nil) {
1685
+ PushySwitchVersionLocked(hashToActivate);
1686
+ }
1687
+ if (cacheData != nil) {
1688
+ [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding]
1689
+ forKey:keyNativeCheckCache];
1690
+ }
1691
+ committed = YES;
1692
+ });
1693
+ return committed;
1694
+ }
1695
+
1696
+ // Sequential fallback over the ordered candidates (§5.1): one request at a
1697
+ // time with its own timeout; after the configured round fails, queryUrls
1698
+ // discovery merges remote candidates (excluding the already-tried) for one
1699
+ // more round. No hedged race on purpose — this path is latency-insensitive.
1700
+ + (NSString *)runCheckRequest:(const flowjson::Value &)config
1701
+ appKey:(NSString *)appKey
1702
+ body:(NSString *)body {
1703
+ double sample = arc4random() / 4294967296.0;
1704
+ flowjson::Value ordered =
1705
+ updateflow::OrderEndpointCandidates(config.Get("endpoints"), sample);
1706
+ NSMutableSet<NSString *> *tried = [NSMutableSet set];
1707
+ const NSUInteger maxHttpAttempts = 8;
1708
+ NSUInteger httpAttempts = 0;
1709
+ for (const auto &endpoint : ordered.elements()) {
1710
+ NSString *base = PushyNormalizeEndpointBase(
1711
+ PushyFromStdString(endpoint.AsString()));
1712
+ if (base.length == 0 || [tried containsObject:base]) {
1713
+ continue;
1714
+ }
1715
+ if (httpAttempts++ >= maxHttpAttempts) {
1716
+ return nil;
1717
+ }
1718
+ [tried addObject:base];
1719
+ NSString *response = PushyHttpRequest(
1720
+ [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey],
1721
+ @"POST", body, 10);
1722
+ if (PushyIsValidCheckResponse(response)) {
1723
+ return response;
1724
+ }
1725
+ }
1726
+ for (const auto &queryUrl : config.Get("queryUrls").elements()) {
1727
+ NSString *listUrl = PushyFromStdString(queryUrl.AsString());
1728
+ if (listUrl == nil) {
1729
+ continue;
1730
+ }
1731
+ if (httpAttempts++ >= maxHttpAttempts) {
1732
+ return nil;
1733
+ }
1734
+ NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, 10);
1735
+ if (listText == nil) {
1736
+ continue;
1737
+ }
1738
+ bool ok = false;
1739
+ flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok);
1740
+ if (!ok || !remote.IsArray()) {
1741
+ continue;
1742
+ }
1743
+ for (const auto &endpoint : remote.elements()) {
1744
+ NSString *base = PushyNormalizeEndpointBase(
1745
+ PushyFromStdString(endpoint.AsString()));
1746
+ if (base.length == 0 || [tried containsObject:base]) {
1747
+ continue;
1748
+ }
1749
+ if (httpAttempts++ >= maxHttpAttempts) {
1750
+ return nil;
1751
+ }
1752
+ [tried addObject:base];
1753
+ NSString *response = PushyHttpRequest(
1754
+ [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey],
1755
+ @"POST", body, 10);
1756
+ if (PushyIsValidCheckResponse(response)) {
1757
+ return response;
1758
+ }
1759
+ }
1760
+ break; // one successfully fetched remote list is enough
1761
+ }
1762
+ return nil;
1763
+ }
1764
+
1765
+ + (BOOL)performAttempts:(const flowjson::Value &)attempts
1766
+ hash:(NSString *)hash
1767
+ originHash:(NSString *)originHash {
1768
+ RCTPushy *engine = [self engine];
1769
+ NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600;
1770
+ NSTimeInterval fullDeadline = 0;
1771
+ for (const auto &attempt : attempts.elements()) {
1772
+ const std::string &type = attempt.Get("type").AsString();
1773
+ PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk
1774
+ : type == "pdiff" ? PushyTypePatchFromPackage
1775
+ : PushyTypeFullDownload;
1776
+ if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) {
1777
+ continue; // diff patches from the running version; none running
1778
+ }
1779
+ BOOL isFullAttempt = pushyType == PushyTypeFullDownload;
1780
+ if (isFullAttempt && fullDeadline == 0) {
1781
+ // diff/pdiff cannot starve the last-resort full download.
1782
+ fullDeadline = PushyMonotonicNow() + 600;
1783
+ }
1784
+ NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline;
1785
+ for (const auto &urlValue : attempt.Get("urls").elements()) {
1786
+ NSString *url = PushyFromStdString(urlValue.AsString());
1787
+ if (url == nil) {
1788
+ continue;
1789
+ }
1790
+ NSTimeInterval remaining = deadline - PushyMonotonicNow();
1791
+ if (remaining <= 0) {
1792
+ if (isFullAttempt) {
1793
+ return NO;
1794
+ }
1795
+ break;
1796
+ }
1797
+ NSMutableDictionary *options =
1798
+ [@{
1799
+ @"updateUrl": url,
1800
+ @"hash": hash,
1801
+ @"deadlineUptime": @(deadline),
1802
+ } mutableCopy];
1803
+ if (pushyType == PushyTypePatchFromPpk) {
1804
+ options[@"originHash"] = originHash;
1805
+ }
1806
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
1807
+ __block NSError *resultError = nil;
1808
+ [engine performUpdate:pushyType options:options callback:^(NSError *error) {
1809
+ resultError = error;
1810
+ dispatch_semaphore_signal(sem);
1811
+ }];
1812
+ if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW,
1813
+ (int64_t)(remaining * NSEC_PER_SEC))) != 0) {
1814
+ RCTLogWarn(@"RCTPushy -- native check: %s attempt timed out", type.c_str());
1815
+ if (isFullAttempt) {
1816
+ return NO;
1817
+ }
1818
+ break;
1819
+ }
1820
+ if (resultError == nil) {
1821
+ return YES;
1822
+ }
1823
+ RCTLogInfo(@"RCTPushy -- native check: %s attempt failed: %@",
1824
+ type.c_str(), resultError.localizedDescription);
1825
+ }
1826
+ }
1827
+ return NO;
1828
+ }
1829
+
1830
+ @end