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
@@ -1,965 +1,80 @@
1
- #import "RCTPushy.h"
1
+ #import "RCTPushyInternal.h"
2
2
  #import "RCTPushyConfiguration.h"
3
- #import "RCTPushyDownloader.h"
4
- #import "ZipArchive.h"
5
- #include "../../cpp/patch_core/archive_limits.h"
6
- #include "../../cpp/patch_core/archive_patch_core.h"
7
- #include "../../cpp/patch_core/digest.h"
8
- #include "../../cpp/patch_core/hbc_transform_wire.h"
9
- #include "../../cpp/patch_core/install_record.h"
10
3
  #include "../../cpp/patch_core/error_codes.h"
4
+ #include "../../cpp/patch_core/hbc_transform_wire.h"
11
5
  #include "../../cpp/patch_core/patch_core.h"
12
- #include "../../cpp/patch_core/state_core.h"
13
- #include "../../cpp/update_flow_core/flow_json.h"
14
- #include "../../cpp/update_flow_core/update_flow_core.h"
15
-
16
- #import <UIKit/UIKit.h>
17
6
 
18
7
  // use_frameworks! 构建里 React 头文件只有 <React/…> 框架形式可见,引号形式的
19
- // __has_include 会探测失败;漏掉这里会静默编入 [self.bridge reload] 兜底,而
20
- // bridgeless 下 bridge 是 nil/代理,reload 变成无声 no-op(切版本后不重启)。
8
+ // __has_include 会探测失败;漏掉框架形式会让重启变成无声 no-op(切版本后不
9
+ // 重启),所以两种形式都探测不到时直接编译失败。
21
10
  #if __has_include("RCTReloadCommand.h")
22
11
  #import "RCTReloadCommand.h"
23
- #define PUSHY_HAS_RELOAD_COMMAND 1
24
12
  #elif __has_include(<React/RCTReloadCommand.h>)
25
13
  #import <React/RCTReloadCommand.h>
26
- #define PUSHY_HAS_RELOAD_COMMAND 1
14
+ #else
15
+ #error React Native 0.62 or newer is required: RCTReloadCommand.h was not found
27
16
  #endif
28
17
  #ifdef RCT_NEW_ARCH_ENABLED
29
18
  #import "RCTPushySpec.h"
30
19
  #endif
31
20
 
32
- #import <React/RCTConvert.h>
33
- #import <React/RCTLog.h>
34
- #import <objc/runtime.h>
35
- #import <os/lock.h>
36
-
37
- #include <atomic>
38
- #include <sys/stat.h>
39
-
40
- // Immutable host-facing snapshot; activated always means NEXT launch.
41
- static NSDictionary *PushyHostResult(NSString *status, NSString *reason,
42
- NSString *hash, BOOL activated) {
43
- return @{@"status": status, @"reason": reason ?: @"",
44
- @"hash": hash ?: @"", @"activated": @(activated)};
45
- }
46
-
47
- static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY";
48
- // Binary identity (SyncBinaryVersion input). Prefixed like every other key:
49
- // the unprefixed names collided with generic host-app/SDK defaults, and a
50
- // foreign value there made every launch look like a rebuilt binary and wipe
51
- // the installed update.
52
- static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION";
53
- static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME";
54
- // The unprefixed names earlier SDKs wrote. Read only as a fallback until the
55
- // first state write migrates them (PushyStateFromDefaults /
56
- // PushyApplyStateToDefaults).
57
- static NSString *const legacyParamPackageVersion = @"packageVersion";
58
- static NSString *const legacyParamBuildTime = @"buildTime";
59
- static NSString *const paramLastVersion = @"lastVersion";
60
- static NSString *const paramCurrentVersion = @"currentVersion";
61
- static NSString *const paramIsFirstTime = @"isFirstTime";
62
- static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK";
63
- static NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID";
64
- static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_";
65
- static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY";
66
- static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY";
67
- static NSString *const KeyPackageUpdatedMarked = RCTPushyRevealText("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a");
68
- // bundleHash cache: "<cacheKey>|<sha256hex>" where cacheKey identifies the
69
- // installed binary (packageVersion + embedded bundle size + mtime). Recomputed
70
- // only when the key changes, i.e. once per install.
71
- static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY";
72
- // Raw JSON persisted by JS (syncNativeConfig) for the native cold-start
73
- // update check; parsed on read by the orchestrator. Absent = check disabled.
74
- static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY";
75
- // Raw response cache written by the native cold-start check for the JS side
76
- // to reuse (§10.3), scoped to the request and config that produced it.
77
- static NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY";
78
- // Set when a native check round starts, cleared when it ends (§11.4). Residue
79
- // on the next launch means the previous process died mid-round (a crash
80
- // rescue was truncated): that launch resumes immediately instead of waiting.
81
- static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY";
82
- static NSString *const PushyErrorDomain = @"cn.reactnative.pushy";
83
-
84
- // file def
85
- static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs";
86
- static NSString * const SOURCE_PATCH_NAME = @"__diff.json";
87
- static NSString * const BUNDLE_PATCH_NAME = RCTPushyRevealText("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9");
88
- // Protocol strings and version-info flags read by JS, stored encoded
89
- // (RCTPushyRevealText) so they do not appear as plain binary strings.
90
- static NSString * const PushyOptionUrlKey = RCTPushyRevealText("2f07f0d0ba8e5d572e");
91
- static NSString * const PushyIpaDeltaSuffix = RCTPushyRevealText("741ee4d0e09b69512137");
92
- static NSString * const PushyPpkDeltaSuffix = RCTPushyRevealText("7407e4dae09b69512137");
93
- static NSString * const PushyInfoCrashHold = RCTPushyRevealText("3905f5c2a6b96d56212a19");
94
- static NSString * const PushyInfoForceBoot = RCTPushyRevealText("3c18e6d2aba9674a360d19ead5a695");
95
- static NSString * const PushyStatusNone = RCTPushyRevealText("3418c1c1aa8a7c40");
96
- #define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete"
97
- static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL;
98
-
99
- // error def — messages are human-readable; the stable cross-platform codes
100
- // live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey.
101
- static NSString * const ERROR_OPTIONS = @"options error";
102
- static NSString * const ERROR_FILE_OPERATION = @"file operation error";
103
- static NSString * const PushyErrorCodeKey = @"PushyErrorCode";
104
-
105
- static NSString *PushyCode(const char *code) {
106
- return [NSString stringWithUTF8String:code];
107
- }
108
-
109
- // cInfo.os label prefix; must match the JS side (core.ts) so the server
110
- // buckets tvOS devices apart from iOS ones.
111
- #if TARGET_OS_TV
112
- static NSString * const PushyOsName = @"tvos";
113
- #else
114
- static NSString * const PushyOsName = @"ios";
115
- #endif
116
-
117
21
  // event def
118
22
  static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress";
119
23
  static NSString * const PARAM_PROGRESS_HASH = @"hash";
120
24
  static NSString * const PARAM_PROGRESS_RECEIVED = @"received";
121
25
  static NSString * const PARAM_PROGRESS_TOTAL = @"total";
122
26
 
123
- static NSTimeInterval PushyMonotonicNow(void) {
124
- return [NSProcessInfo processInfo].systemUptime;
125
- }
126
-
127
- static std::string PushyToStdString(NSString *value);
128
-
129
- static NSString *PushyStagingDirForVersionDir(NSString *versionDir) {
130
- return [versionDir stringByAppendingString:@(pushy::install_record::kStagingSuffix)];
131
- }
132
-
133
- // Parsed completion record (cpp/patch_core/install_record.h): nil when the
134
- // file is absent or malformed; an empty dictionary for the legacy empty
135
- // marker written by SDK < 10.53.
136
- static NSDictionary *PushyReadInstallRecord(NSString *versionDir) {
137
- NSString *path = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
138
- NSData *data = [NSData dataWithContentsOfFile:path];
139
- if (data == nil) {
140
- return nil;
141
- }
142
- if (data.length == 0) {
143
- return @{};
144
- }
145
- if (data.length > 64 * 1024) {
146
- return nil;
147
- }
148
- id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
149
- return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil;
150
- }
151
-
152
- // Presence check (launch / dedup paths, no digest work): bundle present and
153
- // the record exists and, unless legacy-empty, names this version.
154
- static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) {
155
- NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
156
- if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) {
157
- return NO;
158
- }
159
- NSDictionary *record = PushyReadInstallRecord(versionDir);
160
- if (record == nil) {
161
- return NO;
162
- }
163
- if (record.count == 0) {
164
- return YES;
165
- }
166
- return [record[@"schema"] isKindOfClass:[NSNumber class]]
167
- && [record[@"schema"] intValue] == pushy::install_record::kSchema
168
- && [record[@"versionHash"] isKindOfClass:[NSString class]]
169
- && [record[@"versionHash"] isEqualToString:hash];
170
- }
171
-
172
- // Activation check: re-hashes the bundle when the record carries a digest.
173
- // Returns nil when the directory may be activated, the reason otherwise.
174
- static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash) {
175
- NSDictionary *record = PushyReadInstallRecord(versionDir);
176
- if (record == nil) {
177
- return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash];
178
- }
179
- if (record.count == 0) {
180
- return nil;
181
- }
182
- if (![record[@"schema"] isKindOfClass:[NSNumber class]]
183
- || [record[@"schema"] intValue] != pushy::install_record::kSchema
184
- || ![record[@"versionHash"] isKindOfClass:[NSString class]]
185
- || ![record[@"versionHash"] isEqualToString:hash]) {
186
- return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash];
187
- }
188
- NSString *expected = record[@"bundleSha256"];
189
- if (![expected isKindOfClass:[NSString class]] || expected.length == 0) {
190
- return nil;
191
- }
192
- std::string actual = pushy::digest::Sha256File(
193
- PushyToStdString([versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]));
194
- if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) {
195
- return nil;
196
- }
197
- return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash];
198
- }
199
-
200
- // SHA-256 of each in-flight download's archive, keyed by hash; consumed by
201
- // the completion record write. Guarded by @synchronized on the table.
202
- static NSMutableDictionary<NSString *, NSString *> *PushyArtifactDigests(void) {
203
- static NSMutableDictionary *table;
204
- static dispatch_once_t once;
205
- dispatch_once(&once, ^{
206
- table = [NSMutableDictionary dictionary];
207
- });
208
- return table;
209
- }
210
-
211
- static NSError *PushyErrorWithCode(const char *code, NSString *message);
212
-
213
- // nil when the volume holding `path` can take `bytesToWrite` plus the safety
214
- // margin (or the free space is unknown); a PATCH_FAILED error otherwise. The
215
- // probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the
216
- // same rule to the announced Content-Length before the first byte lands.
217
- static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) {
218
- NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite);
219
- if (shortfall == nil) {
220
- return nil;
221
- }
222
- return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall);
223
- }
224
-
225
- // SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the
226
- // archive is walked (entry count, per-entry size, total size, compression
227
- // ratio) plus two entry-shape rules Android's java.util.zip gets for free:
228
- // - no symlinks: SSZipArchive materialises them with any target regardless
229
- // of preserveAttributes, so `assets -> ../../Documents` followed by
230
- // `assets/x` would write outside the version directory;
231
- // - no ".pushy-" reserved names at any depth: the completion marker is
232
- // written by this SDK only, after the whole install succeeded. An archive
233
- // shipping its own would be trusted as a finished install even if the
234
- // patch step that follows fails.
235
- // A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the
236
- // 2.x contract the podspec pins), and the completion handler turns the
237
- // recorded violation into a hard PATCH_FAILED; the caller then discards the
238
- // staging directory.
239
- @interface PushyUnzipGuard : NSObject <SSZipArchiveDelegate>
240
- @property (nonatomic, copy) NSString *violation;
241
- @property (nonatomic, assign) long long totalUncompressed;
242
- @end
243
-
244
- @implementation PushyUnzipGuard
245
-
246
- - (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo {
247
- if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) {
248
- self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)",
249
- (unsigned long)zipInfo.number_entry];
250
- }
251
- }
252
-
253
- - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex
254
- totalFiles:(NSInteger)totalFiles
255
- archivePath:(NSString *)archivePath
256
- fileInfo:(unz_file_info)fileInfo {
257
- if (self.violation != nil) {
258
- return NO;
259
- }
260
- // Unix mode bits travel in the high 16 bits of the external attributes
261
- // (the same field SSZipArchive keys its own symlink branch on).
262
- if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) {
263
- self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink",
264
- (long)fileIndex];
265
- return NO;
266
- }
267
- long long size = (long long)fileInfo.uncompressed_size;
268
- long long compressed = (long long)fileInfo.compressed_size;
269
- if (size > pushy::archive_limits::kMaxEntryBytes) {
270
- self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)",
271
- (long)fileIndex, size];
272
- return NO;
273
- }
274
- self.totalUncompressed += size;
275
- if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) {
276
- self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes",
277
- pushy::archive_limits::kMaxTotalUncompressedBytes];
278
- return NO;
279
- }
280
- if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes
281
- && size / compressed > pushy::archive_limits::kMaxCompressionRatio) {
282
- self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high",
283
- (long)fileIndex];
284
- return NO;
285
- }
286
- return YES;
287
- }
288
-
289
- // The entry name is only exposed once the entry is on disk (shouldUnzip
290
- // runs before the name is read). A reserved name is flagged here; the next
291
- // shouldUnzip refuses (cancelling the unzip) or, for the last entry, the
292
- // completion handler picks the violation up. Either way the caller drops
293
- // the whole staging directory, so the written file never survives.
294
- - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex
295
- totalFiles:(NSInteger)totalFiles
296
- archivePath:(NSString *)archivePath
297
- unzippedFilePath:(NSString *)unzippedFilePath {
298
- NSString *name = unzippedFilePath.lastPathComponent;
299
- if (self.violation == nil && [name hasPrefix:@".pushy-"]) {
300
- self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name];
301
- }
302
- }
303
-
304
- @end
305
-
306
- static NSError *PushyDownloadDeadlineExpiredError(void) {
307
- return [NSError errorWithDomain:PushyErrorDomain
308
- code:-1
309
- userInfo:@{
310
- NSLocalizedDescriptionKey: @"download deadline expired before start",
311
- PushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed),
312
- }];
313
- }
314
-
315
-
316
- typedef NS_ENUM(NSInteger, PushyType) {
317
- PushyTypeFullDownload = 1,
318
- PushyTypePatchFromPackage = 2,
319
- PushyTypePatchFromPpk = 3,
320
- };
321
-
322
- static std::atomic<bool> ignoreRollback{false};
323
27
  // Whether the host asked +bundleURL for the bundle to load (the
324
28
  // isUsingBundleUrl constant, mirroring Android/Harmony). NO means the app
325
29
  // runs a bundle this SDK never resolved, so updates cannot take effect.
326
30
  static std::atomic<bool> pushyIsUsingBundleUrl{false};
327
- // Bumped by resetToPackagedBundle. The cold-start check runs for minutes and
328
- // may already hold a decision when the app resets to the packaged bundle; it
329
- // samples this counter and abandons activation (and its response cache) when
330
- // the value moved, so an in-flight rescue can never resurrect the version the
331
- // app just reset away from.
332
- static std::atomic<uint64_t> pushyResetGeneration{0};
333
- // The version whose bundle this process actually loaded (resolved in
334
- // +bundleURL). resetToPackagedBundle must not delete its directory: update
335
- // assets (images/fonts) are read from it on demand at runtime, so wiping it
336
- // under a silent (no-restart) reset would break every image the running app
337
- // has not loaded yet. Guarded by the state lock.
338
- static NSString *pushyLaunchVersion = nil;
339
31
 
340
- // JS and the bridge-free cold-start engine use different RCTPushy instances,
341
- // but they must still share one download per target hash. Without this
342
- // process-wide registry two NSURLSessionDownloadTasks race over the same
343
- // archive path and the later unzip can delete the first task's valid output.
344
- static NSMutableDictionary<NSString *, NSMutableDictionary *> *PushyInFlightDownloads(void) {
345
- static NSMutableDictionary<NSString *, NSMutableDictionary *> *downloads;
346
- static dispatch_once_t onceToken;
347
- dispatch_once(&onceToken, ^{
348
- downloads = [NSMutableDictionary dictionary];
32
+ // Triggers a JS reload. React Native re-resolves the bundle URL itself on
33
+ // reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp re-asks
34
+ // sourceURLForBridge:), so +bundleURL is deliberately NOT called here: it
35
+ // consumes one-shot launch state (first_time, the first-load mark,
36
+ // ignoreRollback), and consuming it for a reload that never happens would
37
+ // make the next cold start roll the freshly switched version back.
38
+ static void PushyTriggerReload(NSString *reason) {
39
+ dispatch_async(dispatch_get_main_queue(), ^{
40
+ RCTTriggerReloadCommandListeners(reason);
349
41
  });
350
- return downloads;
351
- }
352
-
353
- typedef NS_ENUM(NSInteger, PushyDownloadRegistration) {
354
- PushyDownloadRegistrationOwner,
355
- PushyDownloadRegistrationJoined,
356
- PushyDownloadRegistrationDeferred,
357
- };
358
-
359
- static PushyDownloadRegistration PushyRegisterDownload(
360
- NSString *hash,
361
- NSInteger type,
362
- NSTimeInterval deadlineUptime,
363
- void (^callback)(NSError *),
364
- void (^progress)(long long, long long),
365
- void (^deferredStart)(void)
366
- ) {
367
- NSMutableDictionary *downloads = PushyInFlightDownloads();
368
- @synchronized (downloads) {
369
- NSMutableDictionary *entry = downloads[hash];
370
- if (entry != nil) {
371
- if ([entry[@"type"] integerValue] == type) {
372
- NSTimeInterval ownerDeadline = [entry[@"deadlineUptime"] doubleValue];
373
- // A caller with substantially more time must not inherit an
374
- // owner's nearly-exhausted timeout: it observes the current
375
- // transfer and restarts after the owner settles (the
376
- // completion-marker preflight makes a successful owner free).
377
- // The comparison is on remaining budget, not on the absolute
378
- // deadline: a JS caller always computes now+600 a few seconds
379
- // after the owner did, so a strict `>` would defer every
380
- // second caller and turn the shared download back into
381
- // serialized re-downloads. Only a genuinely starved owner
382
- // (less than half the newcomer's budget left) defers.
383
- const NSTimeInterval now = PushyMonotonicNow();
384
- if (2 * (ownerDeadline - now) < (deadlineUptime - now)) {
385
- if (progress != nil) {
386
- [entry[@"progress"] addObject:[progress copy]];
387
- }
388
- [entry[@"deferred"] addObject:[deferredStart copy]];
389
- return PushyDownloadRegistrationDeferred;
390
- }
391
- [entry[@"callbacks"] addObject:[callback copy]];
392
- if (progress != nil) {
393
- [entry[@"progress"] addObject:[progress copy]];
394
- }
395
- return PushyDownloadRegistrationJoined;
396
- }
397
- // A diff failure must not settle a joined full request. Queue the
398
- // different artifact type behind the owner; once restarted it
399
- // registers normally (and re-checks the completion marker).
400
- if (progress != nil) {
401
- // The artifact type differs, but it still installs the same
402
- // target hash. Keep the waiting JS UI moving while its own
403
- // transfer is queued behind the current owner.
404
- [entry[@"progress"] addObject:[progress copy]];
405
- }
406
- [entry[@"deferred"] addObject:[deferredStart copy]];
407
- return PushyDownloadRegistrationDeferred;
408
- }
409
- NSMutableArray *progressHandlers = [NSMutableArray array];
410
- if (progress != nil) {
411
- [progressHandlers addObject:[progress copy]];
412
- }
413
- downloads[hash] = [@{
414
- @"type": @(type),
415
- @"deadlineUptime": @(deadlineUptime),
416
- @"callbacks": [NSMutableArray arrayWithObject:[callback copy]],
417
- @"progress": progressHandlers,
418
- @"deferred": [NSMutableArray array],
419
- } mutableCopy];
420
- return PushyDownloadRegistrationOwner;
421
- }
422
- }
423
-
424
- static void PushyReportDownloadProgress(
425
- NSString *hash, long long received, long long total
426
- ) {
427
- NSMutableDictionary *downloads = PushyInFlightDownloads();
428
- NSArray *handlers = nil;
429
- @synchronized (downloads) {
430
- handlers = [downloads[hash][@"progress"] copy];
431
- }
432
- for (id value in handlers) {
433
- void (^handler)(long long, long long) =
434
- (void (^)(long long, long long))value;
435
- handler(received, total);
436
- }
437
- }
438
-
439
- static void PushyFinishDownload(NSString *hash, NSError *error) {
440
- NSMutableDictionary *downloads = PushyInFlightDownloads();
441
- NSArray *callbacks = nil;
442
- NSArray *deferred = nil;
443
- @synchronized (downloads) {
444
- callbacks = [downloads[hash][@"callbacks"] copy];
445
- deferred = [downloads[hash][@"deferred"] copy];
446
- [downloads removeObjectForKey:hash];
447
- }
448
- // Establish the next (different-type) owner before waking the completed
449
- // owner's callbacks; otherwise its strategy loop could race the waiter.
450
- for (id value in deferred) {
451
- void (^start)(void) = (void (^)(void))value;
452
- start();
453
- }
454
- for (id value in callbacks) {
455
- void (^callback)(NSError *) = (void (^)(NSError *))value;
456
- callback(error);
457
- }
458
- }
459
-
460
- // Serializes every read-modify-write of the persisted update state. The state
461
- // machine itself is a pure function (state_core), but callers run on different
462
- // threads (main thread bundleURL, module method queue, _fileQueue), so the
463
- // read→transform→write sequence must be atomic to avoid e.g. markSuccess being
464
- // overwritten by a concurrent bundleURL and the version being rolled back.
465
- static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT;
466
-
467
- static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) {
468
- #if DEBUG
469
- // Not re-entrant: a nested acquisition deadlocks in release, so catch it
470
- // where it is written.
471
- os_unfair_lock_assert_not_owner(&pushyStateLock);
472
- #endif
473
- os_unfair_lock_lock(&pushyStateLock);
474
- @try {
475
- block();
476
- } @finally {
477
- os_unfair_lock_unlock(&pushyStateLock);
478
- }
479
- }
480
-
481
- static std::string PushyToStdString(NSString *value) {
482
- if (value == nil) {
483
- return std::string();
484
- }
485
- return std::string([value UTF8String]);
486
- }
487
-
488
- static NSError *PushyNSErrorFromStatus(const pushy::delta::Status &status) {
489
- return [NSError errorWithDomain:PushyErrorDomain
490
- code:-1
491
- userInfo:@{
492
- NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()],
493
- PushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed),
494
- }];
495
- }
496
-
497
- static NSUserDefaults *PushyDefaults(void) {
498
- return [NSUserDefaults standardUserDefaults];
499
- }
500
-
501
- static NSString *PushyFromStdString(const std::string &value) {
502
- if (value.empty()) {
503
- return nil;
504
- }
505
- return [NSString stringWithUTF8String:value.c_str()];
506
- }
507
-
508
- static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) {
509
- if (value != nil) {
510
- [defaults setObject:value forKey:key];
511
- } else {
512
- [defaults removeObjectForKey:key];
513
- }
514
- }
515
-
516
- static NSString *PushyHashInfoKey(NSString *hash) {
517
- return [keyHashInfo stringByAppendingString:hash ?: @""];
518
- }
519
-
520
- static NSString *PushyOptionString(NSDictionary *options, NSString *key) {
521
- return [RCTConvert NSString:options[key]];
522
- }
523
-
524
- static BOOL PushyStringIsBlank(NSString *value) {
525
- if (value == nil || [value isKindOfClass:[NSNull class]]) {
526
- return YES;
527
- }
528
- return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0;
529
- }
530
-
531
- // Server-provided identifiers (hash/originHash) are used as child names under
532
- // the download root; anything that could resolve outside of it (path
533
- // separators, "..", ".") must be rejected before touching the filesystem.
534
- static BOOL PushyIsSafePathComponent(NSString *value) {
535
- if (PushyStringIsBlank(value)) {
536
- return NO;
537
- }
538
- if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) {
539
- return NO;
540
- }
541
- if ([value containsString:@"/"] || [value containsString:@"\\"]) {
542
- return NO;
543
- }
544
- if ([value rangeOfString:@"\0"].location != NSNotFound) {
545
- return NO;
546
- }
547
- return YES;
548
- }
549
-
550
- static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) {
551
- // Prefer the stable cross-platform code (error_codes.h); fall back to the
552
- // numeric NSError code for system errors that were not classified.
553
- NSString *code = error.userInfo[PushyErrorCodeKey];
554
- if (code == nil) {
555
- code = [NSString stringWithFormat:@"%ld", (long)error.code];
556
- }
557
- reject(code, error.localizedDescription, error);
558
- }
559
-
560
- static NSError *PushyErrorWithCode(const char *code, NSString *message) {
561
- return [NSError errorWithDomain:PushyErrorDomain
562
- code:-1
563
- userInfo:@{
564
- NSLocalizedDescriptionKey: message ?: @"unknown error",
565
- PushyErrorCodeKey: PushyCode(code),
566
- }];
567
- }
568
-
569
- static BOOL PushyJsonIsAbsent(id value) {
570
- return value == nil || [value isKindOfClass:[NSNull class]];
571
- }
572
-
573
- // Builds the patch-core manifest from a parsed __diff.json. Every field is
574
- // type-checked: this runs inside a GCD block on _fileQueue, where the
575
- // unrecognized-selector exception a `copies` array or a numeric `from` would
576
- // raise takes the whole app down (and trips the crash rescue) instead of
577
- // failing the patch. NO with `reason` set for a malformed manifest.
578
- static BOOL PushyPatchManifestFromJson(NSDictionary *json,
579
- pushy::delta::PatchManifest *manifest,
580
- NSString **reason) {
581
- id copies = json[@"copies"];
582
- if (!PushyJsonIsAbsent(copies)) {
583
- if (![copies isKindOfClass:[NSDictionary class]]) {
584
- *reason = @"delta manifest: copies is not an object";
585
- return NO;
586
- }
587
- // Content checksum per copy target ("copiesCrc", pdiff manifests
588
- // from CLI >= 2.21.2). The patch core verifies the copy source
589
- // against it and fails the patch on mismatch, so the JS strategy
590
- // chain falls back to the full package instead of copying drifted
591
- // bytes from a rebuilt binary.
592
- NSDictionary *copiesCrc = json[@"copiesCrc"];
593
- if (![copiesCrc isKindOfClass:[NSDictionary class]]) {
594
- copiesCrc = nil;
595
- }
596
- for (id to in (NSDictionary *)copies) {
597
- id from = ((NSDictionary *)copies)[to];
598
- if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) {
599
- *reason = @"delta manifest: copies entry is not a string";
600
- return NO;
601
- }
602
- if ([from length] == 0) {
603
- from = to;
604
- }
605
- pushy::delta::CopyOperation operation;
606
- operation.from = PushyToStdString(from);
607
- operation.to = PushyToStdString(to);
608
- NSNumber *expectedCrc = copiesCrc[to];
609
- if ([expectedCrc isKindOfClass:[NSNumber class]]) {
610
- operation.has_expected_crc = true;
611
- operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue];
612
- }
613
- manifest->copies.push_back(operation);
614
- }
615
- }
616
-
617
- // The CLI emits `deletes` as an object keyed by path (Android reads its
618
- // keys); a plain array of paths is accepted as well, like Harmony.
619
- id deletes = json[@"deletes"];
620
- if (!PushyJsonIsAbsent(deletes)) {
621
- if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) {
622
- *reason = @"delta manifest: deletes is not an object or array";
623
- return NO;
624
- }
625
- for (id path in deletes) {
626
- if (![path isKindOfClass:[NSString class]]) {
627
- *reason = @"delta manifest: deletes entry is not a string";
628
- return NO;
629
- }
630
- manifest->deletes.push_back(PushyToStdString(path));
631
- }
632
- }
633
- return YES;
634
- }
635
-
636
- // Binary identity under the prefixed key, or — until the first state write
637
- // migrates it — the value an earlier SDK left under the unprefixed one, so an
638
- // installed update survives the SDK upgrade instead of reading as a rebuilt
639
- // binary.
640
- static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) {
641
- return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey];
642
- }
643
-
644
- static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) {
645
- pushy::state::State state;
646
- state.package_version = PushyToStdString(
647
- PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion));
648
- state.build_time = PushyToStdString(
649
- PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime));
650
- NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo];
651
- if (pushyInfo != nil) {
652
- state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]);
653
- state.last_version = PushyToStdString(pushyInfo[paramLastVersion]);
654
- state.first_time = [pushyInfo[paramIsFirstTime] boolValue];
655
- id firstLoadOk = pushyInfo[paramIsFirstLoadOk];
656
- state.first_time_ok = firstLoadOk == nil ? true : [firstLoadOk boolValue];
657
- }
658
- state.rolled_back_version = PushyToStdString([defaults stringForKey:keyRolledBackMarked]);
659
- return state;
660
- }
661
-
662
- static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) {
663
- PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version));
664
- PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time));
665
- // One-time migration: once the prefixed keys hold the identity, the
666
- // legacy ones are retired so a host-app write there is never read again.
667
- [defaults removeObjectForKey:legacyParamPackageVersion];
668
- [defaults removeObjectForKey:legacyParamBuildTime];
669
-
670
- BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok;
671
- if (hasPushyInfo) {
672
- NSMutableDictionary *newInfo = [[NSMutableDictionary alloc] init];
673
- if (!state.current_version.empty()) {
674
- newInfo[paramCurrentVersion] = PushyFromStdString(state.current_version);
675
- }
676
- if (!state.last_version.empty()) {
677
- newInfo[paramLastVersion] = PushyFromStdString(state.last_version);
678
- }
679
- newInfo[paramIsFirstTime] = @(state.first_time);
680
- newInfo[paramIsFirstLoadOk] = @(state.first_time_ok);
681
- [defaults setObject:newInfo forKey:keyPushyInfo];
682
- } else {
683
- [defaults removeObjectForKey:keyPushyInfo];
684
- }
685
-
686
- PushySetNullableString(
687
- defaults,
688
- keyRolledBackMarked,
689
- PushyFromStdString(state.rolled_back_version));
690
42
  }
691
43
 
692
- // Version switch without acquiring the state lock: the caller must already
693
- // hold it. Lets the cold-start check commit its whole result (version info,
694
- // switch, response cache) inside one lock acquisition, so resetToPackagedBundle
695
- // can never interleave between the generation check and the writes.
696
- static void PushySwitchVersionLocked(NSString *hash) {
697
- NSUserDefaults *defaults = PushyDefaults();
698
- pushy::state::State next = pushy::state::SwitchVersion(
699
- PushyStateFromDefaults(defaults),
700
- PushyToStdString(hash)
701
- );
702
- PushyApplyStateToDefaults(defaults, next);
703
- // Re-enable first-load consumption and rollback checks for the newly selected bundle.
704
- ignoreRollback = false;
44
+ // A promise rejection for an exception caught around a state transition.
45
+ static void PushyRejectException(RCTPromiseRejectBlock reject, const char *code,
46
+ NSException *exception, NSString *fallback) {
47
+ PushyRejectError(reject, PushyErrorWithCode(code, exception.reason ?: fallback));
705
48
  }
706
49
 
707
- @interface RCTPushy ()
708
- - (void)fetchPackage:(PushyType)type
709
- options:(NSDictionary *)options
710
- resolver:(RCTPromiseResolveBlock)resolve
711
- rejecter:(RCTPromiseRejectBlock)reject;
712
- - (void)performFetch:(PushyType)type
713
- options:(NSDictionary *)options
714
- callback:(void (^)(NSError *error))callback;
715
- - (NSError *)restartBridgeWithReason:(NSString *)reason;
716
- - (void)unzipDownloadedPackage:(NSString *)zipFilePath
717
- hash:(NSString *)hash
718
- type:(PushyType)type
719
- originHash:(NSString *)originHash
720
- callback:(void (^)(NSError *error))callback;
721
- - (void)finishDownloadedPackage:(NSString *)hash
722
- type:(PushyType)type
723
- originHash:(NSString *)originHash
724
- callback:(void (^)(NSError *error))callback;
725
- - (void)applyDeltaForHash:(NSString *)hash
726
- type:(PushyType)type
727
- fromBundle:(NSString *)bundleOrigin
728
- source:(NSString *)sourceOrigin
729
- callback:(void (^)(NSError *error))callback;
730
- - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error;
731
- - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path;
732
- + (void)excludeFromBackup:(NSString *)path;
733
- - (void)unzipFileAtPath:(NSString *)path
734
- toDestination:(NSString *)destination
735
- completionHandler:(void (^)(NSError *error))completionHandler;
736
- + (NSString *)downloadDir;
737
- + (NSURL *)binaryBundleURL;
738
- + (NSString *)packageVersion;
739
- + (NSString *)buildTime;
740
- @end
741
-
742
- // Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once
743
- // per process, a few seconds after launch, entirely independent of the app
744
- // bundle — this is what lets a bricked hot update be replaced on the next
745
- // launch. Decisions come from cpp/update_flow_core; this class is IO glue.
746
- @interface RCTPushyOrchestrator : NSObject
747
- + (void)persistConfiguration:(NSString *)config;
748
- + (BOOL)hasRunnableConfig;
749
- + (NSDictionary *)prepareBundle;
750
- + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion;
751
- #if TARGET_OS_TV && !DEBUG
752
- + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion
753
- rolledBack:(NSString *)launchRolledBackVersion;
754
- #endif
755
- + (void)recordJsRound:(NSString *)config;
756
- + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime;
757
- + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime;
758
- + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime;
759
- + (BOOL)commitRoundWithGeneration:(uint64_t)generation
760
- hashInfo:(NSDictionary *)hashInfoEntry
761
- activate:(NSString *)hashToActivate
762
- responseText:(NSString *)responseText
763
- request:(NSString *)requestBody
764
- config:(NSString *)configJson
765
- responseAt:(long long)responseAtSeconds
766
- activated:(BOOL *)activatedOut;
767
- @end
768
-
769
- // One round per process, whoever starts it first — the delayed cold-start
770
- // path or the crash-rescue thread (§11.3). The semaphore lets the rescue
771
- // wait out an in-flight round instead of racing it.
772
- static std::atomic<bool> pushyRoundStarted{false};
773
- static std::atomic<bool> pushyRoundCompleted{false};
774
- // Flipped the moment a crash is being held. JS is dead from that point on,
775
- // so there is no second decision maker: the round force-activates whatever
776
- // it downloads (§11.3).
777
- static std::atomic<bool> pushyCrashHoldActive{false};
778
- static std::atomic<bool> pushyHoldAttempted{false};
779
- // Set when this process's round is the tvOS purged-version restore.
780
- static std::atomic<bool> pushyPurgeRestoreActive{false};
781
- // True while +bundleURL waits for that restore. Only then may the round
782
- // activate what it downloads on its own (JS has not started to decide), since
783
- // the launch resolves the bundle again afterwards. Closed once the wait ends,
784
- // timeout or not; guarded by the state lock that every activation commits
785
- // under, so a commit lands either before the close (and launches) or after it
786
- // (and is left to JS) — never selected behind the packaged bundle's back.
787
- static bool pushyPurgeRestoreWindowOpen = false;
788
- static dispatch_semaphore_t pushyRoundDone;
789
- static NSString *pushyLaunchRolledBackForHold = nil;
790
- // A version this process downloaded but left for JS to activate. If the
791
- // process then crashes, JS will never activate it — the crash handler
792
- // activates it directly (bounded local work, no network). The generation is
793
- // the one the round committed under: a reset that lands afterwards bumps it,
794
- // and the late activation must lose to that reset exactly like the round
795
- // itself would. Guarded by @synchronized (RCTPushyOrchestrator class).
796
- static NSString *pushyUnactivatedHash = nil;
797
- static uint64_t pushyUnactivatedGeneration = 0;
798
- static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL;
799
- // ≈ process start: scheduleFromColdStart runs during the first bundleURL.
800
- static NSTimeInterval pushyProcessAnchorUptime = 0;
801
- // Config JSON for which JS reported a completed check in this process
802
- // (markJsCheckCompleted). Process-scoped by design. Guarded by
803
- // @synchronized (RCTPushyOrchestrator class).
804
- static NSString *pushyJsCompletedConfig = nil;
805
- // The group supplements (rather than consumes) the crash-rescue semaphore.
806
- static dispatch_group_t pushyHostRoundGroup;
807
- static std::atomic<bool> pushySyncReady{false};
808
- static NSDictionary *pushyHostRoundResult = nil;
809
- static NSString *pushyHostRoundConfig = nil;
810
- static uint64_t pushyHostRoundGeneration = 0;
811
- static std::atomic<uint64_t> pushyNativeConfigGeneration{0};
812
- static uint64_t pushyHostRoundConfigGeneration = 0;
813
-
814
- static const NSTimeInterval kPushyHoldTriggerUptime = 60;
815
- static const NSTimeInterval kPushyHoldBudgetBackgroundThread = 10;
816
- // A held main thread freezes UI teardown; stay well under the watchdog.
817
- static const NSTimeInterval kPushyHoldBudgetMainThread = 3.5;
818
- // The purged-version restore blocks +bundleURL, usually inside
819
- // application:didFinishLaunching: — stay clear of the launch watchdog.
820
- static const NSTimeInterval kPushyPurgeRestoreBudget = 12;
821
-
822
- static void PushyMaybeHoldProcess(void) {
823
- // Once per process; a second crashing thread passes straight through
824
- // instead of waiting behind the first (§11.3: prefer under-rescuing over
825
- // wedging the teardown).
826
- bool expected = false;
827
- if (!pushyHoldAttempted.compare_exchange_strong(expected, true)) {
828
- return;
829
- }
830
- NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime;
831
- bool roundInFlight = pushyRoundStarted.load() && !pushyRoundCompleted.load();
832
- // Early crashes are the brick signature; a crash with an in-flight round
833
- // is worth finishing regardless of uptime. Everything else is an ordinary
834
- // crash whose UX must not be delayed.
835
- if (uptime >= kPushyHoldTriggerUptime && !roundInFlight) {
836
- return;
837
- }
838
- NSTimeInterval budget = [NSThread isMainThread]
839
- ? kPushyHoldBudgetMainThread
840
- : kPushyHoldBudgetBackgroundThread;
841
- NSTimeInterval deadline = PushyMonotonicNow() + budget;
842
- NSLog(@"RCTPushy -- crash hold: holding process for up to %.1fs "
843
- @"(uptime %.1fs)", budget, uptime);
844
-
845
- // The rescue runs on its own queue and the dying thread only waits with a
846
- // hard timeout: even a deadlocked rescue (a thread that died holding a
847
- // lock — uncaught ObjC exceptions skip @finally) can only delay the
848
- // process death, never prevent it.
849
- dispatch_semaphore_t done = dispatch_semaphore_create(0);
850
- dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
50
+ // Host preparation runs off the caller's thread; the completion is delivered
51
+ // on the main queue. Calls run concurrently so each timeout counts from its
52
+ // own call: a serial queue would hold a later prepareBundle(timeout:) behind an
53
+ // earlier call's whole wait. Nothing here needs serializing — claimRound is an
54
+ // atomic compare-exchange and the round group (entered once at setup) releases
55
+ // every waiter when the round ends.
56
+ static void PushyRunHostPreparation(NSDictionary *(^work)(void),
57
+ RCTPushyBundlePreparationCompletion completion) {
58
+ static dispatch_queue_t hostQueue =
59
+ dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT);
60
+ dispatch_async(hostQueue, ^{
61
+ NSDictionary *result;
851
62
  @try {
852
- [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline];
63
+ result = work();
853
64
  } @catch (NSException *exception) {
854
- NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason);
855
- }
856
- dispatch_semaphore_signal(done);
857
- });
858
- if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW,
859
- (int64_t)(budget * NSEC_PER_SEC))) != 0) {
860
- NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go");
861
- }
862
- }
863
-
864
- // Crash-moment brick rescue (§11): when the app is dying of an uncaught
865
- // exception during startup (RCTFatal raises NSException in release), the
866
- // process is still alive and JS will never run again — a natural,
867
- // false-positive-free window to finish the cold-start check. The previous
868
- // handler (crash reporters chain the same way) always runs afterwards.
869
- static void PushyCrashHoldExceptionHandler(NSException *exception) {
870
- @try {
871
- PushyMaybeHoldProcess();
872
- } @catch (NSException *inner) {
873
- // The dying process owes the previous handler its turn no matter
874
- // what the rescue did.
875
- }
876
- if (pushyPreviousExceptionHandler != NULL) {
877
- pushyPreviousExceptionHandler(exception);
878
- }
879
- }
880
-
881
- static void PushyInstallCrashHoldHandler(void) {
882
- static dispatch_once_t onceToken;
883
- dispatch_once(&onceToken, ^{
884
- pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler();
885
- NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler);
886
- });
887
- }
888
-
889
- // Single-flight for the digest in PushyBundleHashSync: the delayed cold-start
890
- // round (utility queue) and JS getBundleHash (_fileQueue) can both miss the
891
- // cache right after an install; the second caller waits for the first and
892
- // then reads its cache entry instead of hashing the same multi-MB file twice.
893
- static NSLock *PushyBundleHashLock(void) {
894
- static NSLock *lock;
895
- static dispatch_once_t once;
896
- dispatch_once(&once, ^{
897
- lock = [NSLock new];
898
- });
899
- return lock;
900
- }
901
-
902
- static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) {
903
- NSString *cached = [defaults stringForKey:keyBundleHashCache];
904
- if ([cached hasPrefix:cachedPrefix]) {
905
- return [cached substringFromIndex:cachedPrefix.length];
906
- }
907
- return nil;
908
- }
909
-
910
- // Shared by the getBundleHash RCT method and the native cold-start check.
911
- // Returns @"" when unknown. With computeIfMissing the sha256 of the embedded
912
- // bundle is taken on a cache miss (once per install; blocking — call off the
913
- // main thread). Without it only the cache is consulted: a crash rescue on
914
- // the main thread has a 3.5s budget in total, and hashing a large bundle on
915
- // a fresh install would spend all of it before the first request.
916
- static NSString *PushyBundleHashSync(BOOL computeIfMissing) {
917
- NSString *path = [[RCTPushy binaryBundleURL] path];
918
- if (path == nil) {
919
- return @"";
920
- }
921
- NSDictionary *attributes =
922
- [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
923
- if (attributes == nil) {
924
- return @"";
925
- }
926
- NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f",
927
- [RCTPushy packageVersion],
928
- attributes.fileSize,
929
- [attributes.fileModificationDate timeIntervalSince1970]];
930
-
931
- NSUserDefaults *defaults = PushyDefaults();
932
- NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"];
933
- NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix);
934
- if (cached != nil) {
935
- return cached;
936
- }
937
- if (!computeIfMissing) {
938
- return @"";
939
- }
940
-
941
- NSLock *lock = PushyBundleHashLock();
942
- [lock lock];
943
- @try {
944
- // The previous holder may have filled the cache while we waited.
945
- cached = PushyCachedBundleHash(defaults, cachedPrefix);
946
- if (cached != nil) {
947
- return cached;
65
+ RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason);
66
+ result = PushyHostResult(@"failed", @"internal_error");
948
67
  }
949
- NSString *hash = PushyFromStdString(
950
- pushy::digest::Sha256File(PushyToStdString(path))) ?: @"";
951
- if (hash.length > 0) {
952
- [defaults setObject:[cachedPrefix stringByAppendingString:hash]
953
- forKey:keyBundleHashCache];
68
+ if (completion != nil) {
69
+ NSDictionary *snapshot = [result copy];
70
+ dispatch_async(dispatch_get_main_queue(), ^{
71
+ completion(snapshot);
72
+ });
954
73
  }
955
- return hash;
956
- } @finally {
957
- [lock unlock];
958
- }
74
+ });
959
75
  }
960
76
 
961
77
  @implementation RCTPushy {
962
- dispatch_queue_t _fileQueue;
963
78
  bool hasListeners;
964
79
  }
965
80
 
@@ -994,134 +109,14 @@ RCT_EXPORT_MODULE(RCTPushy);
994
109
  }
995
110
  }
996
111
 
997
- // Resolves the bundle to launch from the persisted state; nil means the
998
- // packaged bundle. Consumes the launch's one-shot state (first_time), so a
999
- // second call is only valid after something re-armed it (a switchVersion).
1000
- // purgedVersion is set on tvOS when the installed version's files are gone.
1001
- + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut
1002
- purgedVersion:(NSString **)purgedVersionOut
1003
- {
1004
- __block NSURL *resolvedURL = nil;
1005
- __block NSString *launchRolledBackVersion = nil;
1006
- __block NSString *purgedVersion = nil;
1007
- @try {
1008
- PushyWithStateLock(^{
1009
- NSUserDefaults *defaults = PushyDefaults();
1010
-
1011
- NSString *curPackageVersion = [RCTPushy packageVersion];
1012
- NSString *curBuildTime = [RCTPushy buildTime];
1013
-
1014
- pushy::state::State state = PushyStateFromDefaults(defaults);
1015
- pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion(
1016
- state,
1017
- PushyToStdString(curPackageVersion),
1018
- PushyToStdString(curBuildTime)
1019
- );
1020
- if (sync.changed) {
1021
- [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked];
1022
- state = sync.state;
1023
- PushyApplyStateToDefaults(defaults, state);
1024
- }
1025
-
1026
- if (!state.current_version.empty()) {
1027
- std::string const versionBeforeLaunch = state.current_version;
1028
- pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(
1029
- state,
1030
- ignoreRollback.load(),
1031
- true
1032
- );
1033
- state = decision.state;
1034
-
1035
- if (decision.did_rollback) {
1036
- // The crash-protection rollback: the new version never called
1037
- // markSuccess. Keep this visible in release logs.
1038
- RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@",
1039
- PushyFromStdString(versionBeforeLaunch),
1040
- PushyFromStdString(state.current_version));
1041
- }
1042
- if (decision.did_rollback || decision.consumed_first_time) {
1043
- PushyApplyStateToDefaults(defaults, state);
1044
- }
1045
- if (decision.consumed_first_time) {
1046
- // bundleURL may be called many times, ignore rollbacks before process restarted again.
1047
- ignoreRollback = true;
1048
- [defaults setObject:@(YES) forKey:keyFirstLoadMarked];
1049
- }
1050
-
1051
- NSString *loadVersion = PushyFromStdString(decision.load_version);
1052
- NSString *downloadDir = [RCTPushy downloadDir];
1053
- // Guard the rollback chain against cycles: a corrupted state
1054
- // returning an already-visited version would otherwise spin this
1055
- // loop forever during startup (Android has the same guard).
1056
- NSMutableSet<NSString *> *visitedVersions = [NSMutableSet set];
1057
- while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) {
1058
- [visitedVersions addObject:loadVersion];
1059
- NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1060
- if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
1061
- pushyLaunchVersion = loadVersion;
1062
- resolvedURL = [NSURL fileURLWithPath:bundlePath];
1063
- break;
1064
- } else {
1065
- #if TARGET_OS_TV
1066
- // Caches is purgeable on tvOS: a missing bundle is the OS
1067
- // reclaiming space, not a bad release. Everything under
1068
- // rctpushy went with it, so walking back to last_version
1069
- // is pointless, and a rollback mark would make this
1070
- // launch's check refuse to reinstall the version.
1071
- RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion);
1072
- purgedVersion = loadVersion;
1073
- state = pushy::state::ForgetPurgedVersions(state);
1074
- PushyApplyStateToDefaults(defaults, state);
1075
- if (decision.consumed_first_time) {
1076
- // The first load being consumed never happens: the
1077
- // packaged bundle must not report isFirstTime.
1078
- ignoreRollback = false;
1079
- [defaults removeObjectForKey:keyFirstLoadMarked];
1080
- }
1081
- break;
1082
- #else
1083
- RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion);
1084
- state = pushy::state::Rollback(state);
1085
- PushyApplyStateToDefaults(defaults, state);
1086
- loadVersion = PushyFromStdString(state.current_version);
1087
- #endif
1088
- }
1089
- }
1090
- }
1091
- // Capture before constantsToExport consumes this one-shot marker.
1092
- // The delayed native check must never forceBoot the version that this
1093
- // launch just rolled back.
1094
- launchRolledBackVersion = PushyFromStdString(state.rolled_back_version);
1095
- });
1096
- } @finally {
1097
- *launchRolledBackVersionOut = launchRolledBackVersion;
1098
- *purgedVersionOut = purgedVersion;
1099
- }
1100
- return resolvedURL;
1101
- }
1102
-
1103
- + (NSString *) rollback {
1104
- __block NSString *currentVersion = nil;
1105
- PushyWithStateLock(^{
1106
- NSUserDefaults *defaults = PushyDefaults();
1107
- pushy::state::State state = pushy::state::Rollback(PushyStateFromDefaults(defaults));
1108
- PushyApplyStateToDefaults(defaults, state);
1109
- currentVersion = PushyFromStdString(state.current_version);
1110
- });
1111
- return currentVersion;
1112
- }
1113
-
1114
112
  + (void)configure:(NSDictionary<NSString *, id> *)options
1115
113
  completion:(RCTPushyConfigurationCompletion)completion
1116
114
  {
1117
115
  NSError *validationError = nil;
1118
116
  // Snapshot nested mutable caller values before crossing a queue boundary.
1119
117
  NSString *config = RCTPushyNormalizeConfiguration(options, &validationError);
1120
- static dispatch_queue_t configQueue;
1121
- static dispatch_once_t once;
1122
- dispatch_once(&once, ^{
1123
- configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL);
1124
- });
118
+ static dispatch_queue_t configQueue =
119
+ dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL);
1125
120
  dispatch_async(configQueue, ^{
1126
121
  NSError *failure = validationError;
1127
122
  if (config != nil) {
@@ -1142,26 +137,17 @@ RCT_EXPORT_MODULE(RCTPushy);
1142
137
 
1143
138
  + (void)prepareBundleWithCompletion:(RCTPushyBundlePreparationCompletion)completion
1144
139
  {
1145
- static dispatch_queue_t hostQueue;
1146
- static dispatch_once_t once;
1147
- dispatch_once(&once, ^{
1148
- hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_SERIAL);
1149
- });
1150
- dispatch_async(hostQueue, ^{
1151
- NSDictionary *result;
1152
- @try {
1153
- result = [RCTPushyOrchestrator prepareBundle];
1154
- } @catch (NSException *exception) {
1155
- RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason);
1156
- result = PushyHostResult(@"failed", @"internal_error", nil, NO);
1157
- }
1158
- if (completion != nil) {
1159
- NSDictionary *snapshot = [result copy];
1160
- dispatch_async(dispatch_get_main_queue(), ^{
1161
- completion(snapshot);
1162
- });
1163
- }
1164
- });
140
+ PushyRunHostPreparation(^NSDictionary * {
141
+ return [RCTPushyOrchestrator prepareBundle];
142
+ }, completion);
143
+ }
144
+
145
+ + (void)prepareBundleWithTimeout:(NSTimeInterval)timeout
146
+ completion:(RCTPushyBundlePreparationCompletion)completion
147
+ {
148
+ PushyRunHostPreparation(^NSDictionary * {
149
+ return [RCTPushyOrchestrator prepareBundleWithin:timeout];
150
+ }, completion);
1165
151
  }
1166
152
 
1167
153
  + (BOOL)requiresMainQueueSetup
@@ -1191,15 +177,8 @@ RCT_EXPORT_MODULE(RCTPushy);
1191
177
  if (currentVersion != nil) {
1192
178
  ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)];
1193
179
  // bundleSha256 from the install record, for crash-report attribution.
1194
- NSString *bundleSha256 = @"";
1195
- if (PushyIsSafePathComponent(currentVersion)) {
1196
- NSDictionary *record = PushyReadInstallRecord(
1197
- [[RCTPushy downloadDir] stringByAppendingPathComponent:currentVersion]);
1198
- if ([record[@"bundleSha256"] isKindOfClass:[NSString class]]) {
1199
- bundleSha256 = record[@"bundleSha256"];
1200
- }
1201
- }
1202
- ret[@"currentBundleSha256"] = bundleSha256;
180
+ ret[@"currentBundleSha256"] = PushyIsSafePathComponent(currentVersion)
181
+ ? PushyRecordedBundleSha256(PushyVersionDir(currentVersion)) : @"";
1203
182
  }
1204
183
 
1205
184
  if (ret[@"isFirstTime"]) {
@@ -1208,36 +187,18 @@ RCT_EXPORT_MODULE(RCTPushy);
1208
187
 
1209
188
  if (ret[@"rolledBackVersion"] != nil) {
1210
189
  [defaults removeObjectForKey:keyRolledBackMarked];
1211
- [self clearInvalidFiles];
190
+ [RCTPushyInstaller clearInvalidFiles];
1212
191
  }
1213
192
 
1214
- if ([[defaults objectForKey:KeyPackageUpdatedMarked] boolValue]) {
1215
- [defaults removeObjectForKey:KeyPackageUpdatedMarked];
1216
- [self clearInvalidFiles];
193
+ if ([[defaults objectForKey:KeyPackageUpdatedMarked()] boolValue]) {
194
+ [defaults removeObjectForKey:KeyPackageUpdatedMarked()];
195
+ [RCTPushyInstaller clearInvalidFiles];
1217
196
  }
1218
197
  });
1219
198
 
1220
199
  return ret;
1221
200
  }
1222
201
 
1223
- - (instancetype)init
1224
- {
1225
- self = [super init];
1226
- if (self) {
1227
- // One process-wide serial queue, not per-instance: a bridge reload can
1228
- // briefly keep two RCTPushy instances alive, and destructive file work
1229
- // (resetToPackagedBundle's full cleanup) must stay serialized with the
1230
- // other instance's unzip/patch jobs.
1231
- static dispatch_queue_t sharedFileQueue;
1232
- static dispatch_once_t onceToken;
1233
- dispatch_once(&onceToken, ^{
1234
- sharedFileQueue = dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL);
1235
- });
1236
- _fileQueue = sharedFileQueue;
1237
- }
1238
- return self;
1239
- }
1240
-
1241
202
  RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)resolve
1242
203
  rejecter:(RCTPromiseRejectBlock)reject)
1243
204
  {
@@ -1262,10 +223,8 @@ RCT_EXPORT_METHOD(syncNativeConfig:(NSString *)config
1262
223
  PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
1263
224
  return;
1264
225
  }
1265
- NSData *data = [config dataUsingEncoding:NSUTF8StringEncoding];
1266
226
  NSError *error = nil;
1267
- id object = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
1268
- if (![object isKindOfClass:[NSDictionary class]]) {
227
+ if (PushyJsonDictionary(config, &error) == nil) {
1269
228
  PushyRejectError(reject, PushyErrorWithCode(
1270
229
  pushy::error_codes::kInvalidOptions,
1271
230
  error != nil ? error.localizedDescription : ERROR_OPTIONS));
@@ -1275,8 +234,8 @@ RCT_EXPORT_METHOD(syncNativeConfig:(NSString *)config
1275
234
  [RCTPushyOrchestrator persistConfiguration:config];
1276
235
  resolve(@true);
1277
236
  } @catch (NSException *exception) {
1278
- PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed,
1279
- exception.reason ?: @"Native configuration failed"));
237
+ PushyRejectException(reject, pushy::error_codes::kFileOperationFailed, exception,
238
+ @"Native configuration failed");
1280
239
  }
1281
240
  }
1282
241
 
@@ -1307,13 +266,9 @@ RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash
1307
266
  return;
1308
267
  }
1309
268
 
1310
- NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding];
1311
269
  NSError *error = nil;
1312
- id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
1313
- if (object && [object isKindOfClass:[NSDictionary class]]) {
1314
- NSUserDefaults *defaults = PushyDefaults();
1315
- [defaults setObject:value forKey:PushyHashInfoKey(hash)];
1316
-
270
+ if (PushyJsonDictionary(value, &error) != nil) {
271
+ [PushyDefaults() setObject:value forKey:PushyHashInfoKey(hash)];
1317
272
  resolve(@true);
1318
273
  } else {
1319
274
  PushyRejectError(reject, PushyErrorWithCode(
@@ -1367,7 +322,7 @@ RCT_EXPORT_METHOD(setNeedUpdate:(NSDictionary *)options
1367
322
  rejecter:(RCTPromiseRejectBlock)reject)
1368
323
  {
1369
324
  NSError *error = nil;
1370
- if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) {
325
+ if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) {
1371
326
  PushyRejectError(reject, error);
1372
327
  return;
1373
328
  }
@@ -1380,27 +335,19 @@ RCT_EXPORT_METHOD(reloadUpdate:(NSDictionary *)options
1380
335
  rejecter:(RCTPromiseRejectBlock)reject)
1381
336
  {
1382
337
  NSError *error = nil;
1383
- if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) {
338
+ if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) {
1384
339
  PushyRejectError(reject, error);
1385
340
  return;
1386
341
  }
1387
342
 
1388
- NSError *reloadError = [self restartBridgeWithReason:@"pushy restart"];
1389
- if (reloadError != nil) {
1390
- PushyRejectError(reject, reloadError);
1391
- return;
1392
- }
343
+ PushyTriggerReload(@"pushy restart");
1393
344
  resolve(@true);
1394
345
  }
1395
346
 
1396
347
  RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve
1397
348
  rejecter:(RCTPromiseRejectBlock)reject)
1398
349
  {
1399
- NSError *reloadError = [self restartBridgeWithReason:@"pushy restartApp"];
1400
- if (reloadError != nil) {
1401
- PushyRejectError(reject, reloadError);
1402
- return;
1403
- }
350
+ PushyTriggerReload(@"pushy restartApp");
1404
351
  resolve(@true);
1405
352
  }
1406
353
 
@@ -1418,7 +365,7 @@ RCT_EXPORT_METHOD(getBundleHash:(RCTPromiseResolveBlock)resolve
1418
365
  // is running. Mirrors the dev behaviour of buildTime.
1419
366
  resolve(@"");
1420
367
  #else
1421
- dispatch_async(_fileQueue, ^{
368
+ dispatch_async(PushyFileQueue(), ^{
1422
369
  resolve(PushyBundleHashSync(YES));
1423
370
  });
1424
371
  #endif
@@ -1444,12 +391,12 @@ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve
1444
391
  PushyApplyStateToDefaults(defaults, result.state);
1445
392
  });
1446
393
  } @catch (NSException *exception) {
1447
- PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kMarkSuccessFailed,
1448
- exception.reason ?: @"markSuccess failed"));
394
+ PushyRejectException(reject, pushy::error_codes::kMarkSuccessFailed, exception,
395
+ @"markSuccess failed");
1449
396
  return;
1450
397
  }
1451
398
 
1452
- [self clearInvalidFiles];
399
+ [RCTPushyInstaller clearInvalidFiles];
1453
400
  resolve(@true);
1454
401
  #endif
1455
402
  }
@@ -1488,19 +435,18 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1488
435
  }
1489
436
  }
1490
437
  [defaults removeObjectForKey:keyFirstLoadMarked];
1491
- [defaults removeObjectForKey:KeyPackageUpdatedMarked];
438
+ [defaults removeObjectForKey:KeyPackageUpdatedMarked()];
1492
439
  // A cached response still advertises the version this reset just
1493
440
  // removed; dropping it stops the JS side from reusing that answer.
1494
441
  [defaults removeObjectForKey:keyNativeCheckCache];
1495
442
  ignoreRollback = false;
1496
443
  });
1497
444
  } @catch (NSException *exception) {
1498
- PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kResetFailed,
1499
- exception.reason ?: @"reset failed"));
445
+ PushyRejectException(reject, pushy::error_codes::kResetFailed, exception, @"reset failed");
1500
446
  return;
1501
447
  }
1502
448
 
1503
- dispatch_async(_fileQueue, ^{
449
+ dispatch_async(PushyFileQueue(), ^{
1504
450
  // maxAgeDays=0: remove every downloaded entry except the running
1505
451
  // version's directory (cleaned up by the next regular cleanup). The
1506
452
  // state was just wiped, so that is the only name left to keep. The
@@ -1524,7 +470,6 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1524
470
  }
1525
471
 
1526
472
 
1527
-
1528
473
  #pragma mark - private
1529
474
  - (NSArray<NSString *> *)supportedEvents
1530
475
  {
@@ -1546,9 +491,19 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1546
491
  resolver:(RCTPromiseResolveBlock)resolve
1547
492
  rejecter:(RCTPromiseRejectBlock)reject
1548
493
  {
1549
- [self performFetch:type options:options callback:^(NSError *error) {
494
+ NSString *hash = PushyOptionString(options, @"hash") ?: @"";
495
+ void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) {
496
+ if (self->hasListeners) {
497
+ [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{
498
+ PARAM_PROGRESS_HASH: hash,
499
+ PARAM_PROGRESS_RECEIVED: @(receivedBytes),
500
+ PARAM_PROGRESS_TOTAL: @(totalBytes),
501
+ }];
502
+ }
503
+ };
504
+ [RCTPushyInstaller fetch:type options:options progress:progress callback:^(NSError *error) {
1550
505
  if (error != nil) {
1551
- if (error.userInfo[PushyErrorCodeKey] == nil) {
506
+ if (error.userInfo[RCTPushyErrorCodeKey] == nil) {
1552
507
  // Unclassified (system/network) errors from the download
1553
508
  // pipeline; keep the original message.
1554
509
  error = PushyErrorWithCode(pushy::error_codes::kDownloadFailed,
@@ -1561,644 +516,6 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
1561
516
  }];
1562
517
  }
1563
518
 
1564
- // Triggers a JS reload; returns nil when one was scheduled, a RESTART_FAILED
1565
- // error when this host cannot restart. React Native re-resolves the bundle
1566
- // URL itself on reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp
1567
- // re-asks sourceURLForBridge:), so +bundleURL is deliberately NOT called
1568
- // here: it consumes one-shot launch state (first_time, the first-load mark,
1569
- // ignoreRollback), and consuming it for a reload that never happens would
1570
- // make the next cold start roll the freshly switched version back.
1571
- - (NSError *)restartBridgeWithReason:(NSString *)reason
1572
- {
1573
- #if PUSHY_HAS_RELOAD_COMMAND
1574
- dispatch_async(dispatch_get_main_queue(), ^{
1575
- RCTTriggerReloadCommandListeners(reason);
1576
- });
1577
- return nil;
1578
- #else
1579
- // No RCTReloadCommand in this React Native: only the legacy bridge can
1580
- // reload. Under bridgeless the module's bridge is nil or an RCTBridgeProxy
1581
- // whose -reload is a logged no-op (object_getClass avoids messaging the
1582
- // proxy) — report that instead of resolving a restart that never happens.
1583
- RCTBridge *bridge = self.bridge;
1584
- if (bridge == nil || object_getClass(bridge) == NSClassFromString(@"RCTBridgeProxy")) {
1585
- return PushyErrorWithCode(pushy::error_codes::kRestartFailed,
1586
- @"no bridge available to restart (bridgeless host without the RN restart command)");
1587
- }
1588
- dispatch_async(dispatch_get_main_queue(), ^{
1589
- [bridge reload];
1590
- });
1591
- return nil;
1592
- #endif
1593
- }
1594
-
1595
- - (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback
1596
- {
1597
- NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey);
1598
- NSString *hash = PushyOptionString(options, @"hash");
1599
-
1600
- if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) {
1601
- callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
1602
- return;
1603
- }
1604
- NSString *originHash = PushyOptionString(options, @"originHash");
1605
- if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) {
1606
- callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS));
1607
- return;
1608
- }
1609
-
1610
- NSString *dir = [RCTPushy downloadDir];
1611
- BOOL success = [self ensureDirectoryExistsAtPath:dir];
1612
- if (!success) {
1613
- callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION));
1614
- return;
1615
- }
1616
-
1617
- NSString *unzipDir = [dir stringByAppendingPathComponent:hash];
1618
- if (PushyHasCompletedVersionAtPath(unzipDir, hash)) {
1619
- callback(nil);
1620
- return;
1621
- }
1622
-
1623
- NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600;
1624
- NSNumber *configuredDeadline = options[@"deadlineUptime"];
1625
- if ([configuredDeadline isKindOfClass:[NSNumber class]]) {
1626
- deadlineUptime = configuredDeadline.doubleValue;
1627
- }
1628
- if (deadlineUptime <= PushyMonotonicNow()) {
1629
- callback(PushyDownloadDeadlineExpiredError());
1630
- return;
1631
- }
1632
-
1633
- void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) {
1634
- if (self->hasListeners) {
1635
- [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{
1636
- PARAM_PROGRESS_HASH:hash,
1637
- PARAM_PROGRESS_RECEIVED:@(receivedBytes),
1638
- PARAM_PROGRESS_TOTAL:@(totalBytes),
1639
- }];
1640
- }
1641
- };
1642
- void (^deferredStart)(void) = ^{
1643
- [self performFetch:type options:options callback:callback];
1644
- };
1645
- PushyDownloadRegistration registration = PushyRegisterDownload(
1646
- hash, type, deadlineUptime, callback, progress, deferredStart);
1647
- if (registration != PushyDownloadRegistrationOwner) {
1648
- RCTLogInfo(
1649
- @"RCTPushy -- %@ in-flight download for %@",
1650
- registration == PushyDownloadRegistrationJoined ? @"join" : @"defer",
1651
- hash);
1652
- return;
1653
- }
1654
-
1655
- NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]];
1656
-
1657
- // On failure, remove the partial version directory like Android/Harmony
1658
- // do: a half-unzipped/half-patched dir leaks disk and could later be
1659
- // mistaken for a complete version. hash is validated non-blank above, so
1660
- // this can never resolve to the download root itself.
1661
- void (^completion)(NSError *) = ^(NSError *error) {
1662
- // Settle every JS/native waiter only after cleanup or the atomic
1663
- // completion marker write has run on the process-wide file queue.
1664
- dispatch_async(self->_fileQueue, ^{
1665
- NSError *finalError = error;
1666
- NSFileManager *fileManager = [NSFileManager defaultManager];
1667
- NSString *staging = PushyStagingDirForVersionDir(unzipDir);
1668
- NSString *artifactSha256 = nil;
1669
- @synchronized (PushyArtifactDigests()) {
1670
- artifactSha256 = PushyArtifactDigests()[hash];
1671
- [PushyArtifactDigests() removeObjectForKey:hash];
1672
- }
1673
- if (finalError == nil) {
1674
- // Two-phase install (cpp/patch_core/install_record.h): the
1675
- // completion record with the final bundle's digest goes into
1676
- // the staging directory, which is then renamed over the
1677
- // version directory in one atomic step.
1678
- NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1679
- if (![fileManager fileExistsAtPath:bundlePath]) {
1680
- finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed,
1681
- @"bundle missing after install");
1682
- } else {
1683
- std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath));
1684
- NSMutableDictionary *record = [NSMutableDictionary dictionary];
1685
- record[@"schema"] = @(pushy::install_record::kSchema);
1686
- record[@"versionHash"] = hash;
1687
- if (!bundleSha256.empty()) {
1688
- record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()];
1689
- }
1690
- if (artifactSha256.length > 0) {
1691
- record[@"artifactSha256"] = artifactSha256;
1692
- }
1693
- NSError *recordError = nil;
1694
- NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError];
1695
- NSString *marker = [staging stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME];
1696
- if (recordData == nil || ![recordData writeToFile:marker
1697
- options:NSDataWritingAtomic
1698
- error:&recordError]) {
1699
- finalError = recordError ?: PushyErrorWithCode(
1700
- pushy::error_codes::kFileOperationFailed,
1701
- @"failed to write completion record");
1702
- } else {
1703
- if ([fileManager fileExistsAtPath:unzipDir]) {
1704
- [fileManager removeItemAtPath:unzipDir error:nil];
1705
- }
1706
- NSError *moveError = nil;
1707
- if (![fileManager moveItemAtPath:staging toPath:unzipDir error:&moveError]) {
1708
- finalError = moveError ?: PushyErrorWithCode(
1709
- pushy::error_codes::kFileOperationFailed,
1710
- @"failed to promote staging directory");
1711
- }
1712
- }
1713
- }
1714
- }
1715
- if (finalError != nil) {
1716
- // Only the staging directory is ours to drop: the final
1717
- // version directory is never touched by a failed install.
1718
- [fileManager removeItemAtPath:staging error:nil];
1719
- }
1720
- PushyFinishDownload(hash, finalError);
1721
- });
1722
- };
1723
-
1724
- RCTLogInfo(@"RCTPushy -- download file %@", updateUrl);
1725
- NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow();
1726
- if (timeoutSeconds <= 0) {
1727
- completion(PushyDownloadDeadlineExpiredError());
1728
- return;
1729
- }
1730
- [RCTPushyDownloader download:updateUrl
1731
- savePath:zipFilePath
1732
- timeoutInterval:timeoutSeconds
1733
- progressHandler:^(long long receivedBytes, long long totalBytes) {
1734
- PushyReportDownloadProgress(hash, receivedBytes, totalBytes);
1735
- } completionHandler:^(NSString *path, NSError *error) {
1736
- if (error != nil) {
1737
- completion(error);
1738
- return;
1739
- }
1740
- [self unzipDownloadedPackage:zipFilePath
1741
- hash:hash
1742
- type:type
1743
- originHash:originHash
1744
- callback:completion];
1745
- }];
1746
- }
1747
-
1748
- - (void)unzipDownloadedPackage:(NSString *)zipFilePath
1749
- hash:(NSString *)hash
1750
- type:(PushyType)type
1751
- originHash:(NSString *)originHash
1752
- callback:(void (^)(NSError *error))callback
1753
- {
1754
- RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath);
1755
- // Everything lands in <hash>.staging; the completion block promotes it.
1756
- NSString *unzipFilePath = PushyStagingDirForVersionDir(
1757
- [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]);
1758
- dispatch_async(_fileQueue, ^{
1759
- // Archive digest for the completion record, taken before the unzip
1760
- // consumes the file (same serial queue, so it runs first).
1761
- std::string digest = pushy::digest::Sha256File(PushyToStdString(zipFilePath));
1762
- @synchronized (PushyArtifactDigests()) {
1763
- PushyArtifactDigests()[hash] = [NSString stringWithUTF8String:digest.c_str()];
1764
- }
1765
- });
1766
- [self unzipFileAtPath:zipFilePath
1767
- toDestination:unzipFilePath
1768
- completionHandler:^(NSError *error) {
1769
- dispatch_async(self->_fileQueue, ^{
1770
- if (error != nil) {
1771
- callback(error);
1772
- return;
1773
- }
1774
- @try {
1775
- [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback];
1776
- } @catch (NSException *exception) {
1777
- // An uncaught exception in a GCD block is fatal (and would
1778
- // trip the crash rescue); a bad package is a PATCH_FAILED.
1779
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed,
1780
- exception.reason ?: @"delta failed"));
1781
- }
1782
- });
1783
- }];
1784
- }
1785
-
1786
- - (void)finishDownloadedPackage:(NSString *)hash
1787
- type:(PushyType)type
1788
- originHash:(NSString *)originHash
1789
- callback:(void (^)(NSError *error))callback
1790
- {
1791
- switch (type) {
1792
- case PushyTypePatchFromPackage:
1793
- [self applyDeltaForHash:hash
1794
- type:type
1795
- fromBundle:[[RCTPushy binaryBundleURL] path]
1796
- source:[[NSBundle mainBundle] resourcePath]
1797
- callback:callback];
1798
- return;
1799
- case PushyTypePatchFromPpk: {
1800
- NSString *lastVersionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:originHash];
1801
- [self applyDeltaForHash:hash
1802
- type:type
1803
- fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]
1804
- source:lastVersionDir
1805
- callback:callback];
1806
- return;
1807
- }
1808
- case PushyTypeFullDownload:
1809
- callback(nil);
1810
- return;
1811
- }
1812
- }
1813
-
1814
- - (void)applyDeltaForHash:(NSString *)hash
1815
- type:(PushyType)type
1816
- fromBundle:(NSString *)bundleOrigin
1817
- source:(NSString *)sourceOrigin
1818
- callback:(void (^)(NSError *error))callback
1819
- {
1820
- // Patch work happens in the staging directory (two-phase install).
1821
- NSString *unzipDir = PushyStagingDirForVersionDir(
1822
- [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]);
1823
- NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME];
1824
- NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME];
1825
-
1826
- NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME];
1827
- long long manifestBytes = RCTPushyFileSize(sourcePatch);
1828
- if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) {
1829
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed,
1830
- [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes]));
1831
- return;
1832
- }
1833
- NSData *data = [NSData dataWithContentsOfFile:sourcePatch];
1834
- if (data == nil) {
1835
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest"));
1836
- return;
1837
- }
1838
-
1839
- NSError *error = nil;
1840
- id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error];
1841
- if (error != nil) {
1842
- // Classify as a patch failure like the sibling manifest branches;
1843
- // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the
1844
- // fetchPackage fallback even though the download itself succeeded.
1845
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, error.localizedDescription));
1846
- return;
1847
- }
1848
- if (![jsonObject isKindOfClass:[NSDictionary class]]) {
1849
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"invalid delta manifest"));
1850
- return;
1851
- }
1852
- NSDictionary *json = (NSDictionary *)jsonObject;
1853
- pushy::delta::PatchManifest manifest;
1854
- NSString *manifestReason = nil;
1855
- if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) {
1856
- callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason));
1857
- return;
1858
- }
1859
-
1860
- std::vector<std::string> entryNames;
1861
- if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) {
1862
- entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME));
1863
- }
1864
- if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) {
1865
- entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME));
1866
- }
1867
-
1868
- pushy::archive_patch::ArchivePatchPlan plan;
1869
- pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan(
1870
- type == PushyTypePatchFromPackage
1871
- ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage
1872
- : pushy::archive_patch::ArchivePatchType::kPatchFromPpk,
1873
- manifest,
1874
- entryNames,
1875
- &plan
1876
- );
1877
- if (!planStatus.ok) {
1878
- callback(PushyNSErrorFromStatus(planStatus));
1879
- return;
1880
- }
1881
-
1882
- pushy::delta::FileSourcePatchOptions options;
1883
- pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions(
1884
- plan,
1885
- PushyToStdString(sourceOrigin),
1886
- PushyToStdString(unzipDir),
1887
- PushyToStdString(bundleOrigin),
1888
- PushyToStdString(bundlePatch),
1889
- PushyToStdString(destination),
1890
- &options
1891
- );
1892
- if (!optionStatus.ok) {
1893
- callback(PushyNSErrorFromStatus(optionStatus));
1894
- return;
1895
- }
1896
-
1897
- // __diff.json 的 hbcTransform 元数据(HBC 变换域 patch,hdiffv2 轨道):
1898
- // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。
1899
- NSDictionary *hbcTransform = json[@"hbcTransform"];
1900
- if ([hbcTransform isKindOfClass:[NSDictionary class]]) {
1901
- NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME];
1902
- if ([meta isKindOfClass:[NSDictionary class]]) {
1903
- NSError *metaError = nil;
1904
- NSData *metaData = [NSJSONSerialization dataWithJSONObject:meta options:0 error:&metaError];
1905
- if (metaData != nil && metaError == nil) {
1906
- NSString *metaString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding];
1907
- options.bundle_hbc_transform_meta = PushyToStdString(metaString);
1908
- }
1909
- }
1910
- }
1911
-
1912
- pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options);
1913
- if (!status.ok) {
1914
- callback(PushyNSErrorFromStatus(status));
1915
- return;
1916
- }
1917
-
1918
- callback(nil);
1919
- }
1920
-
1921
- - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error
1922
- {
1923
- if (!PushyIsSafePathComponent(hash)) {
1924
- if (error != NULL) {
1925
- *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS);
1926
- }
1927
- return NO;
1928
- }
1929
-
1930
- __block NSError *switchError = nil;
1931
- PushyWithStateLock(^{
1932
- // Same rule as Android: only a version this SDK recorded as completely
1933
- // installed (bundle + marker) may be activated. Versions activated
1934
- // before markers existed are grandfathered through current/last
1935
- // state; any other markerless directory may be a crash-left partial
1936
- // install or a directory something else put there.
1937
- NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
1938
- pushy::state::State state = PushyStateFromDefaults(PushyDefaults());
1939
- std::string hashStd = PushyToStdString(hash);
1940
- BOOL legacyActivated = state.current_version == hashStd || state.last_version == hashStd;
1941
- if (!PushyHasCompletedVersionAtPath(versionDir, hash) && !legacyActivated) {
1942
- BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath:
1943
- [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]];
1944
- switchError = PushyErrorWithCode(
1945
- pushy::error_codes::kSwitchVersionFailed,
1946
- [NSString stringWithFormat:@"Bundle version %@ %@", hash,
1947
- hasBundle ? @"is incomplete." : @"not found."]);
1948
- return;
1949
- }
1950
- if (!legacyActivated) {
1951
- // The record's bundle digest must match the bytes on disk before
1952
- // the next launch is pointed at them.
1953
- NSString *reason = PushyVerifyInstallForActivation(versionDir, hash);
1954
- if (reason != nil) {
1955
- switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason);
1956
- return;
1957
- }
1958
- }
1959
- PushySwitchVersionLocked(hash);
1960
- });
1961
- if (switchError != nil) {
1962
- if (error != NULL) {
1963
- *error = switchError;
1964
- }
1965
- return NO;
1966
- }
1967
- return YES;
1968
- }
1969
-
1970
- - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path
1971
- {
1972
- // No _fileQueue hop here: that queue also runs multi-second unzip/patch
1973
- // work, and a dispatch_sync onto it would block the whole module method
1974
- // queue for the duration. createDirectoryAtPath is idempotent and
1975
- // thread-safe, so checking inline is fine.
1976
- NSFileManager *fileManager = [NSFileManager defaultManager];
1977
- BOOL isDirectory = NO;
1978
- if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) {
1979
- if (isDirectory) {
1980
- // Directories created by older versions never got the flag.
1981
- [RCTPushy excludeFromBackup:path];
1982
- }
1983
- return isDirectory;
1984
- }
1985
-
1986
- NSError *error = nil;
1987
- BOOL success = [fileManager createDirectoryAtPath:path
1988
- withIntermediateDirectories:YES
1989
- attributes:nil
1990
- error:&error];
1991
- if (!success && error != nil) {
1992
- RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription);
1993
- }
1994
- if (success) {
1995
- [RCTPushy excludeFromBackup:path];
1996
- }
1997
-
1998
- return success;
1999
- }
2000
-
2001
- // Everything under rctpushy is re-downloadable, and Application Support is
2002
- // backed up to iCloud by default — Apple requires such content to be
2003
- // excluded from backups.
2004
- + (void)excludeFromBackup:(NSString *)path
2005
- {
2006
- #if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up.
2007
- NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES];
2008
- NSError *error = nil;
2009
- if (![url setResourceValue:@YES
2010
- forKey:NSURLIsExcludedFromBackupKey
2011
- error:&error]) {
2012
- RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription);
2013
- }
2014
- #endif
2015
- }
2016
-
2017
- - (void)unzipFileAtPath:(NSString *)path
2018
- toDestination:(NSString *)destination
2019
- completionHandler:(void (^)(NSError *error))completionHandler
2020
- {
2021
- dispatch_async(_fileQueue, ^{
2022
- NSFileManager *fileManager = [NSFileManager defaultManager];
2023
- if ([fileManager fileExistsAtPath:destination]) {
2024
- [fileManager removeItemAtPath:destination error:nil];
2025
- }
2026
-
2027
- // Resource caps before the first byte is extracted
2028
- // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the
2029
- // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:),
2030
- // not the uncompressed total, so the disk check uses a 2x-archive
2031
- // heuristic; the delegate below enforces the exact caps entry by
2032
- // entry.
2033
- long long archiveBytes = RCTPushyFileSize(path);
2034
- NSError *preflight = nil;
2035
- if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) {
2036
- preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed,
2037
- [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]);
2038
- } else {
2039
- preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2);
2040
- }
2041
- if (preflight != nil) {
2042
- [fileManager removeItemAtPath:path error:nil];
2043
- [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(path) error:nil];
2044
- if (completionHandler != nil) {
2045
- completionHandler(preflight);
2046
- }
2047
- return;
2048
- }
2049
-
2050
- PushyUnzipGuard *guard = [PushyUnzipGuard new];
2051
- // preserveAttributes:NO — the archive's mode/mtime bits have no use
2052
- // here and are one less thing a hostile package controls.
2053
- [SSZipArchive unzipFileAtPath:path
2054
- toDestination:destination
2055
- preserveAttributes:NO
2056
- overwrite:YES
2057
- nestedZipLevel:0
2058
- password:nil
2059
- error:nil
2060
- delegate:guard
2061
- progressHandler:nil
2062
- completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) {
2063
- [fileManager removeItemAtPath:archivePath error:nil];
2064
- // The resume sidecar dies with its archive (§11.4): whether the
2065
- // unzip consumed it or classified it as poisoned, nothing must
2066
- // survive to vouch for bytes that are gone.
2067
- [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(archivePath) error:nil];
2068
- if (completionHandler == nil) {
2069
- return;
2070
- }
2071
-
2072
- NSError *unzipError = error;
2073
- if (guard.violation != nil) {
2074
- unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation);
2075
- } else if (!succeeded && unzipError == nil) {
2076
- unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed");
2077
- } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) {
2078
- // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has
2079
- // no stable code; without one, fetchPackage's fallback would
2080
- // classify it as DOWNLOAD_FAILED even though the download
2081
- // succeeded — keep the classification deterministic.
2082
- unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed,
2083
- unzipError.localizedDescription ?: @"unzip failed");
2084
- }
2085
- completionHandler(unzipError);
2086
- }];
2087
- });
2088
- }
2089
-
2090
- - (void)clearInvalidFiles
2091
- {
2092
- dispatch_async(_fileQueue, ^{
2093
- // Snapshot the state under the lock, but run the (slow) filesystem
2094
- // cleanup outside of it so state operations are not blocked.
2095
- __block pushy::state::State state;
2096
- __block NSString *launchVersion = nil;
2097
- PushyWithStateLock(^{
2098
- state = PushyStateFromDefaults(PushyDefaults());
2099
- launchVersion = pushyLaunchVersion;
2100
- });
2101
- NSString *downloadDir = [RCTPushy downloadDir];
2102
- // Keep the persisted current/last versions AND the version this
2103
- // process booted from: two switches without a restart would otherwise
2104
- // evict the running bundle while its on-demand assets are still
2105
- // being served.
2106
- pushy::delta::Status status = pushy::delta::CleanupOldEntries(
2107
- PushyToStdString(downloadDir),
2108
- std::vector<std::string>{
2109
- state.current_version,
2110
- state.last_version,
2111
- PushyToStdString(launchVersion),
2112
- },
2113
- 3
2114
- );
2115
- if (!status.ok) {
2116
- RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str());
2117
- }
2118
- });
2119
- }
2120
-
2121
- - (NSString *)zipExtension:(PushyType)type
2122
- {
2123
- switch (type) {
2124
- case PushyTypePatchFromPackage:
2125
- return PushyIpaDeltaSuffix;
2126
- case PushyTypePatchFromPpk:
2127
- return PushyPpkDeltaSuffix;
2128
- case PushyTypeFullDownload:
2129
- break;
2130
- }
2131
- return @".ppk";
2132
- }
2133
-
2134
- + (NSString *)downloadDir
2135
- {
2136
- #if TARGET_OS_TV
2137
- // tvOS apps cannot write Application Support; Caches is the only
2138
- // writable, non-temporary location, and the system may purge it while
2139
- // the app is not running (see the purged-version path in +bundleURL).
2140
- NSSearchPathDirectory base = NSCachesDirectory;
2141
- #else
2142
- NSSearchPathDirectory base = NSApplicationSupportDirectory;
2143
- #endif
2144
- NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject];
2145
- return [directory stringByAppendingPathComponent:@"rctpushy"];
2146
- }
2147
-
2148
- + (NSURL *)binaryBundleURL
2149
- {
2150
- return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
2151
- }
2152
-
2153
- + (NSString *)packageVersion
2154
- {
2155
- static NSString *version = nil;
2156
-
2157
- static dispatch_once_t onceToken;
2158
- dispatch_once(&onceToken, ^{
2159
- NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
2160
- version = [infoDictionary objectForKey:@"CFBundleShortVersionString"];
2161
- });
2162
- return version;
2163
- }
2164
-
2165
- + (NSString *)buildTime
2166
- {
2167
- #if DEBUG
2168
- return @"0";
2169
- #else
2170
- static NSString *buildTime;
2171
- static dispatch_once_t onceToken;
2172
- dispatch_once(&onceToken, ^{
2173
- // The podspec's s.resource lands in the app bundle for a static
2174
- // library but inside this pod's own framework bundle under
2175
- // use_frameworks!; look in both.
2176
- NSString *stampPath =
2177
- [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"]
2178
- ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time"
2179
- ofType:@"txt"];
2180
- NSString *stamp = stampPath == nil ? nil
2181
- : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil]
2182
- stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
2183
- if (stamp.length == 0) {
2184
- // Never empty: with "" SyncBinaryVersion would miss a rebuilt
2185
- // binary that kept its CFBundleShortVersionString, and a stale
2186
- // hot update would outlive an App Store update. Every build
2187
- // rewrites Info.plist, so its mtime (integer seconds) stands in.
2188
- RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime",
2189
- stampPath == nil ? @"not found in the main or framework bundle" : @"is empty");
2190
- NSString *infoPlist =
2191
- [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"];
2192
- NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist
2193
- error:nil].fileModificationDate;
2194
- stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]];
2195
- }
2196
- buildTime = stamp;
2197
- });
2198
- return buildTime;
2199
- #endif
2200
- }
2201
-
2202
519
  #ifdef RCT_NEW_ARCH_ENABLED
2203
520
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
2204
521
  (const facebook::react::ObjCTurboModule::InitParams &)params
@@ -2208,861 +525,3 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
2208
525
  #endif
2209
526
 
2210
527
  @end
2211
-
2212
- #pragma mark - native cold-start check orchestration
2213
-
2214
- // Refuses an https→http downgrade before the redirected request — and with
2215
- // it the POST body (appKey, uuid, versions) — leaves the device. The shared
2216
- // session would follow first and only the response could be rejected; ATS
2217
- // blocks plaintext by default, this covers apps that opted out with
2218
- // NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil
2219
- // delivers the 3xx itself as the final response, which the status check in
2220
- // PushyHttpRequest then treats as a failed endpoint.
2221
- @interface PushyRequestRedirectGuard : NSObject <NSURLSessionTaskDelegate>
2222
- @end
2223
-
2224
- @implementation PushyRequestRedirectGuard
2225
-
2226
- - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
2227
- willPerformHTTPRedirection:(NSHTTPURLResponse *)response
2228
- newRequest:(NSURLRequest *)request
2229
- completionHandler:(void (^)(NSURLRequest *))completionHandler
2230
- {
2231
- NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString];
2232
- NSString *nextScheme = [request.URL.scheme lowercaseString];
2233
- if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) {
2234
- completionHandler(nil);
2235
- return;
2236
- }
2237
- completionHandler(request);
2238
- }
2239
-
2240
- @end
2241
-
2242
- // Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns
2243
- // the response body on 2xx, nil on any failure. The semaphore timeout is a
2244
- // backstop over the request's own timeoutInterval.
2245
- static NSString *PushyHttpRequest(NSString *urlString, NSString *method,
2246
- NSString *body, NSTimeInterval timeout) {
2247
- NSURL *url = [NSURL URLWithString:urlString];
2248
- if (url == nil) {
2249
- return nil;
2250
- }
2251
- NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
2252
- request.HTTPMethod = method;
2253
- request.timeoutInterval = timeout;
2254
- [request setValue:@"application/json" forHTTPHeaderField:@"Accept"];
2255
- if (body != nil) {
2256
- [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
2257
- request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
2258
- }
2259
- dispatch_semaphore_t sem = dispatch_semaphore_create(0);
2260
- __block NSString *result = nil;
2261
- // A delegate session (not the shared one) so the redirect guard sees the
2262
- // 3xx before it is followed; the completion handler still receives the
2263
- // final response.
2264
- NSURLSession *session = [NSURLSession
2265
- sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]
2266
- delegate:[PushyRequestRedirectGuard new]
2267
- delegateQueue:nil];
2268
- NSURLSessionDataTask *task = [session dataTaskWithRequest:request
2269
- completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
2270
- NSHTTPURLResponse *httpResponse =
2271
- [response isKindOfClass:[NSHTTPURLResponse class]]
2272
- ? (NSHTTPURLResponse *)response
2273
- : nil;
2274
- NSInteger status = httpResponse.statusCode;
2275
- if (error == nil && httpResponse != nil && status >= 200 && status < 300
2276
- && data != nil) {
2277
- result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
2278
- }
2279
- dispatch_semaphore_signal(sem);
2280
- }];
2281
- [task resume];
2282
- // The session retains its delegate until invalidated: let this one task
2283
- // settle, then release everything.
2284
- [session finishTasksAndInvalidate];
2285
- if (dispatch_semaphore_wait(
2286
- sem, dispatch_time(DISPATCH_TIME_NOW,
2287
- (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) {
2288
- [task cancel];
2289
- return nil;
2290
- }
2291
- return result;
2292
- }
2293
-
2294
- static NSString *PushyNormalizeEndpointBase(NSString *base) {
2295
- while ([base hasSuffix:@"/"]) {
2296
- base = [base substringToIndex:base.length - 1];
2297
- }
2298
- return base;
2299
- }
2300
-
2301
- static BOOL PushyIsValidResponse(NSString *responseText) {
2302
- if (responseText == nil) {
2303
- return NO;
2304
- }
2305
- // Shared schema rule (update_flow_core::IsValidResponse): a 200 with
2306
- // `{"error": ...}` is a failed endpoint, not a verdict.
2307
- return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO;
2308
- }
2309
-
2310
- @implementation RCTPushyOrchestrator
2311
-
2312
- + (void)persistConfiguration:(NSString *)config {
2313
- PushyWithStateLock(^{
2314
- NSUserDefaults *defaults = PushyDefaults();
2315
- if ([defaults stringForKey:keyUuid].length == 0) {
2316
- [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid];
2317
- }
2318
- if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) {
2319
- return;
2320
- }
2321
- // The same generation protects reset and replacement of the request
2322
- // identity/policy, including a late crash-rescue activation.
2323
- pushyResetGeneration.fetch_add(1);
2324
- pushyNativeConfigGeneration.fetch_add(1);
2325
- [defaults setObject:config forKey:keyNativeConfig];
2326
- [defaults removeObjectForKey:keyNativeCheckCache];
2327
- [self recordJsRound:nil];
2328
- });
2329
- if (pushySyncReady.load() && [self hasRunnableConfig]) {
2330
- PushyInstallCrashHoldHandler();
2331
- }
2332
- }
2333
-
2334
- + (BOOL)hasRunnableConfig {
2335
- NSString *json = [PushyDefaults() stringForKey:keyNativeConfig];
2336
- if (json.length == 0) {
2337
- return NO;
2338
- }
2339
- bool ok = false;
2340
- flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok);
2341
- return ok && config.IsObject() && !config.Get("disabled").Truthy()
2342
- && !config.Get("appKey").AsString().empty();
2343
- }
2344
-
2345
- // Process-wide round state, set up by whichever launch path needs a round
2346
- // first: the delayed cold-start check or the tvOS purged-version restore.
2347
- + (void)prepareProcess:(NSString *)launchRolledBackVersion {
2348
- static dispatch_once_t onceToken;
2349
- dispatch_once(&onceToken, ^{
2350
- pushyRoundDone = dispatch_semaphore_create(0);
2351
- pushyHostRoundGroup = dispatch_group_create();
2352
- dispatch_group_enter(pushyHostRoundGroup);
2353
- pushyProcessAnchorUptime = PushyMonotonicNow();
2354
- pushyLaunchRolledBackForHold = [launchRolledBackVersion copy];
2355
- pushySyncReady.store(true);
2356
- // The crash-hold rescue shares the orchestrator's rollout gate: no
2357
- // persisted config, no handler (§11.3).
2358
- if ([PushyDefaults() stringForKey:keyNativeConfig].length > 0) {
2359
- PushyInstallCrashHoldHandler();
2360
- }
2361
- });
2362
- }
2363
-
2364
- + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion {
2365
- #if !DEBUG
2366
- // Once per process; a few seconds of delay keeps the check away from the
2367
- // cold-start critical path (§7 R5) — its result targets the NEXT launch.
2368
- // Unless the previous process died mid-round (residual incomplete
2369
- // marker), in which case every launch second counts (§11.4).
2370
- static dispatch_once_t onceToken;
2371
- dispatch_once(&onceToken, ^{
2372
- [self prepareProcess:launchRolledBackVersion];
2373
- NSUserDefaults *defaults = PushyDefaults();
2374
- int64_t delaySeconds =
2375
- [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5;
2376
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC),
2377
- dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
2378
- if ([self hasJsRound]) {
2379
- // Not consuming the round: a later crash rescue may still
2380
- // need it.
2381
- NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process");
2382
- return;
2383
- }
2384
- [self startRoundWithDeadline:0];
2385
- });
2386
- });
2387
- #endif
2388
- }
2389
-
2390
- #if TARGET_OS_TV && !DEBUG
2391
- // Runs this process's round before the first bundle loads, blocking the
2392
- // caller for at most kPushyPurgeRestoreBudget. The state no longer has a
2393
- // current version, so the server answers with the latest version for the
2394
- // packaged bundle, and the round activates it while the restore window is
2395
- // open. Returns NO when no round can run; otherwise the caller must resolve
2396
- // the launch bundle again: it launches whatever was activated before the
2397
- // window closed, or the packaged bundle. The round stops at the same
2398
- // deadline, keeping a partial download for the next check to resume.
2399
- + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion
2400
- rolledBack:(NSString *)launchRolledBackVersion {
2401
- [self prepareProcess:launchRolledBackVersion];
2402
- if (![self hasRunnableConfig]) {
2403
- RCTLogWarn(@"RCTPushy -- version %@ was purged and no native sync is "
2404
- @"configured; launching the packaged bundle", purgedVersion);
2405
- return NO;
2406
- }
2407
- PushyWithStateLock(^{
2408
- pushyPurgeRestoreWindowOpen = true;
2409
- });
2410
- pushyPurgeRestoreActive.store(true);
2411
- NSTimeInterval deadline = PushyMonotonicNow() + kPushyPurgeRestoreBudget;
2412
- dispatch_semaphore_t done = dispatch_semaphore_create(0);
2413
- dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
2414
- [self startRoundWithDeadline:deadline];
2415
- dispatch_semaphore_signal(done);
2416
- });
2417
- BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW,
2418
- (int64_t)(kPushyPurgeRestoreBudget * NSEC_PER_SEC))) != 0;
2419
- // The round's deadline does not bound a slow response (the request
2420
- // timeout is an idle timeout) nor a version already on disk, so the close
2421
- // is what stops a late activation — not the deadline.
2422
- PushyWithStateLock(^{
2423
- pushyPurgeRestoreWindowOpen = false;
2424
- });
2425
- if (timedOut) {
2426
- RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion);
2427
- } else {
2428
- NSDictionary *result = pushyHostRoundResult;
2429
- RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion,
2430
- result[@"status"], result[@"reason"], result[@"hash"]);
2431
- }
2432
- return YES;
2433
- }
2434
- #endif
2435
-
2436
- + (NSDictionary *)prepareBundle {
2437
- #if DEBUG
2438
- return PushyHostResult(@"skipped", @"debug", nil, NO);
2439
- #else
2440
- if (!pushySyncReady.load()) {
2441
- return PushyHostResult(@"skipped", @"not_initialized", nil, NO);
2442
- }
2443
- NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig];
2444
- if (configJson.length == 0) {
2445
- return PushyHostResult(@"skipped", @"not_configured", nil, NO);
2446
- }
2447
- id config = [NSJSONSerialization JSONObjectWithData:
2448
- [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
2449
- if (![config isKindOfClass:NSDictionary.class]) {
2450
- return PushyHostResult(@"failed", @"invalid_config", nil, NO);
2451
- }
2452
- id disabled = config[@"disabled"];
2453
- if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) {
2454
- return PushyHostResult(@"skipped", @"disabled", nil, NO);
2455
- }
2456
- id appKey = config[@"appKey"];
2457
- if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) {
2458
- return PushyHostResult(@"failed", @"invalid_config", nil, NO);
2459
- }
2460
- [self startRoundWithDeadline:0];
2461
- // A group is broadcast-style. Sharing the rescue semaphore would let one
2462
- // waiter consume the only signal and leave the other waiting forever.
2463
- if (!pushyRoundStarted.load()) {
2464
- return PushyHostResult(@"skipped", @"config_changed", nil, NO);
2465
- }
2466
- dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER);
2467
- if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()
2468
- || ![configJson isEqualToString:pushyHostRoundConfig]
2469
- || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) {
2470
- return PushyHostResult(@"cancelled", @"config_changed", nil, NO);
2471
- }
2472
- if (pushyHostRoundGeneration != pushyResetGeneration.load()) {
2473
- return PushyHostResult(@"cancelled", @"reset", nil, NO);
2474
- }
2475
- return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO);
2476
- #endif
2477
- }
2478
-
2479
- + (void)recordJsRound:(NSString *)config {
2480
- @synchronized (RCTPushyOrchestrator.class) {
2481
- pushyJsCompletedConfig = [config copy];
2482
- }
2483
- }
2484
-
2485
- // True when JS already obtained a valid response in this process for the
2486
- // exact config the native round would use (§10.3): the delayed round is
2487
- // then a duplicate request. Only the scheduled round asks — the crash-rescue
2488
- // path still runs, JS is dead by then.
2489
- + (BOOL)hasJsRound {
2490
- NSString *jsConfig;
2491
- @synchronized (RCTPushyOrchestrator.class) {
2492
- jsConfig = pushyJsCompletedConfig;
2493
- }
2494
- if (jsConfig == nil) {
2495
- return NO;
2496
- }
2497
- NSString *persisted = [PushyDefaults() stringForKey:keyNativeConfig];
2498
- return persisted != nil && [persisted isEqualToString:jsConfig];
2499
- }
2500
-
2501
- // Runs the process's single round on the calling thread if nobody has
2502
- // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and
2503
- // download phase to the remaining budget.
2504
- + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {
2505
- // Missing/disabled configuration is a preflight skip, not a used round.
2506
- if (![self hasRunnableConfig]) {
2507
- return;
2508
- }
2509
- bool expected = false;
2510
- if (!pushyRoundStarted.compare_exchange_strong(expected, true)) {
2511
- return;
2512
- }
2513
- @try {
2514
- [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime];
2515
- } @catch (NSException *exception) {
2516
- // The rescue path must never take the app down with it.
2517
- RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason);
2518
- pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO);
2519
- } @finally {
2520
- pushyRoundCompleted.store(true);
2521
- dispatch_semaphore_signal(pushyRoundDone);
2522
- dispatch_group_leave(pushyHostRoundGroup);
2523
- }
2524
- }
2525
-
2526
- // Crash-rescue entry (§11.3), called from the handler's worker queue while
2527
- // the uncaught-exception handler holds the dying process. Ensures this
2528
- // process's round runs to completion within the budget, then activates a
2529
- // downloaded-but-unactivated version if one exists — the last chance before
2530
- // the process is gone.
2531
- + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime {
2532
- pushyCrashHoldActive.store(true);
2533
- [self startRoundWithDeadline:deadlineUptime];
2534
- if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) {
2535
- NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow();
2536
- if (remaining > 0) {
2537
- dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW,
2538
- (int64_t)(remaining * NSEC_PER_SEC)));
2539
- }
2540
- }
2541
- [self activatePendingVersion];
2542
- }
2543
-
2544
- // The alert-strategy variant of the §10.7 hole: the round downloaded a fix
2545
- // but deferred activation to JS, and JS is now dead. Activation is local and
2546
- // bounded (a state switch under the commit lock).
2547
- + (void)activatePendingVersion {
2548
- NSString *hash = nil;
2549
- uint64_t generation = 0;
2550
- @synchronized (self) {
2551
- hash = pushyUnactivatedHash;
2552
- generation = pushyUnactivatedGeneration;
2553
- }
2554
- if (hash == nil) {
2555
- return;
2556
- }
2557
- // Unlike Android's switchVersion, PushySwitchVersionLocked does not
2558
- // validate the version directory; never point the next launch at bytes
2559
- // that are gone (e.g. wiped by a reset whose generation bump we would
2560
- // catch below anyway, or by cleanup).
2561
- NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
2562
- if (!PushyHasCompletedVersionAtPath(versionDir, hash)) {
2563
- NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash);
2564
- return;
2565
- }
2566
- NSDictionary *hashInfoEntry = nil;
2567
- NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)];
2568
- NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding];
2569
- id parsed = existingData == nil ? nil :
2570
- [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil];
2571
- if ([parsed isKindOfClass:[NSDictionary class]]) {
2572
- NSMutableDictionary *merged = [parsed mutableCopy];
2573
- merged[PushyInfoCrashHold] = @YES;
2574
- hashInfoEntry = @{@"hash": hash, @"info": merged};
2575
- }
2576
- BOOL committed = [self commitRoundWithGeneration:generation
2577
- hashInfo:hashInfoEntry
2578
- activate:hash
2579
- responseText:nil
2580
- request:nil
2581
- config:nil
2582
- responseAt:0
2583
- activated:NULL];
2584
- if (committed) {
2585
- @synchronized (self) {
2586
- pushyUnactivatedHash = nil;
2587
- }
2588
- NSLog(@"RCTPushy -- crash hold: activated downloaded version %@", hash);
2589
- } else {
2590
- NSLog(@"RCTPushy -- crash hold: reset since download, dropping activation");
2591
- }
2592
- }
2593
-
2594
- // A bare module instance drives the existing download/patch pipeline without
2595
- // a bridge: the file queue is process-global and progress events are gated on
2596
- // hasListeners (never set without a bridge).
2597
- + (RCTPushy *)engine {
2598
- static RCTPushy *engine;
2599
- static dispatch_once_t onceToken;
2600
- dispatch_once(&onceToken, ^{
2601
- engine = [[RCTPushy alloc] init];
2602
- });
2603
- return engine;
2604
- }
2605
-
2606
- + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {
2607
- const uint64_t resetGeneration = pushyResetGeneration.load();
2608
- pushyHostRoundGeneration = resetGeneration;
2609
- pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load();
2610
- pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO);
2611
- NSUserDefaults *defaults = PushyDefaults();
2612
- NSString *configJson = [defaults stringForKey:keyNativeConfig];
2613
- pushyHostRoundConfig = [configJson copy];
2614
- if (configJson.length == 0) {
2615
- pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO);
2616
- return;
2617
- }
2618
- bool ok = false;
2619
- flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok);
2620
- if (!ok || !config.IsObject()) {
2621
- pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO);
2622
- return;
2623
- }
2624
- if (config.Get("disabled").Truthy()) {
2625
- pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO);
2626
- return;
2627
- }
2628
- NSString *appKey = PushyFromStdString(config.Get("appKey").AsString());
2629
- if (appKey.length == 0) {
2630
- pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO);
2631
- return;
2632
- }
2633
- // Preserve the interrupted-round breadcrumb and reset-safe atomic commit.
2634
- [defaults setObject:@YES forKey:keyNativeCheckIncomplete];
2635
- @try {
2636
- [self runConfiguredRound:config
2637
- configJson:configJson
2638
- appKey:appKey
2639
- launchRolledBackVersion:launchRolledBackVersion
2640
- resetGeneration:resetGeneration
2641
- deadline:deadlineUptime];
2642
- } @finally {
2643
- [defaults removeObjectForKey:keyNativeCheckIncomplete];
2644
- }
2645
- }
2646
-
2647
- + (void)runConfiguredRound:(const flowjson::Value &)config
2648
- configJson:(NSString *)configJson
2649
- appKey:(NSString *)appKey
2650
- launchRolledBackVersion:(NSString *)launchRolledBackVersion
2651
- resetGeneration:(uint64_t)resetGeneration
2652
- deadline:(NSTimeInterval)deadlineUptime {
2653
- NSUserDefaults *defaults = PushyDefaults();
2654
- NSString *packageVersion =
2655
- PushyFromStdString(config.Get("packageVersion").AsString());
2656
- if (packageVersion.length == 0) {
2657
- packageVersion = [RCTPushy packageVersion];
2658
- }
2659
-
2660
- __block NSString *currentVersion = nil;
2661
- PushyWithStateLock(^{
2662
- pushy::state::State state = PushyStateFromDefaults(PushyDefaults());
2663
- currentVersion = PushyFromStdString(state.current_version);
2664
- });
2665
- NSString *rolledBackVersion = launchRolledBackVersion;
2666
- NSString *uuid = [defaults stringForKey:keyUuid] ?: @"";
2667
-
2668
- flowjson::Value identity = flowjson::Value::Object();
2669
- identity.Set("packageVersion",
2670
- flowjson::Value::String(PushyToStdString(packageVersion)));
2671
- if (currentVersion != nil) {
2672
- identity.Set("currentVersion",
2673
- flowjson::Value::String(PushyToStdString(currentVersion)));
2674
- }
2675
- identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid)));
2676
- if (rolledBackVersion != nil) {
2677
- identity.Set("rolledBackVersion",
2678
- flowjson::Value::String(PushyToStdString(rolledBackVersion)));
2679
- }
2680
-
2681
- flowjson::Value cInfo = flowjson::Value::Object();
2682
- cInfo.Set("rnu", config.Get("rnu"));
2683
- cInfo.Set("rn", config.Get("rn"));
2684
- cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString
2685
- stringWithFormat:@"%@ %@", PushyOsName, [[UIDevice currentDevice] systemVersion]])));
2686
- cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid)));
2687
-
2688
- flowjson::Value input = flowjson::Value::Object();
2689
- input.Set("packageVersion", identity.Get("packageVersion"));
2690
- if (currentVersion != nil) {
2691
- input.Set("currentVersion", identity.Get("currentVersion"));
2692
- }
2693
- input.Set("buildTime",
2694
- flowjson::Value::String(PushyToStdString([RCTPushy buildTime])));
2695
- input.Set("cInfo", cInfo);
2696
- input.Set("supportedDiffVersion",
2697
- flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion));
2698
- // Cache-only while a crash is being held (§11.3 budget); computed (and
2699
- // shared with JS getBundleHash) on the ordinary delayed round.
2700
- input.Set("bundleHash",
2701
- flowjson::Value::String(PushyToStdString(
2702
- PushyBundleHashSync(!pushyCrashHoldActive.load()))));
2703
-
2704
- std::string bodyJson =
2705
- flowjson::Stringify(flowcore::BuildRequestBody(input));
2706
- NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()];
2707
- if (body == nil) {
2708
- RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8");
2709
- pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO);
2710
- return;
2711
- }
2712
-
2713
- NSString *responseText = [self runQueryRequest:config
2714
- appKey:appKey
2715
- body:body
2716
- deadline:deadlineUptime];
2717
- if (responseText == nil) {
2718
- RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch");
2719
- return;
2720
- }
2721
- long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970];
2722
-
2723
- flowjson::Value decision = flowcore::HandleResponse(
2724
- PushyToStdString(responseText), identity, false,
2725
- config.Get("afterDownload").AsString());
2726
- if (decision.Get("action").AsString() != "download") {
2727
- BOOL committed = [self commitRoundWithGeneration:resetGeneration
2728
- hashInfo:nil
2729
- activate:nil
2730
- responseText:responseText
2731
- request:body
2732
- config:configJson
2733
- responseAt:responseAtSeconds
2734
- activated:NULL];
2735
- pushyHostRoundResult = committed
2736
- ? PushyHostResult(PushyStatusNone, PushyFromStdString(decision.Get("reason").AsString()), nil, NO)
2737
- : PushyHostResult(@"cancelled", @"reset", nil, NO);
2738
- RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)",
2739
- decision.Get("reason").AsString().c_str());
2740
- return;
2741
- }
2742
- NSString *hash = PushyFromStdString(decision.Get("hash").AsString());
2743
- if (!PushyIsSafePathComponent(hash)) {
2744
- pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO);
2745
- return;
2746
- }
2747
-
2748
- NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash];
2749
- BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash);
2750
- if (!downloaded) {
2751
- downloaded = [self performAttempts:decision.Get("attempts")
2752
- hash:hash
2753
- originHash:currentVersion
2754
- deadline:deadlineUptime];
2755
- }
2756
- if (!downloaded) {
2757
- BOOL committed = [self commitRoundWithGeneration:resetGeneration
2758
- hashInfo:nil
2759
- activate:nil
2760
- responseText:responseText
2761
- request:body
2762
- config:configJson
2763
- responseAt:responseAtSeconds
2764
- activated:NULL];
2765
- pushyHostRoundResult = committed
2766
- ? PushyHostResult(@"failed", @"download_failed", nil, NO)
2767
- : PushyHostResult(@"cancelled", @"reset", nil, NO);
2768
- return;
2769
- }
2770
-
2771
- // Persist name/description/metaInfo alongside the version, mirroring the
2772
- // JS side's setLocalHashInfo after a successful download.
2773
- const flowjson::Value &info = decision.Get("info");
2774
- NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary];
2775
- for (const char *key : {"name", "description", "metaInfo"}) {
2776
- if (info.Get(key).IsString()) {
2777
- versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @"";
2778
- }
2779
- }
2780
- // A forceBoot activation is the brick-rescue path: mark it in the
2781
- // persisted info so JS can report force_boot_rescue when this version
2782
- // survives to markSuccess. Only the server-sent directive counts — a
2783
- // silent-strategy activation is ordinary delivery.
2784
- if (info.Get("config").Get("forceBoot").Truthy()) {
2785
- versionInfo[PushyInfoForceBoot] = @YES;
2786
- }
2787
- if (pushyCrashHoldActive.load()) {
2788
- versionInfo[PushyInfoCrashHold] = @YES;
2789
- }
2790
- // Silent strategies or a server-marked forceBoot version (per-version
2791
- // remote override — the brick rescue) activate for the next launch;
2792
- // otherwise activation stays with the JS side (§6/§10.1). Unless a crash
2793
- // is being held: JS is dead, deferring to it would leave the fix on disk
2794
- // forever (§11.3). Or the launch is blocked on reinstalling a version
2795
- // tvOS purged: JS has not started, and the whole point is to boot it —
2796
- // commitRoundWithGeneration drops that one once the restore window closed.
2797
- BOOL activate = decision.Get("activate").Truthy() || pushyCrashHoldActive.load()
2798
- || pushyPurgeRestoreActive.load();
2799
- BOOL activated = NO;
2800
- BOOL committed = [self commitRoundWithGeneration:resetGeneration
2801
- hashInfo:@{@"hash": hash, @"info": versionInfo}
2802
- activate:activate ? hash : nil
2803
- responseText:responseText
2804
- request:body
2805
- config:configJson
2806
- responseAt:responseAtSeconds
2807
- activated:&activated];
2808
- activate = activated;
2809
- if (!committed) {
2810
- RCTLogInfo(@"RCTPushy -- native sync: reset during round, dropping result");
2811
- } else if (activate) {
2812
- @synchronized (self) {
2813
- pushyUnactivatedHash = nil;
2814
- }
2815
- RCTLogInfo(@"RCTPushy -- native sync: downloaded %@ and set for next launch", hash);
2816
- } else {
2817
- // Remembered so a crash later in this process can still activate it
2818
- // (activatePendingVersion) — JS never will.
2819
- @synchronized (self) {
2820
- pushyUnactivatedHash = [hash copy];
2821
- pushyUnactivatedGeneration = resetGeneration;
2822
- }
2823
- RCTLogInfo(@"RCTPushy -- native sync: downloaded %@, activation left to JS", hash);
2824
- }
2825
- pushyHostRoundResult = committed
2826
- ? PushyHostResult(@"downloaded", @"", hash, activate)
2827
- : PushyHostResult(@"cancelled", @"reset", nil, NO);
2828
- }
2829
-
2830
- // Everything a round persists — version info, the activation, the response
2831
- // cache — is written inside ONE state-lock acquisition that first re-checks the
2832
- // reset generation. resetToPackagedBundle bumps that generation under the same
2833
- // lock, so there is no compare-and-act window: either the whole round commits,
2834
- // or the reset wins and none of it does.
2835
- + (BOOL)commitRoundWithGeneration:(uint64_t)generation
2836
- hashInfo:(NSDictionary *)hashInfoEntry
2837
- activate:(NSString *)hashToActivate
2838
- responseText:(NSString *)responseText
2839
- request:(NSString *)requestBody
2840
- config:(NSString *)configJson
2841
- responseAt:(long long)responseAtSeconds
2842
- activated:(BOOL *)activatedOut {
2843
- // responseText is nil for the crash handler's activation-only commit
2844
- // (activatePendingVersion): no round ran, so there is no cache to write.
2845
- NSData *cacheData = nil;
2846
- if (responseText != nil) {
2847
- NSDictionary *cacheEntry = @{
2848
- @"ts": @(responseAtSeconds),
2849
- @"body": responseText,
2850
- @"request": requestBody,
2851
- @"config": configJson,
2852
- };
2853
- cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil];
2854
- }
2855
- __block BOOL committed = NO;
2856
- __block BOOL activated = NO;
2857
- PushyWithStateLock(^{
2858
- if (pushyResetGeneration.load() != generation) {
2859
- return;
2860
- }
2861
- NSString *activation = hashToActivate;
2862
- NSDictionary *info = hashInfoEntry[@"info"];
2863
- // The tvOS purged-version restore round (a round commit, not the
2864
- // crash handler's activation-only one): it may take over this launch
2865
- // only while +bundleURL still waits for it; after that the version is
2866
- // left to JS unless a crash is being held (JS is dead then).
2867
- if (responseText != nil && pushyPurgeRestoreActive.load()) {
2868
- if (pushyPurgeRestoreWindowOpen) {
2869
- if (activation != nil && info != nil) {
2870
- NSMutableDictionary *marked = [info mutableCopy];
2871
- marked[@"purgeRestore"] = @YES;
2872
- info = marked;
2873
- }
2874
- } else if (!pushyCrashHoldActive.load()) {
2875
- activation = nil;
2876
- }
2877
- }
2878
- NSUserDefaults *defaults = PushyDefaults();
2879
- if (hashInfoEntry != nil) {
2880
- NSData *infoData = [NSJSONSerialization dataWithJSONObject:info
2881
- options:0
2882
- error:nil];
2883
- if (infoData != nil) {
2884
- [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding]
2885
- forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])];
2886
- }
2887
- }
2888
- if (activation != nil) {
2889
- PushySwitchVersionLocked(activation);
2890
- activated = YES;
2891
- }
2892
- if (cacheData != nil) {
2893
- [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding]
2894
- forKey:keyNativeCheckCache];
2895
- }
2896
- committed = YES;
2897
- });
2898
- if (activatedOut != NULL) {
2899
- *activatedOut = activated;
2900
- }
2901
- return committed;
2902
- }
2903
-
2904
- // Sequential fallback over the ordered candidates (§5.1): one request at a
2905
- // time with its own timeout; after the configured round fails, queryUrls
2906
- // discovery merges remote candidates (excluding the already-tried) for one
2907
- // more round. No hedged race on purpose — this path is latency-insensitive.
2908
- // Per-request timeout, capped to the crash-rescue budget when one is active.
2909
- // <= 0 means the budget is gone and the round must stop issuing requests.
2910
- static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) {
2911
- if (deadlineUptime <= 0) {
2912
- return 10;
2913
- }
2914
- return MIN(10, deadlineUptime - PushyMonotonicNow());
2915
- }
2916
-
2917
- + (NSString *)runQueryRequest:(const flowjson::Value &)config
2918
- appKey:(NSString *)appKey
2919
- body:(NSString *)body
2920
- deadline:(NSTimeInterval)deadlineUptime {
2921
- double sample = arc4random() / 4294967296.0;
2922
- flowjson::Value ordered =
2923
- flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample);
2924
- NSMutableSet<NSString *> *tried = [NSMutableSet set];
2925
- const NSUInteger maxHttpAttempts = 8;
2926
- NSUInteger httpAttempts = 0;
2927
- for (const auto &endpoint : ordered.elements()) {
2928
- NSString *base = PushyNormalizeEndpointBase(
2929
- PushyFromStdString(endpoint.AsString()));
2930
- if (base.length == 0 || [tried containsObject:base]) {
2931
- continue;
2932
- }
2933
- if (httpAttempts++ >= maxHttpAttempts) {
2934
- return nil;
2935
- }
2936
- [tried addObject:base];
2937
- NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime);
2938
- if (timeout <= 0) {
2939
- return nil;
2940
- }
2941
- NSString *response = PushyHttpRequest(
2942
- [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey],
2943
- @"POST", body, timeout);
2944
- if (PushyIsValidResponse(response)) {
2945
- return response;
2946
- }
2947
- }
2948
- for (const auto &queryUrl : config.Get("queryUrls").elements()) {
2949
- NSString *listUrl = PushyFromStdString(queryUrl.AsString());
2950
- if (listUrl == nil) {
2951
- continue;
2952
- }
2953
- if (httpAttempts++ >= maxHttpAttempts) {
2954
- return nil;
2955
- }
2956
- NSTimeInterval listTimeout = PushyQueryRequestTimeout(deadlineUptime);
2957
- if (listTimeout <= 0) {
2958
- return nil;
2959
- }
2960
- NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout);
2961
- if (listText == nil) {
2962
- continue;
2963
- }
2964
- bool ok = false;
2965
- flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok);
2966
- if (!ok || !remote.IsArray()) {
2967
- continue;
2968
- }
2969
- for (const auto &endpoint : remote.elements()) {
2970
- NSString *base = PushyNormalizeEndpointBase(
2971
- PushyFromStdString(endpoint.AsString()));
2972
- if (base.length == 0 || [tried containsObject:base]) {
2973
- continue;
2974
- }
2975
- if (httpAttempts++ >= maxHttpAttempts) {
2976
- return nil;
2977
- }
2978
- [tried addObject:base];
2979
- NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime);
2980
- if (timeout <= 0) {
2981
- return nil;
2982
- }
2983
- NSString *response = PushyHttpRequest(
2984
- [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey],
2985
- @"POST", body, timeout);
2986
- if (PushyIsValidResponse(response)) {
2987
- return response;
2988
- }
2989
- }
2990
- break; // one successfully fetched remote list is enough
2991
- }
2992
- return nil;
2993
- }
2994
-
2995
- + (BOOL)performAttempts:(const flowjson::Value &)attempts
2996
- hash:(NSString *)hash
2997
- originHash:(NSString *)originHash
2998
- deadline:(NSTimeInterval)holdDeadline {
2999
- RCTPushy *engine = [self engine];
3000
- // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows.
3001
- NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600;
3002
- if (holdDeadline > 0) {
3003
- incrementalDeadline = MIN(incrementalDeadline, holdDeadline);
3004
- }
3005
- NSTimeInterval fullDeadline = 0;
3006
- for (const auto &attempt : attempts.elements()) {
3007
- const std::string &type = attempt.Get("type").AsString();
3008
- PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk
3009
- : type == "pdiff" ? PushyTypePatchFromPackage
3010
- : PushyTypeFullDownload;
3011
- if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) {
3012
- continue; // diff patches from the running version; none running
3013
- }
3014
- BOOL isFullAttempt = pushyType == PushyTypeFullDownload;
3015
- if (isFullAttempt && fullDeadline == 0) {
3016
- // diff/pdiff cannot starve the last-resort full download.
3017
- fullDeadline = PushyMonotonicNow() + 600;
3018
- if (holdDeadline > 0) {
3019
- fullDeadline = MIN(fullDeadline, holdDeadline);
3020
- }
3021
- }
3022
- NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline;
3023
- for (const auto &urlValue : attempt.Get("urls").elements()) {
3024
- NSString *url = PushyFromStdString(urlValue.AsString());
3025
- if (url == nil) {
3026
- continue;
3027
- }
3028
- NSTimeInterval remaining = deadline - PushyMonotonicNow();
3029
- if (remaining <= 0) {
3030
- if (isFullAttempt) {
3031
- return NO;
3032
- }
3033
- break;
3034
- }
3035
- NSMutableDictionary *options =
3036
- [@{
3037
- PushyOptionUrlKey: url,
3038
- @"hash": hash,
3039
- @"deadlineUptime": @(deadline),
3040
- } mutableCopy];
3041
- if (pushyType == PushyTypePatchFromPpk) {
3042
- options[@"originHash"] = originHash;
3043
- }
3044
- dispatch_semaphore_t sem = dispatch_semaphore_create(0);
3045
- __block NSError *resultError = nil;
3046
- [engine performFetch:pushyType options:options callback:^(NSError *error) {
3047
- resultError = error;
3048
- dispatch_semaphore_signal(sem);
3049
- }];
3050
- if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW,
3051
- (int64_t)(remaining * NSEC_PER_SEC))) != 0) {
3052
- RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str());
3053
- if (isFullAttempt) {
3054
- return NO;
3055
- }
3056
- break;
3057
- }
3058
- if (resultError == nil) {
3059
- return YES;
3060
- }
3061
- RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@",
3062
- type.c_str(), resultError.localizedDescription);
3063
- }
3064
- }
3065
- return NO;
3066
- }
3067
-
3068
- @end