react-native-update 10.59.1 → 10.60.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 (125) hide show
  1. package/README-CN.md +7 -7
  2. package/README.md +7 -6
  3. package/android/build.gradle +25 -33
  4. package/android/jni/Android.mk +16 -24
  5. package/android/lib/arm64-v8a/librnpushy.so +0 -0
  6. package/android/lib/armeabi-v7a/librnpushy.so +0 -0
  7. package/android/lib/x86/librnpushy.so +0 -0
  8. package/android/lib/x86_64/librnpushy.so +0 -0
  9. package/android/proguard.pro +9 -7
  10. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +11 -12
  11. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +23 -6
  12. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +50 -51
  13. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +12 -13
  14. package/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java +3 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/CrashHold.java +22 -30
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +181 -332
  17. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -1
  18. package/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +27 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +1 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +32 -8
  21. package/android/src/main/java/cn/reactnative/modules/update/HttpClients.java +63 -0
  22. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +0 -11
  23. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +66 -116
  24. package/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +9 -1
  25. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +1 -1
  26. package/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +32 -60
  27. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +8 -23
  28. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +42 -80
  29. package/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +22 -39
  30. package/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +176 -299
  31. package/android/src/main/java/cn/reactnative/modules/update/Threads.java +35 -0
  32. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +242 -174
  33. package/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +1 -1
  34. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +75 -17
  35. package/android/src/main/java/cn/reactnative/modules/update/{UpdateModuleImpl.java → UpdateModule.java} +113 -95
  36. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +16 -19
  37. package/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java +69 -0
  38. package/cpp/android_jni/archive_limits_jni.cpp +83 -0
  39. package/cpp/android_jni/install_record_jni.cpp +89 -0
  40. package/cpp/android_jni/jni_log.h +31 -0
  41. package/cpp/android_jni/jni_natives.h +40 -0
  42. package/cpp/android_jni/jni_registration.cpp +133 -0
  43. package/cpp/android_jni/jni_util.h +160 -0
  44. package/cpp/android_jni/update_core_android.cpp +450 -0
  45. package/cpp/android_jni/update_flow_jni.cpp +133 -0
  46. package/{android/jni → cpp/hpatch}/hpatch.c +5 -5
  47. package/{android/jni → cpp/hpatch}/hpatch.h +4 -1
  48. package/cpp/patch_core/archive_limits.cpp +194 -0
  49. package/cpp/patch_core/archive_limits.h +99 -10
  50. package/cpp/patch_core/archive_patch_core.cpp +96 -12
  51. package/cpp/patch_core/archive_patch_core.h +49 -0
  52. package/cpp/patch_core/digest.h +5 -4
  53. package/cpp/patch_core/error_codes.h +5 -3
  54. package/cpp/patch_core/install_record.cpp +430 -0
  55. package/cpp/patch_core/install_record.h +163 -9
  56. package/cpp/patch_core/patch_core.cpp +62 -16
  57. package/cpp/patch_core/patch_core.h +27 -7
  58. package/cpp/patch_core/patch_manifest.cpp +201 -0
  59. package/cpp/patch_core/patch_manifest.h +74 -0
  60. package/cpp/patch_core/state_ops.h +3 -3
  61. package/cpp/update_flow_core/flow_json.cpp +82 -8
  62. package/cpp/update_flow_core/flow_json.h +21 -0
  63. package/cpp/update_flow_core/native_config.cpp +456 -0
  64. package/cpp/update_flow_core/native_config.h +84 -0
  65. package/cpp/update_flow_core/native_round.cpp +326 -0
  66. package/cpp/update_flow_core/native_round.h +90 -0
  67. package/cpp/update_flow_core/update_flow_core.h +2 -2
  68. package/harmony/hvigor-plugin.ts +11 -19
  69. package/harmony/pushy/src/main/cpp/CMakeLists.txt +19 -26
  70. package/harmony/pushy/src/main/cpp/PushyPackage.h +2 -2
  71. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +55 -94
  72. package/harmony/pushy/src/main/cpp/pushy.cpp +694 -517
  73. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +48 -72
  74. package/harmony/pushy/src/main/ets/DownloadTask.ts +164 -404
  75. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +1 -3
  76. package/harmony/pushy/src/main/ets/ErrorCodes.ts +1 -14
  77. package/harmony/pushy/src/main/ets/EventHub.ts +1 -1
  78. package/harmony/pushy/src/main/ets/InstallRecord.ts +52 -151
  79. package/harmony/pushy/src/main/ets/Logger.ts +20 -34
  80. package/harmony/pushy/src/main/ets/NativePatchCore.ts +116 -42
  81. package/harmony/pushy/src/main/ets/PushyConfiguration.ts +8 -85
  82. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -25
  83. package/harmony/pushy/src/main/ets/SaveFile.ts +99 -18
  84. package/harmony/pushy/src/main/ets/SyncCoordinator.ts +136 -335
  85. package/harmony/pushy/src/main/ets/Texts.ts +0 -4
  86. package/harmony/pushy/src/main/ets/UpdateContext.ts +106 -119
  87. package/harmony/pushy.har +0 -0
  88. package/ios/RCTPushy/RCTPushy+Launch.mm +185 -0
  89. package/ios/RCTPushy/RCTPushy.h +12 -0
  90. package/ios/RCTPushy/RCTPushy.mm +87 -2628
  91. package/ios/RCTPushy/RCTPushyCommon.h +34 -0
  92. package/ios/RCTPushy/RCTPushyConfiguration.h +0 -8
  93. package/ios/RCTPushy/RCTPushyConfiguration.mm +40 -121
  94. package/ios/RCTPushy/RCTPushyDownloader.mm +40 -37
  95. package/ios/RCTPushy/RCTPushyInstallRecord.mm +61 -0
  96. package/ios/RCTPushy/RCTPushyInstaller.mm +689 -0
  97. package/ios/RCTPushy/RCTPushyInternal.h +144 -0
  98. package/ios/RCTPushy/RCTPushyOrchestrator.mm +1001 -0
  99. package/ios/RCTPushy/RCTPushyState.mm +314 -0
  100. package/package.json +25 -23
  101. package/react-native-update.podspec +74 -83
  102. package/src/client.ts +361 -451
  103. package/src/context.ts +1 -1
  104. package/src/core.ts +35 -8
  105. package/src/endpoint.ts +0 -2
  106. package/src/error.ts +13 -32
  107. package/src/i18n.ts +18 -83
  108. package/src/locales/en.ts +0 -4
  109. package/src/locales/zh.ts +0 -4
  110. package/src/provider.tsx +60 -53
  111. package/src/telemetry.ts +0 -13
  112. package/src/type.ts +0 -15
  113. package/src/updateFlowCore.ts +116 -10
  114. package/src/utils.ts +49 -42
  115. package/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java +0 -6
  116. package/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +0 -104
  117. package/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +0 -37
  118. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +0 -121
  119. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +0 -125
  120. package/cpp/patch_core/jni_natives.h +0 -27
  121. package/cpp/patch_core/jni_registration.cpp +0 -103
  122. package/cpp/patch_core/jni_util.h +0 -60
  123. package/cpp/patch_core/patch_core_android.cpp +0 -97
  124. package/cpp/patch_core/update_core_android.cpp +0 -485
  125. package/cpp/update_flow_core/update_flow_jni.cpp +0 -150
@@ -0,0 +1,1001 @@
1
+ #import "RCTPushyInternal.h"
2
+ #include "../../cpp/patch_core/error_codes.h"
3
+ #include "../../cpp/update_flow_core/flow_json.h"
4
+ #include "../../cpp/update_flow_core/native_config.h"
5
+ #include "../../cpp/update_flow_core/native_round.h"
6
+ #include "../../cpp/update_flow_core/update_flow_core.h"
7
+
8
+ #if TARGET_OS_TV
9
+ #import <Network/Network.h>
10
+ #include <sys/sysctl.h>
11
+ #include <sys/time.h>
12
+ #endif
13
+
14
+ // Raw JSON persisted by JS (syncNativeConfig) or the host (configure) for the
15
+ // native round; parsed on read (PushyLoadRunnableConfig). Absent = no round.
16
+ static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY";
17
+ // Set when a native check round starts, cleared when it ends (§11.4). Residue
18
+ // on the next launch means the previous process died mid-round (a crash
19
+ // rescue was truncated): that launch resumes immediately instead of waiting.
20
+ static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY";
21
+
22
+ // The crash-hold version-info flag and the no-op status read by JS, stored
23
+ // encoded (RCTPushyRevealText) so they do not appear as plain binary strings.
24
+ static NSString *PushyInfoCrashHold(void) {
25
+ static NSString *value = RCTPushyRevealText("3905f5c2a6b96d56212a19");
26
+ return value;
27
+ }
28
+ static NSString *PushyStatusNone(void) {
29
+ static NSString *value = RCTPushyRevealText("3418c1c1aa8a7c40");
30
+ return value;
31
+ }
32
+
33
+ // YES when `semaphore` was signalled within `seconds`.
34
+ static BOOL PushyWaitSeconds(dispatch_semaphore_t semaphore, NSTimeInterval seconds) {
35
+ return dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW,
36
+ (int64_t)(seconds * NSEC_PER_SEC))) == 0;
37
+ }
38
+
39
+ // cInfo.os label prefix; must match the JS side (core.ts) so the server
40
+ // buckets tvOS devices apart from iOS ones.
41
+ #if TARGET_OS_TV
42
+ static NSString * const PushyOsName = @"tvos";
43
+ #else
44
+ static NSString * const PushyOsName = @"ios";
45
+ #endif
46
+
47
+ // One round per process, whoever starts it first — the delayed cold-start
48
+ // path or the crash-rescue thread (§11.3). The semaphore lets the rescue
49
+ // wait out an in-flight round instead of racing it.
50
+ static std::atomic<bool> pushyRoundStarted{false};
51
+ static std::atomic<bool> pushyRoundCompleted{false};
52
+ // Flipped the moment a crash is being held. JS is dead from that point on,
53
+ // so there is no second decision maker: the round force-activates whatever
54
+ // it downloads (§11.3).
55
+ static std::atomic<bool> pushyCrashHoldActive{false};
56
+ static std::atomic<bool> pushyHoldAttempted{false};
57
+ // Set when this process's round is the tvOS purged-version restore.
58
+ static std::atomic<bool> pushyPurgeRestoreActive{false};
59
+ // True while +bundleURL waits for that restore. Only then may the round
60
+ // activate what it downloads on its own (JS has not started to decide), since
61
+ // the launch resolves the bundle again afterwards. Closed once the wait ends,
62
+ // timeout or not; guarded by the state lock that every activation commits
63
+ // under, so a commit lands either before the close (and launches) or after it
64
+ // (and is left to JS) — never selected behind the packaged bundle's back.
65
+ static bool pushyPurgeRestoreWindowOpen = false;
66
+ static dispatch_semaphore_t pushyRoundDone;
67
+ static NSString *pushyLaunchRolledBackForHold = nil;
68
+ // A version this process downloaded but left for JS to activate. If the
69
+ // process then crashes, JS will never activate it — the crash handler
70
+ // activates it directly (bounded local work, no network). The generation is
71
+ // the one the round committed under: a reset that lands afterwards bumps it,
72
+ // and the late activation must lose to that reset exactly like the round
73
+ // itself would. Guarded by @synchronized (RCTPushyOrchestrator class).
74
+ static NSString *pushyUnactivatedHash = nil;
75
+ static uint64_t pushyUnactivatedGeneration = 0;
76
+ static uint64_t pushyUnactivatedConfigGeneration = 0;
77
+ static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL;
78
+ // ≈ process start: scheduleFromColdStart runs during the first bundleURL.
79
+ static NSTimeInterval pushyProcessAnchorUptime = 0;
80
+ // Config JSON for which JS reported a completed check in this process
81
+ // (markJsCheckCompleted). Process-scoped by design. Guarded by
82
+ // @synchronized (RCTPushyOrchestrator class).
83
+ static NSString *pushyJsCompletedConfig = nil;
84
+ // The group supplements (rather than consumes) the crash-rescue semaphore.
85
+ static dispatch_group_t pushyHostRoundGroup;
86
+ static std::atomic<bool> pushySyncReady{false};
87
+ static NSDictionary *pushyHostRoundResult = nil;
88
+ static NSString *pushyHostRoundConfig = nil;
89
+ static uint64_t pushyHostRoundGeneration = 0;
90
+ // Bumped by persistConfiguration when the config really changes (C1
91
+ // equivalence fails); resets keep pushyResetGeneration to themselves.
92
+ static std::atomic<uint64_t> pushyNativeConfigGeneration{0};
93
+ static uint64_t pushyHostRoundConfigGeneration = 0;
94
+
95
+ static const NSTimeInterval kPushyHoldTriggerUptime = 60;
96
+ static const NSTimeInterval kPushyHoldBudgetBackgroundThread = 10;
97
+ // A held main thread freezes UI teardown; stay well under the watchdog.
98
+ static const NSTimeInterval kPushyHoldBudgetMainThread = 3.5;
99
+ // The purged-version restore blocks +bundleURL, usually inside
100
+ // application:didFinishLaunching: — stay clear of the launch watchdog. The
101
+ // budget is measured from process start; with less than the minimum left the
102
+ // restore is not attempted.
103
+ #if TARGET_OS_TV && !DEBUG
104
+ static const NSTimeInterval kPushyPurgeRestoreBudget = 12;
105
+ static const NSTimeInterval kPushyPurgeRestoreMinimumBudget = 2;
106
+
107
+ // Seconds since the kernel started this process; 0 when unknown.
108
+ static NSTimeInterval PushyProcessElapsed(void) {
109
+ struct kinfo_proc info;
110
+ size_t size = sizeof(info);
111
+ int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()};
112
+ if (sysctl(mib, 4, &info, &size, NULL, 0) != 0 || size == 0) {
113
+ return 0;
114
+ }
115
+ struct timeval now;
116
+ gettimeofday(&now, NULL);
117
+ const struct timeval start = info.kp_proc.p_starttime;
118
+ NSTimeInterval elapsed = (now.tv_sec - start.tv_sec)
119
+ + (now.tv_usec - start.tv_usec) / 1e6;
120
+ return elapsed > 0 ? elapsed : 0;
121
+ }
122
+
123
+ // NO only when the system positively reports no usable network path; an
124
+ // unknown state (no answer within the short wait) lets the restore try.
125
+ static BOOL PushyHasNetworkPath(void) {
126
+ dispatch_queue_t queue = dispatch_queue_create("cn.reactnative.pushy.path", DISPATCH_QUEUE_SERIAL);
127
+ nw_path_monitor_t monitor = nw_path_monitor_create();
128
+ dispatch_semaphore_t answered = dispatch_semaphore_create(0);
129
+ __block nw_path_status_t status = nw_path_status_invalid;
130
+ nw_path_monitor_set_queue(monitor, queue);
131
+ nw_path_monitor_set_update_handler(monitor, ^(nw_path_t path) {
132
+ BOOL first = status == nw_path_status_invalid;
133
+ status = nw_path_get_status(path);
134
+ if (first) {
135
+ dispatch_semaphore_signal(answered);
136
+ }
137
+ });
138
+ nw_path_monitor_start(monitor);
139
+ PushyWaitSeconds(answered, 0.5);
140
+ nw_path_monitor_cancel(monitor);
141
+ __block nw_path_status_t result;
142
+ dispatch_sync(queue, ^{
143
+ result = status;
144
+ });
145
+ return result != nw_path_status_unsatisfied;
146
+ }
147
+ #endif
148
+ // prepareBundleWithCompletion: (no explicit timeout) waits this long at most:
149
+ // past the longest round (8 query requests, then two 600s download phases).
150
+ static const NSTimeInterval kPushyHostPrepareDefaultTimeout = 1800;
151
+
152
+ @interface RCTPushyOrchestrator ()
153
+ + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime;
154
+ @end
155
+
156
+ static void PushyMaybeHoldProcess(void) {
157
+ // Once per process; a second crashing thread passes straight through
158
+ // instead of waiting behind the first (§11.3: prefer under-rescuing over
159
+ // wedging the teardown).
160
+ bool expected = false;
161
+ if (!pushyHoldAttempted.compare_exchange_strong(expected, true)) {
162
+ return;
163
+ }
164
+ NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime;
165
+ bool roundInFlight = pushyRoundStarted.load() && !pushyRoundCompleted.load();
166
+ // Early crashes are the brick signature; a crash with an in-flight round
167
+ // is worth finishing regardless of uptime. Everything else is an ordinary
168
+ // crash whose UX must not be delayed.
169
+ if (uptime >= kPushyHoldTriggerUptime && !roundInFlight) {
170
+ return;
171
+ }
172
+ NSTimeInterval budget = [NSThread isMainThread]
173
+ ? kPushyHoldBudgetMainThread
174
+ : kPushyHoldBudgetBackgroundThread;
175
+ NSTimeInterval deadline = PushyMonotonicNow() + budget;
176
+ NSLog(@"RCTPushy -- crash hold: holding process for up to %.1fs "
177
+ @"(uptime %.1fs)", budget, uptime);
178
+
179
+ // The rescue runs on its own queue and the dying thread only waits with a
180
+ // hard timeout: even a deadlocked rescue (a thread that died holding a
181
+ // lock — uncaught ObjC exceptions skip @finally) can only delay the
182
+ // process death, never prevent it.
183
+ dispatch_semaphore_t done = dispatch_semaphore_create(0);
184
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
185
+ @try {
186
+ [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline];
187
+ } @catch (NSException *exception) {
188
+ NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason);
189
+ }
190
+ dispatch_semaphore_signal(done);
191
+ });
192
+ if (!PushyWaitSeconds(done, budget)) {
193
+ NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go");
194
+ }
195
+ }
196
+
197
+ // Crash-moment brick rescue (§11): when the app is dying of an uncaught
198
+ // exception during startup (RCTFatal raises NSException in release), the
199
+ // process is still alive and JS will never run again — a natural,
200
+ // false-positive-free window to finish the cold-start check. The previous
201
+ // handler (crash reporters chain the same way) always runs afterwards.
202
+ static void PushyCrashHoldExceptionHandler(NSException *exception) {
203
+ @try {
204
+ PushyMaybeHoldProcess();
205
+ } @catch (NSException *inner) {
206
+ // The dying process owes the previous handler its turn no matter
207
+ // what the rescue did.
208
+ }
209
+ if (pushyPreviousExceptionHandler != NULL) {
210
+ pushyPreviousExceptionHandler(exception);
211
+ }
212
+ }
213
+
214
+ static void PushyInstallCrashHoldHandler(void) {
215
+ static const bool installed = [] {
216
+ pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler();
217
+ NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler);
218
+ return true;
219
+ }();
220
+ (void)installed;
221
+ }
222
+
223
+ #pragma mark - native cold-start check orchestration
224
+
225
+ // Refuses an https→http downgrade before the redirected request — and with
226
+ // it the POST body (appKey, uuid, versions) — leaves the device. The shared
227
+ // session would follow first and only the response could be rejected; ATS
228
+ // blocks plaintext by default, this covers apps that opted out with
229
+ // NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader
230
+ // (RCTPushyIsHttpsDowngrade). Returning nil
231
+ // delivers the 3xx itself as the final response, which the status check in
232
+ // PushyHttpRequest then treats as a failed endpoint.
233
+ @interface PushyRequestRedirectGuard : NSObject <NSURLSessionTaskDelegate>
234
+ @end
235
+
236
+ @implementation PushyRequestRedirectGuard
237
+
238
+ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
239
+ willPerformHTTPRedirection:(NSHTTPURLResponse *)response
240
+ newRequest:(NSURLRequest *)request
241
+ completionHandler:(void (^)(NSURLRequest *))completionHandler
242
+ {
243
+ completionHandler(RCTPushyIsHttpsDowngrade(task.originalRequest.URL, request.URL) ? nil : request);
244
+ }
245
+
246
+ @end
247
+
248
+ // One long-lived delegate session (not the shared one) so the redirect guard
249
+ // sees the 3xx before it is followed; completion handlers still receive the
250
+ // final response. Never invalidated: it lives as long as the process.
251
+ static NSURLSession *PushyRequestSession(void) {
252
+ static NSURLSession *session = [NSURLSession
253
+ sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]
254
+ delegate:[PushyRequestRedirectGuard new]
255
+ delegateQueue:nil];
256
+ return session;
257
+ }
258
+
259
+ // Same shape as -[UIDevice systemVersion] ("17.4", "17.4.1"), which is
260
+ // main-thread-only UIKit; the round runs on a background queue.
261
+ static NSString *PushyOsVersion(void) {
262
+ NSOperatingSystemVersion version = [NSProcessInfo processInfo].operatingSystemVersion;
263
+ if (version.patchVersion > 0) {
264
+ return [NSString stringWithFormat:@"%ld.%ld.%ld", (long)version.majorVersion,
265
+ (long)version.minorVersion, (long)version.patchVersion];
266
+ }
267
+ return [NSString stringWithFormat:@"%ld.%ld", (long)version.majorVersion,
268
+ (long)version.minorVersion];
269
+ }
270
+
271
+ // Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns
272
+ // the response body on 2xx, nil on any failure. The semaphore timeout is a
273
+ // backstop over the request's own timeoutInterval.
274
+ static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
275
+ NSString *body, NSTimeInterval timeout) {
276
+ NSURL *url = [NSURL URLWithString:urlString];
277
+ if (url == nil) {
278
+ return nil;
279
+ }
280
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
281
+ request.HTTPMethod = method;
282
+ request.timeoutInterval = timeout;
283
+ [request setValue:@"application/json" forHTTPHeaderField:@"Accept"];
284
+ if (body != nil) {
285
+ [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
286
+ request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
287
+ }
288
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
289
+ __block NSString *result = nil;
290
+ NSURLSessionDataTask *task = [PushyRequestSession() dataTaskWithRequest:request
291
+ completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
292
+ NSHTTPURLResponse *httpResponse =
293
+ [response isKindOfClass:[NSHTTPURLResponse class]]
294
+ ? (NSHTTPURLResponse *)response
295
+ : nil;
296
+ NSInteger status = httpResponse.statusCode;
297
+ if (error == nil && httpResponse != nil && status >= 200 && status < 300
298
+ && data != nil) {
299
+ result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
300
+ }
301
+ dispatch_semaphore_signal(sem);
302
+ }];
303
+ [task resume];
304
+ if (!PushyWaitSeconds(sem, timeout + 5)) {
305
+ [task cancel];
306
+ return nil;
307
+ }
308
+ return result;
309
+ }
310
+
311
+ // Contract C1 (flowcore::ConfigsEquivalent, shared with Android and Harmony;
312
+ // JS has its own copy): the same config in another serialization — key order,
313
+ // trailing slashes, defaulted keys — is not a change. Lets a host-owned
314
+ // config and the same config as JS serializes it count as one, instead of a
315
+ // "change" that cancels the round. nil (no stored config) never matches.
316
+ static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) {
317
+ return a != nil && b != nil
318
+ && flowcore::ConfigsEquivalent(PushyToStdString(a), PushyToStdString(b));
319
+ }
320
+
321
+ // The persisted config a round may run with. Every gate (crash-hold handler
322
+ // install, round start, prepareBundle, the round itself) reads it through
323
+ // PushyLoadRunnableConfig, which applies the shared gate
324
+ // (flowcore::ParseRunnableConfig), so they agree on what "configured" and
325
+ // "disabled" mean. YES when a round may run (*json is the config text);
326
+ // otherwise *rejection is the host-facing result (skipped:
327
+ // not_configured/disabled, failed: invalid_config).
328
+ static BOOL PushyLoadRunnableConfig(NSString **json, NSDictionary **rejection) {
329
+ NSString *text = [PushyDefaults() stringForKey:keyNativeConfig];
330
+ if (json != NULL) {
331
+ *json = [text copy];
332
+ }
333
+ flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(PushyToStdString(text));
334
+ if (!gate.runnable() && rejection != NULL) {
335
+ *rejection = PushyHostResult(PushyFromStdString(gate.status),
336
+ PushyFromStdString(gate.reason));
337
+ }
338
+ return gate.runnable();
339
+ }
340
+
341
+ @implementation RCTPushyOrchestrator
342
+
343
+ + (void)persistConfiguration:(NSString *)config {
344
+ PushyWithStateLock(^{
345
+ NSUserDefaults *defaults = PushyDefaults();
346
+ if ([defaults stringForKey:keyUuid].length == 0) {
347
+ [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid];
348
+ }
349
+ if (PushyConfigsEquivalent([defaults stringForKey:keyNativeConfig], config)) {
350
+ // The same config in another serialization (key order, trailing
351
+ // slashes, defaulted keys). Not a change: the stored text, the
352
+ // response cache and any in-flight round all stay valid.
353
+ return;
354
+ }
355
+ // A different request identity/policy: rounds started under the old
356
+ // one must not commit (pushyNativeConfigGeneration, checked by
357
+ // commitRoundWithGeneration next to the reset generation), and the
358
+ // cached response and JS-completed marker belong to the old one.
359
+ pushyNativeConfigGeneration.fetch_add(1);
360
+ [defaults setObject:config forKey:keyNativeConfig];
361
+ [defaults removeObjectForKey:keyNativeCheckCache];
362
+ [self recordJsRound:nil];
363
+ });
364
+ if (pushySyncReady.load() && [self hasRunnableConfig]) {
365
+ PushyInstallCrashHoldHandler();
366
+ }
367
+ }
368
+
369
+ + (BOOL)hasRunnableConfig {
370
+ return PushyLoadRunnableConfig(NULL, NULL);
371
+ }
372
+
373
+ // Process-wide round state, set up by whichever launch path needs a round
374
+ // first: the delayed cold-start check or the tvOS purged-version restore.
375
+ + (void)prepareProcess:(NSString *)launchRolledBackVersion {
376
+ static const bool prepared = [launchRolledBackVersion] {
377
+ pushyRoundDone = dispatch_semaphore_create(0);
378
+ pushyHostRoundGroup = dispatch_group_create();
379
+ dispatch_group_enter(pushyHostRoundGroup);
380
+ pushyProcessAnchorUptime = PushyMonotonicNow();
381
+ pushyLaunchRolledBackForHold = [launchRolledBackVersion copy];
382
+ pushySyncReady.store(true);
383
+ // The crash-hold rescue shares the orchestrator's rollout gate: no
384
+ // runnable config, no handler (§11.3). persistConfiguration installs
385
+ // it once a runnable config arrives later in the process.
386
+ if ([RCTPushyOrchestrator hasRunnableConfig]) {
387
+ PushyInstallCrashHoldHandler();
388
+ }
389
+ return true;
390
+ }();
391
+ (void)prepared;
392
+ }
393
+
394
+ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion {
395
+ #if !DEBUG
396
+ // Once per process; a few seconds of delay keeps the check away from the
397
+ // cold-start critical path (§7 R5) — its result targets the NEXT launch.
398
+ // Unless the previous process died mid-round (residual incomplete
399
+ // marker), in which case every launch second counts (§11.4).
400
+ // No `self` inside the once-lambdas: a block created in a lambda that
401
+ // captured `self` by reference keeps that (dangling) reference.
402
+ static const bool scheduled = [launchRolledBackVersion] {
403
+ [RCTPushyOrchestrator prepareProcess:launchRolledBackVersion];
404
+ NSUserDefaults *defaults = PushyDefaults();
405
+ int64_t delaySeconds =
406
+ [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5;
407
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC),
408
+ dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
409
+ if ([RCTPushyOrchestrator hasJsRound]) {
410
+ // Not consuming the round: a later crash rescue may still
411
+ // need it.
412
+ NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process");
413
+ return;
414
+ }
415
+ [RCTPushyOrchestrator startRoundWithDeadline:0];
416
+ });
417
+ return true;
418
+ }();
419
+ (void)scheduled;
420
+ #endif
421
+ }
422
+
423
+ #if TARGET_OS_TV && !DEBUG
424
+ // Runs this process's round before the first bundle loads, blocking the
425
+ // caller for at most what is left of kPushyPurgeRestoreBudget since process
426
+ // start. The state no longer has a
427
+ // current version, so the server answers with the latest version for the
428
+ // packaged bundle, and the round activates it while the restore window is
429
+ // open. Returns NO when no round can run; otherwise the caller must resolve
430
+ // the launch bundle again: it launches whatever was activated before the
431
+ // window closed, or the packaged bundle. The round stops at the same
432
+ // deadline, keeping a partial download for the next check to resume.
433
+ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion
434
+ rolledBack:(NSString *)launchRolledBackVersion {
435
+ [self prepareProcess:launchRolledBackVersion];
436
+ if (![self hasRunnableConfig]) {
437
+ RCTLogWarn(@"RCTPushy -- version %@ was purged and no native sync is "
438
+ @"configured; launching the packaged bundle", purgedVersion);
439
+ return NO;
440
+ }
441
+ // Offline: the round could only burn the launch budget.
442
+ if (!PushyHasNetworkPath()) {
443
+ RCTLogWarn(@"RCTPushy -- version %@ was purged and there is no network; "
444
+ @"launching the packaged bundle", purgedVersion);
445
+ return NO;
446
+ }
447
+ // The launch watchdog counts from process start, not from this call:
448
+ // whatever the host did before resolving the bundle is already spent.
449
+ NSTimeInterval budget = kPushyPurgeRestoreBudget - PushyProcessElapsed();
450
+ if (budget < kPushyPurgeRestoreMinimumBudget) {
451
+ RCTLogWarn(@"RCTPushy -- version %@ was purged and the launch budget is "
452
+ @"spent; launching the packaged bundle", purgedVersion);
453
+ return NO;
454
+ }
455
+ PushyWithStateLock(^{
456
+ pushyPurgeRestoreWindowOpen = true;
457
+ });
458
+ pushyPurgeRestoreActive.store(true);
459
+ NSTimeInterval deadline = PushyMonotonicNow() + budget;
460
+ dispatch_semaphore_t done = dispatch_semaphore_create(0);
461
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
462
+ [self startRoundWithDeadline:deadline];
463
+ dispatch_semaphore_signal(done);
464
+ });
465
+ BOOL timedOut = !PushyWaitSeconds(done, budget);
466
+ // The round's deadline does not bound a slow response (the request
467
+ // timeout is an idle timeout) nor a version already on disk, so the close
468
+ // is what stops a late activation — not the deadline.
469
+ PushyWithStateLock(^{
470
+ pushyPurgeRestoreWindowOpen = false;
471
+ });
472
+ if (timedOut) {
473
+ RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion);
474
+ } else {
475
+ NSDictionary *result = pushyHostRoundResult;
476
+ RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion,
477
+ result[@"status"], result[@"reason"], result[@"hash"]);
478
+ }
479
+ return YES;
480
+ }
481
+ #endif
482
+
483
+ + (NSDictionary *)prepareBundle {
484
+ return [self prepareBundleWithin:kPushyHostPrepareDefaultTimeout];
485
+ }
486
+
487
+ // Waits at most `timeout` seconds for this process's round. The round itself
488
+ // is not cut short by the host's wait (its result still serves the next
489
+ // launch); the host gets failed/timeout instead of a result.
490
+ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout {
491
+ #if DEBUG
492
+ return PushyHostResult(@"skipped", @"debug");
493
+ #else
494
+ if (!pushySyncReady.load()) {
495
+ return PushyHostResult(@"skipped", @"not_initialized");
496
+ }
497
+ NSString *configJson = nil;
498
+ NSDictionary *rejection = nil;
499
+ if (!PushyLoadRunnableConfig(&configJson, &rejection)) {
500
+ return rejection;
501
+ }
502
+ // Run the round on its own queue rather than this caller's thread, so the
503
+ // wait below can be bounded.
504
+ if ([self claimRound]) {
505
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
506
+ [self runClaimedRoundWithDeadline:0];
507
+ });
508
+ }
509
+ // A group is broadcast-style. Sharing the rescue semaphore would let one
510
+ // waiter consume the only signal and leave the other waiting forever.
511
+ if (!pushyRoundStarted.load()) {
512
+ return PushyHostResult(@"skipped", @"config_changed");
513
+ }
514
+ if (!(timeout > 0)) {
515
+ timeout = 0; // also NaN
516
+ }
517
+ if (dispatch_group_wait(pushyHostRoundGroup, dispatch_time(DISPATCH_TIME_NOW,
518
+ (int64_t)(MIN(timeout, (NSTimeInterval)(INT64_MAX / NSEC_PER_SEC)) * NSEC_PER_SEC))) != 0) {
519
+ return PushyHostResult(@"failed", @"timeout");
520
+ }
521
+ if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()
522
+ || !PushyConfigsEquivalent(configJson, pushyHostRoundConfig)
523
+ || !PushyConfigsEquivalent(configJson, [PushyDefaults() stringForKey:keyNativeConfig])) {
524
+ return PushyHostResult(@"cancelled", @"config_changed");
525
+ }
526
+ if (pushyHostRoundGeneration != pushyResetGeneration.load()) {
527
+ return PushyHostResult(@"cancelled", @"reset");
528
+ }
529
+ return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error");
530
+ #endif
531
+ }
532
+
533
+ + (void)recordJsRound:(NSString *)config {
534
+ @synchronized (RCTPushyOrchestrator.class) {
535
+ pushyJsCompletedConfig = [config copy];
536
+ }
537
+ }
538
+
539
+ // True when JS already obtained a valid response in this process for a config
540
+ // equivalent to the one the native round would use (§10.3): the delayed round is
541
+ // then a duplicate request. Only the scheduled round asks — the crash-rescue
542
+ // path still runs, JS is dead by then.
543
+ + (BOOL)hasJsRound {
544
+ NSString *jsConfig;
545
+ @synchronized (RCTPushyOrchestrator.class) {
546
+ jsConfig = pushyJsCompletedConfig;
547
+ }
548
+ if (jsConfig == nil) {
549
+ return NO;
550
+ }
551
+ return PushyConfigsEquivalent([PushyDefaults() stringForKey:keyNativeConfig], jsConfig);
552
+ }
553
+
554
+ // Runs the process's single round on the calling thread if nobody has
555
+ // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and
556
+ // download phase to the remaining budget.
557
+ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {
558
+ if ([self claimRound]) {
559
+ [self runClaimedRoundWithDeadline:deadlineUptime];
560
+ }
561
+ }
562
+
563
+ // Claims this process's single round. Missing/disabled configuration is a
564
+ // preflight skip, not a used round. A YES must be followed by exactly one
565
+ // runClaimedRoundWithDeadline:.
566
+ + (BOOL)claimRound {
567
+ if (![self hasRunnableConfig]) {
568
+ return NO;
569
+ }
570
+ bool expected = false;
571
+ return pushyRoundStarted.compare_exchange_strong(expected, true);
572
+ }
573
+
574
+ + (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime {
575
+ @try {
576
+ [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime];
577
+ } @catch (NSException *exception) {
578
+ // The rescue path must never take the app down with it.
579
+ RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason);
580
+ pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error");
581
+ } @finally {
582
+ pushyRoundCompleted.store(true);
583
+ dispatch_semaphore_signal(pushyRoundDone);
584
+ dispatch_group_leave(pushyHostRoundGroup);
585
+ }
586
+ }
587
+
588
+ // Crash-rescue entry (§11.3), called from the handler's worker queue while
589
+ // the uncaught-exception handler holds the dying process. Ensures this
590
+ // process's round runs to completion within the budget, then activates a
591
+ // downloaded-but-unactivated version if one exists — the last chance before
592
+ // the process is gone.
593
+ + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime {
594
+ pushyCrashHoldActive.store(true);
595
+ [self startRoundWithDeadline:deadlineUptime];
596
+ if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) {
597
+ NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow();
598
+ if (remaining > 0) {
599
+ PushyWaitSeconds(pushyRoundDone, remaining);
600
+ }
601
+ }
602
+ [self activatePendingVersion];
603
+ }
604
+
605
+ // The alert-strategy variant of the §10.7 hole: the round downloaded a fix
606
+ // but deferred activation to JS, and JS is now dead. Activation is local and
607
+ // bounded (a state switch under the commit lock).
608
+ + (void)activatePendingVersion {
609
+ NSString *hash = nil;
610
+ uint64_t generation = 0;
611
+ uint64_t configGeneration = 0;
612
+ @synchronized (self) {
613
+ hash = pushyUnactivatedHash;
614
+ generation = pushyUnactivatedGeneration;
615
+ configGeneration = pushyUnactivatedConfigGeneration;
616
+ }
617
+ if (hash == nil) {
618
+ return;
619
+ }
620
+ // Unlike Android's switchVersion, PushySwitchVersionLocked does not
621
+ // validate the version directory; never point the next launch at bytes
622
+ // that are gone (e.g. wiped by a reset whose generation bump we would
623
+ // catch below anyway, or by cleanup).
624
+ if (!PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash)) {
625
+ NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash);
626
+ return;
627
+ }
628
+ NSDictionary *hashInfoEntry = nil;
629
+ NSDictionary *parsed =
630
+ PushyJsonDictionary([PushyDefaults() stringForKey:PushyHashInfoKey(hash)], NULL);
631
+ if (parsed != nil) {
632
+ NSMutableDictionary *merged = [parsed mutableCopy];
633
+ merged[PushyInfoCrashHold()] = @YES;
634
+ hashInfoEntry = @{@"hash": hash, @"info": merged};
635
+ }
636
+ BOOL committed = [self commitRoundWithGeneration:generation
637
+ configGeneration:configGeneration
638
+ hashInfo:hashInfoEntry
639
+ activate:hash
640
+ cache:nil
641
+ activated:NULL];
642
+ if (committed) {
643
+ @synchronized (self) {
644
+ pushyUnactivatedHash = nil;
645
+ }
646
+ NSLog(@"RCTPushy -- crash hold: activated downloaded version %@", hash);
647
+ } else {
648
+ NSLog(@"RCTPushy -- crash hold: reset or new config since download, dropping activation");
649
+ }
650
+ }
651
+
652
+ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {
653
+ // Generations first, config second: a config replaced in between is
654
+ // caught by the commit's generation check.
655
+ const uint64_t resetGeneration = pushyResetGeneration.load();
656
+ const uint64_t configGeneration = pushyNativeConfigGeneration.load();
657
+ pushyHostRoundGeneration = resetGeneration;
658
+ pushyHostRoundConfigGeneration = configGeneration;
659
+ pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed");
660
+ NSUserDefaults *defaults = PushyDefaults();
661
+ NSString *configJson = nil;
662
+ NSDictionary *rejection = nil;
663
+ BOOL runnable = PushyLoadRunnableConfig(&configJson, &rejection);
664
+ pushyHostRoundConfig = configJson;
665
+ if (!runnable) {
666
+ pushyHostRoundResult = rejection;
667
+ return;
668
+ }
669
+ // Preserve the interrupted-round breadcrumb and reset-safe atomic commit.
670
+ [defaults setObject:@YES forKey:keyNativeCheckIncomplete];
671
+ @try {
672
+ [self runConfiguredRound:configJson
673
+ launchRolledBackVersion:launchRolledBackVersion
674
+ resetGeneration:resetGeneration
675
+ configGeneration:configGeneration
676
+ deadline:deadlineUptime];
677
+ } @finally {
678
+ [defaults removeObjectForKey:keyNativeCheckIncomplete];
679
+ }
680
+ }
681
+
682
+ + (void)runConfiguredRound:(NSString *)configJson
683
+ launchRolledBackVersion:(NSString *)launchRolledBackVersion
684
+ resetGeneration:(uint64_t)resetGeneration
685
+ configGeneration:(uint64_t)configGeneration
686
+ deadline:(NSTimeInterval)deadlineUptime {
687
+ __block NSString *currentVersion = nil;
688
+ PushyWithStateLock(^{
689
+ pushy::state::State state = PushyStateFromDefaults(PushyDefaults());
690
+ currentVersion = PushyFromStdString(state.current_version);
691
+ });
692
+ // This launch as the shared core sees it (native_round.h); nil values are
693
+ // left out — a version-less launch has no currentVersion, as the JS
694
+ // constant does not.
695
+ flowjson::Value env = flowjson::Value::Object();
696
+ const auto put = [&env](const char *key, NSString *value) {
697
+ if (value != nil) {
698
+ env.Set(key, flowjson::Value::String(PushyToStdString(value)));
699
+ }
700
+ };
701
+ put("packageVersion", [RCTPushy packageVersion]);
702
+ put("currentVersion", currentVersion);
703
+ put("uuid", [PushyDefaults() stringForKey:keyUuid]);
704
+ put("rolledBackVersion", launchRolledBackVersion);
705
+ put("buildTime", [RCTPushy buildTime]);
706
+ put("os", [NSString stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]);
707
+ // Cache-only while a crash is being held (§11.3 budget) or the launch is
708
+ // blocked on the tvOS purged-version restore; computed (and shared with JS
709
+ // getBundleHash) on the ordinary delayed round.
710
+ put("bundleHash", PushyBundleHashSync(
711
+ !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load())));
712
+
713
+ const std::string config = PushyToStdString(configJson);
714
+ const flowjson::Value round =
715
+ flowcore::PrepareRound(config, env, arc4random() / 4294967296.0);
716
+ NSString *body = PushyFromStdString(round.Get("body").AsString());
717
+ if (body == nil) {
718
+ RCTLogWarn(@"RCTPushy -- native sync: no request to send");
719
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request");
720
+ return;
721
+ }
722
+
723
+ NSString *responseText = [self runQueryRequest:round.Get("query")
724
+ body:body
725
+ deadline:deadlineUptime];
726
+ if (responseText == nil) {
727
+ RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch");
728
+ return;
729
+ }
730
+ // Planned attempts, hashInfo and the response cache (anchored to the
731
+ // response's arrival) all come from the shared core.
732
+ flowjson::Value decision = flowcore::HandleRoundResponse(
733
+ PushyToStdString(responseText), config, env, PushyToStdString(body),
734
+ (long long)[[NSDate date] timeIntervalSince1970]);
735
+ if (!decision.IsObject()) {
736
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response");
737
+ return;
738
+ }
739
+ NSString *cache = PushyFromStdString(decision.Get("cache").AsString());
740
+ NSDictionary *cancelledByReset = PushyHostResult(@"cancelled", @"reset");
741
+ // Commits only the response cache (nothing to install or activate) and
742
+ // yields `result`, unless a reset or config change since the round
743
+ // started won.
744
+ NSDictionary *(^commitResponseOnly)(NSDictionary *) = ^NSDictionary *(NSDictionary *result) {
745
+ BOOL committed = [self commitRoundWithGeneration:resetGeneration
746
+ configGeneration:configGeneration
747
+ hashInfo:nil
748
+ activate:nil
749
+ cache:cache
750
+ activated:NULL];
751
+ return committed ? result : cancelledByReset;
752
+ };
753
+
754
+ if (decision.Get("action").AsString() != "download") {
755
+ pushyHostRoundResult = commitResponseOnly(PushyHostResult(
756
+ PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString())));
757
+ RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)",
758
+ decision.Get("reason").AsString().c_str());
759
+ return;
760
+ }
761
+ NSString *hash = PushyFromStdString(decision.Get("hash").AsString());
762
+ if (!PushyIsSafePathComponent(hash)) {
763
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response");
764
+ return;
765
+ }
766
+
767
+ BOOL downloaded = PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash);
768
+ if (!downloaded) {
769
+ downloaded = [self performAttempts:decision.Get("attempts")
770
+ hash:hash
771
+ originHash:currentVersion
772
+ deadline:deadlineUptime];
773
+ }
774
+ if (!downloaded) {
775
+ pushyHostRoundResult = commitResponseOnly(PushyHostResult(@"failed", @"download_failed"));
776
+ return;
777
+ }
778
+
779
+ // The version info to persist alongside it (the JS side's
780
+ // setLocalHashInfo, the forceBoot rescue marked by the core).
781
+ flowjson::Value info = decision.Get("hashInfo");
782
+ if (pushyCrashHoldActive.load()) {
783
+ info.Set(PushyToStdString(PushyInfoCrashHold()), flowjson::Value::Bool(true));
784
+ }
785
+ NSDictionary *versionInfo =
786
+ PushyJsonDictionary(PushyFromStdString(flowjson::Stringify(info)), NULL);
787
+ // Silent strategies or a server-marked forceBoot version (per-version
788
+ // remote override — the brick rescue) activate for the next launch;
789
+ // otherwise activation stays with the JS side (§6/§10.1). Unless a crash
790
+ // is being held: JS is dead, deferring to it would leave the fix on disk
791
+ // forever (§11.3). Or the launch is blocked on reinstalling a version
792
+ // tvOS purged: JS has not started, and the whole point is to boot it —
793
+ // commitRoundWithGeneration drops that one once the restore window closed.
794
+ BOOL activate = decision.Get("activate").Truthy() || pushyCrashHoldActive.load()
795
+ || pushyPurgeRestoreActive.load();
796
+ BOOL activated = NO;
797
+ BOOL committed = [self commitRoundWithGeneration:resetGeneration
798
+ configGeneration:configGeneration
799
+ hashInfo:@{@"hash": hash, @"info": versionInfo}
800
+ activate:activate ? hash : nil
801
+ cache:cache
802
+ activated:&activated];
803
+ activate = activated;
804
+ if (!committed) {
805
+ RCTLogInfo(@"RCTPushy -- native sync: reset during round, dropping result");
806
+ } else if (activate) {
807
+ @synchronized (self) {
808
+ pushyUnactivatedHash = nil;
809
+ }
810
+ RCTLogInfo(@"RCTPushy -- native sync: downloaded %@ and set for next launch", hash);
811
+ } else {
812
+ // Remembered so a crash later in this process can still activate it
813
+ // (activatePendingVersion) — JS never will.
814
+ @synchronized (self) {
815
+ pushyUnactivatedHash = [hash copy];
816
+ pushyUnactivatedGeneration = resetGeneration;
817
+ pushyUnactivatedConfigGeneration = configGeneration;
818
+ }
819
+ RCTLogInfo(@"RCTPushy -- native sync: downloaded %@, activation left to JS", hash);
820
+ }
821
+ pushyHostRoundResult = committed
822
+ ? PushyHostResult(@"downloaded", @"", hash, activate)
823
+ : cancelledByReset;
824
+ }
825
+
826
+ // Everything a round persists — version info, the activation, the response
827
+ // cache — is written inside ONE state-lock acquisition that first re-checks the
828
+ // reset and config generations. resetToPackagedBundle bumps that generation under the same
829
+ // lock, so there is no compare-and-act window: either the whole round commits,
830
+ // or the reset wins and none of it does.
831
+ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
832
+ configGeneration:(uint64_t)configGeneration
833
+ hashInfo:(NSDictionary *)hashInfoEntry
834
+ activate:(NSString *)hashToActivate
835
+ cache:(NSString *)cache
836
+ activated:(BOOL *)activatedOut {
837
+ // cache is nil for the crash handler's activation-only commit
838
+ // (activatePendingVersion): no round ran, so there is no cache to write.
839
+ __block BOOL committed = NO;
840
+ __block BOOL activated = NO;
841
+ PushyWithStateLock(^{
842
+ if (pushyResetGeneration.load() != generation) {
843
+ return;
844
+ }
845
+ // The config the round ran with was replaced (persistConfiguration
846
+ // bumps this under the same lock).
847
+ if (pushyNativeConfigGeneration.load() != configGeneration) {
848
+ return;
849
+ }
850
+ NSString *activation = hashToActivate;
851
+ NSDictionary *info = hashInfoEntry[@"info"];
852
+ // The tvOS purged-version restore round (a round commit, not the
853
+ // crash handler's activation-only one): it may take over this launch
854
+ // only while +bundleURL still waits for it; after that the version is
855
+ // left to JS unless a crash is being held (JS is dead then).
856
+ if (cache != nil && pushyPurgeRestoreActive.load()) {
857
+ if (pushyPurgeRestoreWindowOpen) {
858
+ if (activation != nil && info != nil) {
859
+ NSMutableDictionary *marked = [info mutableCopy];
860
+ marked[@"purgeRestore"] = @YES;
861
+ info = marked;
862
+ }
863
+ } else if (!pushyCrashHoldActive.load()) {
864
+ activation = nil;
865
+ }
866
+ }
867
+ NSUserDefaults *defaults = PushyDefaults();
868
+ NSString *infoJson = hashInfoEntry == nil ? nil : PushyJsonString(info);
869
+ if (infoJson != nil) {
870
+ [defaults setObject:infoJson forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])];
871
+ }
872
+ if (activation != nil) {
873
+ PushySwitchVersionLocked(activation);
874
+ activated = YES;
875
+ }
876
+ if (cache != nil) {
877
+ [defaults setObject:cache forKey:keyNativeCheckCache];
878
+ }
879
+ committed = YES;
880
+ });
881
+ if (activatedOut != NULL) {
882
+ *activatedOut = activated;
883
+ }
884
+ return committed;
885
+ }
886
+
887
+ // Per-request timeout, capped to the crash-rescue budget when one is active.
888
+ // <= 0 means the budget is gone and the round must stop issuing requests.
889
+ static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) {
890
+ if (deadlineUptime <= 0) {
891
+ return 10;
892
+ }
893
+ return MIN(10, deadlineUptime - PushyMonotonicNow());
894
+ }
895
+
896
+ // Sequential fallback over the endpoints (§5.1), one request at a time with
897
+ // its own timeout, as the shared core walks it (flowcore::NextQueryStep:
898
+ // order, list discovery, https-only remote bases, the request budget,
899
+ // response validity). No hedged race on purpose — this path is
900
+ // latency-insensitive.
901
+ + (NSString *)runQueryRequest:(const flowjson::Value &)query
902
+ body:(NSString *)body
903
+ deadline:(NSTimeInterval)deadlineUptime {
904
+ flowjson::Value state = query;
905
+ NSString *text = nil;
906
+ while (true) {
907
+ state = flowcore::NextQueryStep(state, text == nil
908
+ ? flowjson::Value::Undefined()
909
+ : flowjson::Value::String(PushyToStdString(text)));
910
+ if (state.Get("found").Truthy()) {
911
+ return text;
912
+ }
913
+ NSString *url = PushyFromStdString(state.Get("url").AsString());
914
+ NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime);
915
+ if (url == nil || timeout <= 0) {
916
+ return nil;
917
+ }
918
+ BOOL post = state.Get("post").Truthy();
919
+ text = PushyHttpRequest(url, post ? @"POST" : @"GET", post ? body : nil, timeout);
920
+ }
921
+ }
922
+
923
+ // Strategies in order (diff, pdiff, full), each over its mirrors, already
924
+ // planned for this device by the core (no diff without a running version, no
925
+ // cleartext URL under an https-only config). A failure
926
+ // that is not mirror-retryable (PATCH_FAILED: the bytes arrived but could not
927
+ // be applied, and every mirror serves the same bytes) moves on to the next
928
+ // strategy instead of downloading the same artifact again, like the JS side.
929
+ + (BOOL)performAttempts:(const flowjson::Value &)attempts
930
+ hash:(NSString *)hash
931
+ originHash:(NSString *)originHash
932
+ deadline:(NSTimeInterval)holdDeadline {
933
+ // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows.
934
+ NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600;
935
+ if (holdDeadline > 0) {
936
+ incrementalDeadline = MIN(incrementalDeadline, holdDeadline);
937
+ }
938
+ NSTimeInterval fullDeadline = 0;
939
+ for (const auto &attempt : attempts.elements()) {
940
+ const std::string &type = attempt.Get("type").AsString();
941
+ PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk
942
+ : type == "pdiff" ? PushyTypePatchFromPackage
943
+ : PushyTypeFullDownload;
944
+ BOOL isFullAttempt = pushyType == PushyTypeFullDownload;
945
+ if (isFullAttempt && fullDeadline == 0) {
946
+ // diff/pdiff cannot starve the last-resort full download.
947
+ fullDeadline = PushyMonotonicNow() + 600;
948
+ if (holdDeadline > 0) {
949
+ fullDeadline = MIN(fullDeadline, holdDeadline);
950
+ }
951
+ }
952
+ NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline;
953
+ for (const auto &urlValue : attempt.Get("urls").elements()) {
954
+ NSString *url = PushyFromStdString(urlValue.AsString());
955
+ if (url == nil) {
956
+ continue;
957
+ }
958
+ NSTimeInterval remaining = deadline - PushyMonotonicNow();
959
+ if (remaining <= 0) {
960
+ if (isFullAttempt) {
961
+ return NO;
962
+ }
963
+ break;
964
+ }
965
+ NSMutableDictionary *options =
966
+ [@{
967
+ PushyOptionUrlKey(): url,
968
+ @"hash": hash,
969
+ @"deadlineUptime": @(deadline),
970
+ } mutableCopy];
971
+ if (pushyType == PushyTypePatchFromPpk) {
972
+ options[@"originHash"] = originHash;
973
+ }
974
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
975
+ __block NSError *resultError = nil;
976
+ [RCTPushyInstaller fetch:pushyType options:options progress:nil callback:^(NSError *error) {
977
+ resultError = error;
978
+ dispatch_semaphore_signal(sem);
979
+ }];
980
+ if (!PushyWaitSeconds(sem, remaining)) {
981
+ RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str());
982
+ if (isFullAttempt) {
983
+ return NO;
984
+ }
985
+ break;
986
+ }
987
+ if (resultError == nil) {
988
+ return YES;
989
+ }
990
+ RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@",
991
+ type.c_str(), resultError.localizedDescription);
992
+ if (!flowcore::IsMirrorRetryableCode(
993
+ PushyToStdString(resultError.userInfo[RCTPushyErrorCodeKey]))) {
994
+ break;
995
+ }
996
+ }
997
+ }
998
+ return NO;
999
+ }
1000
+
1001
+ @end