react-native-update 10.56.1 → 10.57.1

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.
@@ -1,4 +1,5 @@
1
1
  #import "RCTPushy.h"
2
+ #import "RCTPushyNativeConfig.h"
2
3
  #import "RCTPushyDownloader.h"
3
4
  #import "ZipArchive.h"
4
5
  #include "../../cpp/patch_core/archive_limits.h"
@@ -36,6 +37,13 @@
36
37
  #include <atomic>
37
38
  #include <sys/stat.h>
38
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
+
39
47
  static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY";
40
48
  // Binary identity (SyncBinaryVersion input). Prefixed like every other key:
41
49
  // the unprefixed names collided with generic host-app/SDK defaults, and a
@@ -720,6 +728,9 @@ static void PushySwitchVersionLocked(NSString *hash) {
720
728
  // bundle — this is what lets a bricked hot update be replaced on the next
721
729
  // launch. Decisions come from cpp/update_flow_core; this class is IO glue.
722
730
  @interface RCTPushyOrchestrator : NSObject
731
+ + (void)persistConfiguration:(NSString *)config;
732
+ + (BOOL)hasRunnableConfig;
733
+ + (NSDictionary *)checkAndUpdate;
723
734
  + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion;
724
735
  + (void)markJsCheckCompleted:(NSString *)config;
725
736
  + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime;
@@ -761,6 +772,14 @@ static NSTimeInterval pushyProcessAnchorUptime = 0;
761
772
  // (markJsCheckCompleted). Process-scoped by design. Guarded by
762
773
  // @synchronized (RCTPushyOrchestrator class).
763
774
  static NSString *pushyJsCompletedConfig = nil;
775
+ // The group supplements (rather than consumes) the crash-rescue semaphore.
776
+ static dispatch_group_t pushyHostRoundGroup;
777
+ static std::atomic<bool> pushyNativeCheckReady{false};
778
+ static NSDictionary *pushyHostRoundResult = nil;
779
+ static NSString *pushyHostRoundConfig = nil;
780
+ static uint64_t pushyHostRoundGeneration = 0;
781
+ static std::atomic<uint64_t> pushyNativeConfigGeneration{0};
782
+ static uint64_t pushyHostRoundConfigGeneration = 0;
764
783
 
765
784
  static const NSTimeInterval kPushyRescueTriggerUptime = 60;
766
785
  static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10;
@@ -1008,6 +1027,59 @@ RCT_EXPORT_MODULE(RCTPushy);
1008
1027
  return currentVersion;
1009
1028
  }
1010
1029
 
1030
+ + (void)configure:(NSDictionary<NSString *, id> *)options
1031
+ completion:(RCTPushyNativeConfigurationCompletion)completion
1032
+ {
1033
+ NSError *validationError = nil;
1034
+ // Snapshot nested mutable caller values before crossing a queue boundary.
1035
+ NSString *config = RCTPushyNormalizeNativeConfig(options, &validationError);
1036
+ static dispatch_queue_t configQueue;
1037
+ static dispatch_once_t once;
1038
+ dispatch_once(&once, ^{
1039
+ configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL);
1040
+ });
1041
+ dispatch_async(configQueue, ^{
1042
+ NSError *failure = validationError;
1043
+ if (config != nil) {
1044
+ @try {
1045
+ [RCTPushyOrchestrator persistConfiguration:config];
1046
+ } @catch (NSException *exception) {
1047
+ failure = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed,
1048
+ exception.reason ?: @"Native configuration failed");
1049
+ }
1050
+ }
1051
+ if (completion != nil) {
1052
+ dispatch_async(dispatch_get_main_queue(), ^{
1053
+ completion(failure);
1054
+ });
1055
+ }
1056
+ });
1057
+ }
1058
+
1059
+ + (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion
1060
+ {
1061
+ static dispatch_queue_t hostQueue;
1062
+ static dispatch_once_t once;
1063
+ dispatch_once(&once, ^{
1064
+ hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-check", DISPATCH_QUEUE_SERIAL);
1065
+ });
1066
+ dispatch_async(hostQueue, ^{
1067
+ NSDictionary *result;
1068
+ @try {
1069
+ result = [RCTPushyOrchestrator checkAndUpdate];
1070
+ } @catch (NSException *exception) {
1071
+ RCTLogWarn(@"RCTPushy -- native host check failed: %@", exception.reason);
1072
+ result = PushyHostResult(@"failed", @"internal_error", nil, NO);
1073
+ }
1074
+ if (completion != nil) {
1075
+ NSDictionary *snapshot = [result copy];
1076
+ dispatch_async(dispatch_get_main_queue(), ^{
1077
+ completion(snapshot);
1078
+ });
1079
+ }
1080
+ });
1081
+ }
1082
+
1011
1083
  + (BOOL)requiresMainQueueSetup
1012
1084
  {
1013
1085
  return NO;
@@ -1115,8 +1187,13 @@ RCT_EXPORT_METHOD(syncNativeConfig:(NSString *)config
1115
1187
  error != nil ? error.localizedDescription : ERROR_OPTIONS));
1116
1188
  return;
1117
1189
  }
1118
- [PushyDefaults() setObject:config forKey:keyNativeConfig];
1119
- resolve(@true);
1190
+ @try {
1191
+ [RCTPushyOrchestrator persistConfiguration:config];
1192
+ resolve(@true);
1193
+ } @catch (NSException *exception) {
1194
+ PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed,
1195
+ exception.reason ?: @"Native configuration failed"));
1196
+ }
1120
1197
  }
1121
1198
 
1122
1199
  RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve
@@ -2138,6 +2215,39 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2138
2215
 
2139
2216
  @implementation RCTPushyOrchestrator
2140
2217
 
2218
+ + (void)persistConfiguration:(NSString *)config {
2219
+ PushyWithStateLock(^{
2220
+ NSUserDefaults *defaults = PushyDefaults();
2221
+ if ([defaults stringForKey:keyUuid].length == 0) {
2222
+ [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid];
2223
+ }
2224
+ if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) {
2225
+ return;
2226
+ }
2227
+ // The same generation protects reset and replacement of the request
2228
+ // identity/policy, including a late crash-rescue activation.
2229
+ pushyResetGeneration.fetch_add(1);
2230
+ pushyNativeConfigGeneration.fetch_add(1);
2231
+ [defaults setObject:config forKey:keyNativeConfig];
2232
+ [defaults removeObjectForKey:keyNativeCheckCache];
2233
+ [self markJsCheckCompleted:nil];
2234
+ });
2235
+ if (pushyNativeCheckReady.load() && [self hasRunnableConfig]) {
2236
+ PushyInstallCrashRescueHandler();
2237
+ }
2238
+ }
2239
+
2240
+ + (BOOL)hasRunnableConfig {
2241
+ NSString *json = [PushyDefaults() stringForKey:keyNativeConfig];
2242
+ if (json.length == 0) {
2243
+ return NO;
2244
+ }
2245
+ bool ok = false;
2246
+ flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok);
2247
+ return ok && config.IsObject() && !config.Get("disabled").Truthy()
2248
+ && !config.Get("appKey").AsString().empty();
2249
+ }
2250
+
2141
2251
  + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion {
2142
2252
  #if !DEBUG
2143
2253
  // Once per process; a few seconds of delay keeps the check away from the
@@ -2147,8 +2257,11 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2147
2257
  static dispatch_once_t onceToken;
2148
2258
  dispatch_once(&onceToken, ^{
2149
2259
  pushyRoundDone = dispatch_semaphore_create(0);
2260
+ pushyHostRoundGroup = dispatch_group_create();
2261
+ dispatch_group_enter(pushyHostRoundGroup);
2150
2262
  pushyProcessAnchorUptime = PushyMonotonicNow();
2151
2263
  pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];
2264
+ pushyNativeCheckReady.store(true);
2152
2265
  NSUserDefaults *defaults = PushyDefaults();
2153
2266
  // The crash-hold rescue shares the orchestrator's rollout gate: no
2154
2267
  // persisted config, no handler (§11.3).
@@ -2171,6 +2284,49 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2171
2284
  #endif
2172
2285
  }
2173
2286
 
2287
+ + (NSDictionary *)checkAndUpdate {
2288
+ #if DEBUG
2289
+ return PushyHostResult(@"skipped", @"debug", nil, NO);
2290
+ #else
2291
+ if (!pushyNativeCheckReady.load()) {
2292
+ return PushyHostResult(@"skipped", @"not_initialized", nil, NO);
2293
+ }
2294
+ NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig];
2295
+ if (configJson.length == 0) {
2296
+ return PushyHostResult(@"skipped", @"not_configured", nil, NO);
2297
+ }
2298
+ id config = [NSJSONSerialization JSONObjectWithData:
2299
+ [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
2300
+ if (![config isKindOfClass:NSDictionary.class]) {
2301
+ return PushyHostResult(@"failed", @"invalid_config", nil, NO);
2302
+ }
2303
+ id disabled = config[@"disabled"];
2304
+ if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) {
2305
+ return PushyHostResult(@"skipped", @"disabled", nil, NO);
2306
+ }
2307
+ id appKey = config[@"appKey"];
2308
+ if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) {
2309
+ return PushyHostResult(@"failed", @"invalid_config", nil, NO);
2310
+ }
2311
+ [self startRoundWithDeadline:0];
2312
+ // A group is broadcast-style. Sharing the rescue semaphore would let one
2313
+ // waiter consume the only signal and leave the other waiting forever.
2314
+ if (!pushyRoundStarted.load()) {
2315
+ return PushyHostResult(@"skipped", @"config_changed", nil, NO);
2316
+ }
2317
+ dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER);
2318
+ if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()
2319
+ || ![configJson isEqualToString:pushyHostRoundConfig]
2320
+ || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) {
2321
+ return PushyHostResult(@"cancelled", @"config_changed", nil, NO);
2322
+ }
2323
+ if (pushyHostRoundGeneration != pushyResetGeneration.load()) {
2324
+ return PushyHostResult(@"cancelled", @"reset", nil, NO);
2325
+ }
2326
+ return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO);
2327
+ #endif
2328
+ }
2329
+
2174
2330
  + (void)markJsCheckCompleted:(NSString *)config {
2175
2331
  @synchronized (RCTPushyOrchestrator.class) {
2176
2332
  pushyJsCompletedConfig = [config copy];
@@ -2197,6 +2353,10 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2197
2353
  // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and
2198
2354
  // download phase to the remaining budget.
2199
2355
  + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {
2356
+ // Missing/disabled configuration is a preflight skip, not a used round.
2357
+ if (![self hasRunnableConfig]) {
2358
+ return;
2359
+ }
2200
2360
  bool expected = false;
2201
2361
  if (!pushyRoundStarted.compare_exchange_strong(expected, true)) {
2202
2362
  return;
@@ -2206,9 +2366,11 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2206
2366
  } @catch (NSException *exception) {
2207
2367
  // The rescue path must never take the app down with it.
2208
2368
  RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);
2369
+ pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO);
2209
2370
  } @finally {
2210
2371
  pushyRoundCompleted.store(true);
2211
2372
  dispatch_semaphore_signal(pushyRoundDone);
2373
+ dispatch_group_leave(pushyHostRoundGroup);
2212
2374
  }
2213
2375
  }
2214
2376
 
@@ -2220,7 +2382,7 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2220
2382
  + (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime {
2221
2383
  pushyCrashRescueActive.store(true);
2222
2384
  [self startRoundWithDeadline:deadlineUptime];
2223
- if (!pushyRoundCompleted.load()) {
2385
+ if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) {
2224
2386
  NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow();
2225
2387
  if (remaining > 0) {
2226
2388
  dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW,
@@ -2292,27 +2454,33 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2292
2454
  }
2293
2455
 
2294
2456
  + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {
2295
- // Sampled before any IO: resetToPackagedBundle bumps it, and a reset that
2296
- // lands while this round is running must win over the round's decision.
2297
2457
  const uint64_t resetGeneration = pushyResetGeneration.load();
2458
+ pushyHostRoundGeneration = resetGeneration;
2459
+ pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load();
2460
+ pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO);
2298
2461
  NSUserDefaults *defaults = PushyDefaults();
2299
2462
  NSString *configJson = [defaults stringForKey:keyNativeConfig];
2463
+ pushyHostRoundConfig = [configJson copy];
2300
2464
  if (configJson.length == 0) {
2301
- // No persisted config (old integration / first ever launch): the
2302
- // native check silently does not run — this is the rollout gate.
2465
+ pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO);
2303
2466
  return;
2304
2467
  }
2305
2468
  bool ok = false;
2306
2469
  flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok);
2307
- if (!ok || !config.IsObject() || config.Get("disabled").Truthy()) {
2470
+ if (!ok || !config.IsObject()) {
2471
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO);
2472
+ return;
2473
+ }
2474
+ if (config.Get("disabled").Truthy()) {
2475
+ pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO);
2308
2476
  return;
2309
2477
  }
2310
2478
  NSString *appKey = PushyFromStdString(config.Get("appKey").AsString());
2311
2479
  if (appKey.length == 0) {
2480
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO);
2312
2481
  return;
2313
2482
  }
2314
- // From here on the round does real work: leave the breadcrumb that the
2315
- // next launch reads to skip its 5s delay if we die mid-round (§11.4).
2483
+ // Preserve the interrupted-round breadcrumb and reset-safe atomic commit.
2316
2484
  [defaults setObject:@YES forKey:keyNativeCheckIncomplete];
2317
2485
  @try {
2318
2486
  [self runConfiguredRound:config
@@ -2388,6 +2556,7 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2388
2556
  NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()];
2389
2557
  if (body == nil) {
2390
2558
  RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");
2559
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO);
2391
2560
  return;
2392
2561
  }
2393
2562
 
@@ -2405,19 +2574,23 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2405
2574
  PushyToStdString(responseText), identity, false,
2406
2575
  config.Get("afterDownload").AsString());
2407
2576
  if (decision.Get("action").AsString() != "download") {
2408
- [self commitRoundWithGeneration:resetGeneration
2577
+ BOOL committed = [self commitRoundWithGeneration:resetGeneration
2409
2578
  hashInfo:nil
2410
2579
  activate:nil
2411
2580
  responseText:responseText
2412
2581
  request:body
2413
2582
  config:configJson
2414
2583
  responseAt:responseAtSeconds];
2584
+ pushyHostRoundResult = committed
2585
+ ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO)
2586
+ : PushyHostResult(@"cancelled", @"reset", nil, NO);
2415
2587
  RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",
2416
2588
  decision.Get("reason").AsString().c_str());
2417
2589
  return;
2418
2590
  }
2419
2591
  NSString *hash = PushyFromStdString(decision.Get("hash").AsString());
2420
2592
  if (!PushyIsSafePathComponent(hash)) {
2593
+ pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO);
2421
2594
  return;
2422
2595
  }
2423
2596
 
@@ -2430,13 +2603,16 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2430
2603
  deadline:deadlineUptime];
2431
2604
  }
2432
2605
  if (!downloaded) {
2433
- [self commitRoundWithGeneration:resetGeneration
2606
+ BOOL committed = [self commitRoundWithGeneration:resetGeneration
2434
2607
  hashInfo:nil
2435
2608
  activate:nil
2436
2609
  responseText:responseText
2437
2610
  request:body
2438
2611
  config:configJson
2439
2612
  responseAt:responseAtSeconds];
2613
+ pushyHostRoundResult = committed
2614
+ ? PushyHostResult(@"failed", @"download_failed", nil, NO)
2615
+ : PushyHostResult(@"cancelled", @"reset", nil, NO);
2440
2616
  return;
2441
2617
  }
2442
2618
 
@@ -2488,6 +2664,9 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) {
2488
2664
  }
2489
2665
  RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash);
2490
2666
  }
2667
+ pushyHostRoundResult = committed
2668
+ ? PushyHostResult(@"downloaded", @"", hash, activate)
2669
+ : PushyHostResult(@"cancelled", @"reset", nil, NO);
2491
2670
  }
2492
2671
 
2493
2672
  // Everything a round persists — version info, the activation, the response
@@ -0,0 +1,5 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ // Validates and snapshots host options without changing any update state.
4
+ FOUNDATION_EXPORT NSString * _Nullable RCTPushyNormalizeNativeConfig(
5
+ NSDictionary * _Nonnull options, NSError * _Nullable * _Nullable error);
@@ -0,0 +1,103 @@
1
+ #import "RCTPushyNativeConfig.h"
2
+
3
+ static void PushyConfigInvalid(NSString *message) {
4
+ @throw [NSException exceptionWithName:NSInvalidArgumentException
5
+ reason:[@"Invalid native configuration: " stringByAppendingString:message]
6
+ userInfo:nil];
7
+ }
8
+
9
+ static NSString *PushyConfigString(id value, NSString *name, BOOL allowEmpty) {
10
+ if (![value isKindOfClass:NSString.class]
11
+ || (!allowEmpty && [[value stringByTrimmingCharactersInSet:
12
+ NSCharacterSet.whitespaceAndNewlineCharacterSet] length] == 0)) {
13
+ PushyConfigInvalid([NSString stringWithFormat:@"%@ must be a string%@",
14
+ name, allowEmpty ? @"" : @" and must not be blank"]);
15
+ }
16
+ return value;
17
+ }
18
+
19
+ static NSArray *PushyConfigUrls(id value, NSString *name, BOOL base) {
20
+ if (![value isKindOfClass:NSArray.class] || (base && [value count] == 0)) {
21
+ PushyConfigInvalid([NSString stringWithFormat:@"%@ must be %@ array",
22
+ name, base ? @"a non-empty" : @"an"]);
23
+ }
24
+ NSMutableArray *result = [NSMutableArray array];
25
+ NSRegularExpression *authority = [NSRegularExpression regularExpressionWithPattern:
26
+ @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)"
27
+ options:0 error:nil];
28
+ for (id item in value) {
29
+ NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet:
30
+ NSCharacterSet.whitespaceAndNewlineCharacterSet];
31
+ if ([authority firstMatchInString:url options:0 range:NSMakeRange(0, url.length)] == nil
32
+ || [url rangeOfCharacterFromSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].location != NSNotFound
33
+ || [url containsString:@"\\"]
34
+ || (base && ([url containsString:@"?"] || [url containsString:@"#"]))) {
35
+ PushyConfigInvalid([NSString stringWithFormat:
36
+ @"%@ requires absolute HTTP(S) URLs without credentials%@",
37
+ name, base ? @", queries or fragments" : @""]);
38
+ }
39
+ if (base) {
40
+ while ([url hasSuffix:@"/"]) {
41
+ url = [url substringToIndex:url.length - 1];
42
+ }
43
+ }
44
+ if (![result containsObject:url]) {
45
+ [result addObject:url];
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ NSString *RCTPushyNormalizeNativeConfig(NSDictionary *options, NSError **error) {
52
+ @try {
53
+ if (![options isKindOfClass:NSDictionary.class]) {
54
+ PushyConfigInvalid(@"expected an object");
55
+ }
56
+ NSArray *keys = @[@"appKey", @"endpoints", @"queryUrls", @"afterDownload",
57
+ @"disabled", @"packageVersion", @"rnu", @"rn"];
58
+ for (id key in options) {
59
+ if (![keys containsObject:key]) {
60
+ PushyConfigInvalid([NSString stringWithFormat:@"unknown option %@", key]);
61
+ }
62
+ }
63
+ NSString *appKey = PushyConfigString(options[@"appKey"], @"appKey", NO);
64
+ BOOL customEndpoints = options[@"endpoints"] != nil;
65
+ NSArray *endpoints = PushyConfigUrls(customEndpoints ? options[@"endpoints"]
66
+ : @[@"https://update.react-native.cn/api", @"https://update.reactnative.cn/api"],
67
+ @"endpoints", YES);
68
+ NSArray *queryUrls = PushyConfigUrls(options[@"queryUrls"] ?: (customEndpoints ? @[]
69
+ : @[@"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json",
70
+ @"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json"]),
71
+ @"queryUrls", NO);
72
+ NSString *afterDownload = options[@"afterDownload"]
73
+ ? PushyConfigString(options[@"afterDownload"], @"afterDownload", NO) : @"none";
74
+ if (![@[@"none", @"setNeedUpdate"] containsObject:afterDownload]) {
75
+ PushyConfigInvalid(@"afterDownload must be none or setNeedUpdate");
76
+ }
77
+ id disabled = options[@"disabled"] ?: @NO;
78
+ if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {
79
+ PushyConfigInvalid(@"disabled must be a boolean");
80
+ }
81
+ NSMutableDictionary *result = [@{
82
+ @"appKey": appKey, @"endpoints": endpoints, @"queryUrls": queryUrls,
83
+ @"afterDownload": afterDownload, @"disabled": disabled,
84
+ @"rnu": options[@"rnu"] ? PushyConfigString(options[@"rnu"], @"rnu", YES) : @"",
85
+ @"rn": options[@"rn"] ? PushyConfigString(options[@"rn"], @"rn", YES) : @""
86
+ } mutableCopy];
87
+ if (options[@"packageVersion"] != nil) {
88
+ result[@"packageVersion"] = PushyConfigString(options[@"packageVersion"], @"packageVersion", NO);
89
+ }
90
+ // Stable ordering makes repeated identical configure calls idempotent.
91
+ NSData *data = [NSJSONSerialization dataWithJSONObject:result
92
+ options:NSJSONWritingSortedKeys error:error];
93
+ return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
94
+ } @catch (NSException *exception) {
95
+ if (error != NULL) {
96
+ *error = [NSError errorWithDomain:@"cn.reactnative.pushy" code:1 userInfo:@{
97
+ NSLocalizedDescriptionKey: exception.reason ?: @"Invalid native configuration",
98
+ @"PushyErrorCode": @"INVALID_OPTIONS"
99
+ }];
100
+ }
101
+ return nil;
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.56.1",
3
+ "version": "10.57.1",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
@@ -43,7 +43,13 @@ Pod::Spec.new do |s|
43
43
  end
44
44
 
45
45
  # Set platform based on whether it's a valid Expo project and if we can parse its target
46
- final_ios_deployment_target = '11.0' # Default target
46
+ # React Native's own minimum (defined by react_native_pods.rb, which the
47
+ # Podfile loads before evaluating podspecs). The pod platform version also
48
+ # becomes the deployment target of the privacy resource bundle target,
49
+ # which React Native's post_install does not raise: Xcode 27 rejects
50
+ # anything below 15.0 there, so a hard-coded 11.0 fails the build.
51
+ final_ios_deployment_target =
52
+ defined?(min_ios_version_supported) ? min_ios_version_supported : '11.0'
47
53
 
48
54
  if valid_expo_project
49
55
  # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project ---
@@ -26,8 +26,9 @@ export interface Spec extends TurboModule {
26
26
  * Persist the config subset the native cold-start update check consumes
27
27
  * (appKey, endpoints, afterDownload policy; NATIVE_CHECKUPDATE_DESIGN
28
28
  * §10.1). Stored as a raw JSON string, parsed natively on read. JS is the
29
- * single config source — a native side without persisted config silently
30
- * skips its check, which doubles as the feature's rollout gate.
29
+ * default config source. Native hosts may also call configure(); select
30
+ * nativeConfigSource: 'native' in JS to leave host configuration untouched.
31
+ * A native side without persisted config skips its check.
31
32
  */
32
33
  syncNativeConfig(config: string): Promise<void>;
33
34
  /**
package/src/client.ts CHANGED
@@ -420,6 +420,10 @@ export class Pushy {
420
420
  };
421
421
 
422
422
  private flushNativeConfig = () => {
423
+ if (this.options.nativeConfigSource === 'native') {
424
+ this.pendingNativeConfigJson = undefined;
425
+ return;
426
+ }
423
427
  if (this.nativeConfigSyncInFlight) {
424
428
  return;
425
429
  }
@@ -480,6 +484,10 @@ export class Pushy {
480
484
  };
481
485
 
482
486
  private syncNativeConfig = () => {
487
+ if (this.options.nativeConfigSource === 'native') {
488
+ this.pendingNativeConfigJson = undefined;
489
+ return;
490
+ }
483
491
  if (
484
492
  Platform.OS === 'web' ||
485
493
  typeof PushyModule.syncNativeConfig !== 'function'
package/src/type.ts CHANGED
@@ -228,6 +228,13 @@ export interface ClientOptions {
228
228
  * per-version forceBoot directive may.
229
229
  */
230
230
  disableNativeCheck?: boolean;
231
+ /**
232
+ * Owner of persisted native update configuration. Default: 'javascript'.
233
+ * Use 'native' when configure() is called by the host: JS will not overwrite
234
+ * that configuration. This does not configure or disable JS checks itself;
235
+ * keep appKey/server consistent and use checkStrategy to control JS checks.
236
+ */
237
+ nativeConfigSource?: 'javascript' | 'native';
231
238
  }
232
239
 
233
240
  export interface UpdateTestPayload {