react-native-update 10.49.0 → 10.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/BUNDLEHASH_MIGRATION.md +22 -2
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  3. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  4. package/README-CN.md +14 -1
  5. package/README.md +14 -1
  6. package/android/jni/Android.mk +5 -1
  7. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  8. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  9. package/android/lib/x86/librnupdate.so +0 -0
  10. package/android/lib/x86_64/librnupdate.so +0 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  12. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -22
  13. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  16. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +196 -42
  18. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -3
  19. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  20. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  21. package/cpp/patch_core/digest.cpp +53 -0
  22. package/cpp/patch_core/digest.h +18 -0
  23. package/cpp/patch_core/patch_core.cpp +17 -1
  24. package/cpp/patch_core/patch_core.h +8 -0
  25. package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
  26. package/cpp/update_flow_core/flow_json.cpp +522 -0
  27. package/cpp/update_flow_core/flow_json.h +103 -0
  28. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  29. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  30. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  31. package/cpp/update_flow_core/update_flow_core.h +77 -0
  32. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  33. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  34. package/harmony/pushy/src/main/cpp/pushy.cpp +150 -1
  35. package/harmony/pushy/src/main/ets/DownloadTask.ts +133 -9
  36. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  37. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  38. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  39. package/harmony/pushy/src/main/ets/NativePatchCore.ts +16 -0
  40. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  41. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  42. package/harmony/pushy/src/main/ets/UpdateContext.ts +152 -52
  43. package/harmony/pushy.har +0 -0
  44. package/ios/RCTPushy/RCTPushy.mm +840 -68
  45. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  46. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  47. package/package.json +3 -1
  48. package/react-native-update.podspec +2 -0
  49. package/src/NativePushy.ts +14 -0
  50. package/src/client.ts +439 -122
  51. package/src/endpoint.ts +105 -80
  52. package/src/error.ts +4 -0
  53. package/src/locales/en.ts +4 -0
  54. package/src/locales/zh.ts +4 -0
  55. package/src/provider.tsx +74 -9
  56. package/src/telemetry.ts +8 -0
  57. package/src/type.ts +31 -0
  58. package/src/updateFlowCore.ts +320 -0
  59. package/src/utils.ts +14 -12
  60. package/src/isInRollout.ts +0 -86
  61. package/src/resolveCheckResult.ts +0 -30
@@ -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) {
@@ -139,6 +299,25 @@ static BOOL PushyStringIsBlank(NSString *value) {
139
299
  return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0;
140
300
  }
141
301
 
302
+ // Server-provided identifiers (hash/originHash) are used as child names under
303
+ // the download root; anything that could resolve outside of it (path
304
+ // separators, "..", ".") must be rejected before touching the filesystem.
305
+ static BOOL PushyIsSafePathComponent(NSString *value) {
306
+ if (PushyStringIsBlank(value)) {
307
+ return NO;
308
+ }
309
+ if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) {
310
+ return NO;
311
+ }
312
+ if ([value containsString:@"/"] || [value containsString:@"\\"]) {
313
+ return NO;
314
+ }
315
+ if ([value rangeOfString:@"\0"].location != NSNotFound) {
316
+ return NO;
317
+ }
318
+ return YES;
319
+ }
320
+
142
321
  static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
143
322
  // Prefer the stable cross-platform code (error_codes.h); fall back to the
144
323
  // numeric NSError code for system errors that were not classified.
@@ -162,15 +341,28 @@ static pushy::patch::PatchManifest PushyPatchManifestFromJson(NSDictionary *json
162
341
  pushy::patch::PatchManifest manifest;
163
342
 
164
343
  NSDictionary *copies = json[@"copies"];
344
+ // Content checksum per copy target ("copiesCrc", pdiff manifests from
345
+ // CLI >= 2.21.2). The patch core verifies the copy source against it and
346
+ // fails the patch on mismatch, so the JS strategy chain falls back to the
347
+ // full package instead of copying drifted bytes from a rebuilt binary.
348
+ NSDictionary *copiesCrc = json[@"copiesCrc"];
349
+ if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
350
+ copiesCrc = nil;
351
+ }
165
352
  for (NSString *to in copies) {
166
353
  NSString *from = copies[to];
167
354
  if (from.length <= 0) {
168
355
  from = to;
169
356
  }
170
- manifest.copies.push_back(pushy::patch::CopyOperation{
171
- PushyToStdString(from),
172
- PushyToStdString(to),
173
- });
357
+ pushy::patch::CopyOperation operation;
358
+ operation.from = PushyToStdString(from);
359
+ operation.to = PushyToStdString(to);
360
+ NSNumber *expectedCrc = copiesCrc[to];
361
+ if ([expectedCrc isKindOfClass:[NSNumber class]]) {
362
+ operation.has_expected_crc = true;
363
+ operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
364
+ }
365
+ manifest.copies.push_back(operation);
174
366
  }
175
367
 
176
368
  NSDictionary *deletes = json[@"deletes"];
@@ -223,6 +415,21 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
223
415
  PushyFromStdString(state.rolled_back_version));
224
416
  }
225
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
+
226
433
  @interface RCTPushy ()
227
434
  - (void)downloadUpdate:(PushyType)type
228
435
  options:(NSDictionary *)options
@@ -248,11 +455,66 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta
248
455
  callback:(void (^)(NSError *error))callback;
249
456
  - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error;
250
457
  - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path;
458
+ + (void)excludeFromBackup:(NSString *)path;
251
459
  - (void)unzipFileAtPath:(NSString *)path
252
460
  toDestination:(NSString *)destination
253
461
  completionHandler:(void (^)(NSError *error))completionHandler;
462
+ + (NSString *)downloadDir;
463
+ + (NSURL *)binaryBundleURL;
464
+ + (NSString *)packageVersion;
465
+ + (NSString *)buildTime;
466
+ @end
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;
254
482
  @end
255
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
+
256
518
  @implementation RCTPushy {
257
519
  dispatch_queue_t _fileQueue;
258
520
  bool hasListeners;
@@ -263,8 +525,10 @@ RCT_EXPORT_MODULE(RCTPushy);
263
525
  + (NSURL *)bundleURL
264
526
  {
265
527
  __block NSURL *resolvedURL = nil;
266
- PushyWithStateLock(^{
267
- NSUserDefaults *defaults = PushyDefaults();
528
+ __block NSString *launchRolledBackVersion = nil;
529
+ @try {
530
+ PushyWithStateLock(^{
531
+ NSUserDefaults *defaults = PushyDefaults();
268
532
 
269
533
  NSString *curPackageVersion = [RCTPushy packageVersion];
270
534
  NSString *curBuildTime = [RCTPushy buildTime];
@@ -318,7 +582,7 @@ RCT_EXPORT_MODULE(RCTPushy);
318
582
  if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
319
583
  pushyLaunchVersion = loadVersion;
320
584
  resolvedURL = [NSURL fileURLWithPath:bundlePath];
321
- return;
585
+ break;
322
586
  } else {
323
587
  RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion);
324
588
  state = pushy::state::Rollback(state);
@@ -327,9 +591,18 @@ RCT_EXPORT_MODULE(RCTPushy);
327
591
  }
328
592
  }
329
593
  }
330
- });
331
-
332
- 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
+ }
333
606
  }
334
607
 
335
608
  + (NSString *) rollback {
@@ -419,6 +692,36 @@ RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)res
419
692
  resolve(@true);
420
693
  }
421
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
+
422
725
  RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
423
726
  value:(NSString *)value resolver:(RCTPromiseResolveBlock)resolve
424
727
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -532,37 +835,7 @@ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
532
835
  resolve(@"");
533
836
  #else
534
837
  dispatch_async(_fileQueue, ^{
535
- NSString *path = [[RCTPushy binaryBundleURL] path];
536
- if (path == nil) {
537
- resolve(@"");
538
- return;
539
- }
540
- NSDictionary *attributes =
541
- [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
542
- if (attributes == nil) {
543
- resolve(@"");
544
- return;
545
- }
546
- NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
547
- [RCTPushy packageVersion],
548
- attributes.fileSize,
549
- [attributes.fileModificationDate timeIntervalSince1970]];
550
-
551
- NSUserDefaults *defaults = PushyDefaults();
552
- NSString *cached = [defaults stringForKey:keyBundleHashCache];
553
- NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
554
- if ([cached hasPrefix:cachedPrefix]) {
555
- resolve([cached substringFromIndex:cachedPrefix.length]);
556
- return;
557
- }
558
-
559
- NSString *hash = PushyFromStdString(
560
- pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
561
- if (hash.length > 0) {
562
- [defaults setObject:[cachedPrefix stringByAppendingString:hash]
563
- forKey:keyBundleHashCache];
564
- }
565
- resolve(hash);
838
+ resolve(PushyBundleHashSync());
566
839
  });
567
840
  #endif
568
841
  }
@@ -600,6 +873,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
600
873
  // for gray release bucketing and must not change on reset.
601
874
  __block NSString *keepVersion = nil;
602
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);
603
880
  NSUserDefaults *defaults = PushyDefaults();
604
881
  keepVersion = pushyLaunchVersion;
605
882
 
@@ -619,6 +896,9 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
619
896
  }
620
897
  [defaults removeObjectForKey:keyFirstLoadMarked];
621
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];
622
902
  ignoreRollback = false;
623
903
  });
624
904
 
@@ -694,12 +974,12 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
694
974
  NSString *updateUrl = PushyOptionString(options, @"updateUrl");
695
975
  NSString *hash = PushyOptionString(options, @"hash");
696
976
 
697
- if (PushyStringIsBlank(updateUrl) || PushyStringIsBlank(hash)) {
977
+ if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) {
698
978
  callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
699
979
  return;
700
980
  }
701
981
  NSString *originHash = PushyOptionString(options, @"originHash");
702
- if (type == PushyTypePatchFromPpk && PushyStringIsBlank(originHash)) {
982
+ if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) {
703
983
  callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
704
984
  return;
705
985
  }
@@ -711,31 +991,85 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
711
991
  return;
712
992
  }
713
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
+
714
1032
  NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]];
715
1033
 
716
1034
  // On failure, remove the partial version directory like Android/Harmony
717
1035
  // do: a half-unzipped/half-patched dir leaks disk and could later be
718
1036
  // mistaken for a complete version. hash is validated non-blank above, so
719
1037
  // this can never resolve to the download root itself.
720
- NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
721
1038
  void (^completion)(NSError *) = ^(NSError *error) {
722
- if (error != nil) {
723
- 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) {
724
1056
  [[NSFileManager defaultManager] removeItemAtPath:unzipDir error:nil];
725
- });
726
- }
727
- callback(error);
1057
+ }
1058
+ PushyFinishDownload(hash, finalError);
1059
+ });
728
1060
  };
729
1061
 
730
1062
  RCTLogInfo(@"RCTPushy -- download file %@", updateUrl);
731
- [RCTPushyDownloader download:updateUrl savePath:zipFilePath progressHandler:^(long long receivedBytes, long long totalBytes) {
732
- if (self->hasListeners) {
733
- [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{
734
- PARAM_PROGRESS_HASH:hash,
735
- PARAM_PROGRESS_RECEIVED:[NSNumber numberWithLongLong:receivedBytes],
736
- PARAM_PROGRESS_TOTAL:[NSNumber numberWithLongLong:totalBytes]
737
- }];
738
- }
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);
739
1073
  } completionHandler:^(NSString *path, NSError *error) {
740
1074
  if (error != nil) {
741
1075
  completion(error);
@@ -893,7 +1227,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
893
1227
 
894
1228
  - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error
895
1229
  {
896
- if (PushyStringIsBlank(hash)) {
1230
+ if (!PushyIsSafePathComponent(hash)) {
897
1231
  if (error != NULL) {
898
1232
  *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
899
1233
  }
@@ -901,14 +1235,7 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
901
1235
  }
902
1236
 
903
1237
  PushyWithStateLock(^{
904
- NSUserDefaults *defaults = PushyDefaults();
905
- pushy::state::State next = pushy::state::SwitchVersion(
906
- PushyStateFromDefaults(defaults),
907
- PushyToStdString(hash)
908
- );
909
- PushyApplyStateToDefaults(defaults, next);
910
- // Re-enable first-load consumption and rollback checks for the newly selected bundle.
911
- ignoreRollback = false;
1238
+ PushySwitchVersionLocked(hash);
912
1239
  });
913
1240
  return YES;
914
1241
  }
@@ -922,6 +1249,10 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
922
1249
  NSFileManager *fileManager = [NSFileManager defaultManager];
923
1250
  BOOL isDirectory = NO;
924
1251
  if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
1252
+ if (isDirectory) {
1253
+ // Directories created by older versions never got the flag.
1254
+ [RCTPushy excludeFromBackup:path];
1255
+ }
925
1256
  return isDirectory;
926
1257
  }
927
1258
 
@@ -933,10 +1264,27 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
933
1264
  if (!success && error != nil) {
934
1265
  RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
935
1266
  }
1267
+ if (success) {
1268
+ [RCTPushy excludeFromBackup:path];
1269
+ }
936
1270
 
937
1271
  return success;
938
1272
  }
939
1273
 
1274
+ // Everything under rctpushy is re-downloadable, and Application Support is
1275
+ // backed up to iCloud by default — Apple requires such content to be
1276
+ // excluded from backups.
1277
+ + (void)excludeFromBackup:(NSString *)path
1278
+ {
1279
+ NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES];
1280
+ NSError *error = nil;
1281
+ if (![url setResourceValue:@YES
1282
+ forKey:NSURLIsExcludedFromBackupKey
1283
+ error:&error]) {
1284
+ RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription);
1285
+ }
1286
+ }
1287
+
940
1288
  - (void)unzipFileAtPath:(NSString *)path
941
1289
  toDestination:(NSString *)destination
942
1290
  completionHandler:(void (^)(NSError *error))completionHandler
@@ -1056,3 +1404,427 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1056
1404
  #endif
1057
1405
 
1058
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