react-native-update 10.51.1 → 10.52.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.
@@ -2,14 +2,94 @@
2
2
 
3
3
  static NSString *const RCTPushyDownloaderErrorDomain = @"cn.reactnative.pushy";
4
4
 
5
- @interface RCTPushyDownloader()<NSURLSessionDownloadDelegate>
5
+ // Cross-launch resumable download (NATIVE_CHECKUPDATE_DESIGN §11.4): the
6
+ // archive streams straight into savePath (an NSURLSessionDownloadTask's
7
+ // temporary file is discarded on failure, which made every partial byte
8
+ // worthless), and a sidecar next to it records what the partial belongs to
9
+ // (url + validators + total). A brick gets a few hundred milliseconds per
10
+ // launch plus a bounded crash-rescue window, so progress must be monotonic
11
+ // across process deaths.
12
+
13
+ static NSString *RCTPushyResumeSidecarPath(NSString *savePath) {
14
+ return [savePath stringByAppendingString:@".resume"];
15
+ }
16
+
17
+ static NSDictionary *RCTPushyReadResumeMeta(NSString *savePath, NSString *url) {
18
+ NSData *data = [NSData dataWithContentsOfFile:RCTPushyResumeSidecarPath(savePath)];
19
+ if (data == nil) {
20
+ return nil;
21
+ }
22
+ id meta = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
23
+ if (![meta isKindOfClass:[NSDictionary class]]) {
24
+ return nil;
25
+ }
26
+ // An archive whose sidecar names another URL is untrusted.
27
+ if (![url isEqualToString:meta[@"url"]]) {
28
+ return nil;
29
+ }
30
+ return meta;
31
+ }
32
+
33
+ static void RCTPushyDeleteResumeSidecar(NSString *savePath) {
34
+ [[NSFileManager defaultManager] removeItemAtPath:RCTPushyResumeSidecarPath(savePath)
35
+ error:nil];
36
+ }
37
+
38
+ static long long RCTPushyFileSize(NSString *path) {
39
+ NSDictionary *attributes =
40
+ [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
41
+ NSNumber *size = attributes[NSFileSize];
42
+ return size == nil ? -1 : size.longLongValue;
43
+ }
44
+
45
+ // "bytes <start>-<end>/<total>". Returns the total (0 when "*"), or -1 when
46
+ // missing/malformed or the start does not match the local partial.
47
+ static long long RCTPushyParseContentRange(NSString *header, long long expectedStart) {
48
+ if (![header hasPrefix:@"bytes "]) {
49
+ return -1;
50
+ }
51
+ NSString *range = [[header substringFromIndex:6]
52
+ stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
53
+ NSRange slash = [range rangeOfString:@"/"];
54
+ NSRange dash = [range rangeOfString:@"-"];
55
+ if (slash.location == NSNotFound || dash.location == NSNotFound
56
+ || dash.location > slash.location) {
57
+ return -1;
58
+ }
59
+ long long start = [range substringToIndex:dash.location].longLongValue;
60
+ if (start != expectedStart) {
61
+ return -1;
62
+ }
63
+ NSString *totalPart = [range substringFromIndex:slash.location + 1];
64
+ if ([totalPart isEqualToString:@"*"]) {
65
+ return 0;
66
+ }
67
+ long long total = totalPart.longLongValue;
68
+ return total > 0 ? total : -1;
69
+ }
70
+
71
+ @interface RCTPushyDownloader()<NSURLSessionDataDelegate>
6
72
 
7
73
  @property (nonatomic, strong) NSURLSession *session;
8
74
  @property (copy) void (^progressHandler)(long long, long long);
9
75
  @property (copy) void (^completionHandler)(NSString*, NSError*);
10
76
  @property (copy) NSString *savePath;
77
+ @property (copy) NSString *urlString;
78
+ @property (nonatomic, assign) NSTimeInterval timeoutInterval;
79
+ @property (nonatomic, strong) NSFileHandle *fileHandle;
80
+ @property (nonatomic, strong) NSDictionary *resumeMeta;
81
+ @property (nonatomic, assign) long long resumeOffset; // requested Range start
82
+ @property (nonatomic, assign) long long baseOffset; // granted by the response
83
+ @property (nonatomic, assign) long long receivedBytes; // streamed this session
84
+ @property (nonatomic, assign) long long contentLength; // this response's body
85
+ @property (nonatomic, assign) long long expectedTotal; // whole file (0 unknown)
11
86
  @property (nonatomic, strong) NSError *fileError;
12
87
  @property (nonatomic, assign) BOOL finished;
88
+ @property (nonatomic, assign) BOOL retriedFromZero;
89
+ // The server encoded the body itself (Content-Encoding other than identity):
90
+ // NSURLSession delivers decoded bytes, so length accounting against the
91
+ // encoded Content-Length is meaningless and resume offsets cannot be trusted.
92
+ @property (nonatomic, assign) BOOL encodedBody;
13
93
  @property (nonatomic, assign) int lastReportedPercentage;
14
94
  @property (nonatomic, assign) long long lastReportedBytes;
15
95
  @end
@@ -28,13 +108,15 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
28
108
  downloader.progressHandler = progressHandler;
29
109
  downloader.completionHandler = completionHandler;
30
110
  downloader.savePath = savePath;
111
+ downloader.urlString = downloadPath;
112
+ downloader.timeoutInterval = timeoutInterval;
31
113
 
32
- [downloader startDownload:downloadPath timeoutInterval:timeoutInterval];
114
+ [downloader startTransfer];
33
115
  }
34
116
 
35
- - (void)startDownload:(NSString *)path timeoutInterval:(NSTimeInterval)timeoutInterval
117
+ - (void)startTransfer
36
118
  {
37
- NSURL *url = [NSURL URLWithString:path];
119
+ NSURL *url = [NSURL URLWithString:self.urlString];
38
120
  if (url == nil) {
39
121
  [self completeWithError:[NSError errorWithDomain:RCTPushyDownloaderErrorDomain
40
122
  code:-1
@@ -44,6 +126,47 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
44
126
  return;
45
127
  }
46
128
 
129
+ NSFileManager *fileManager = [NSFileManager defaultManager];
130
+ self.resumeMeta = RCTPushyReadResumeMeta(self.savePath, self.urlString);
131
+ self.resumeOffset = 0;
132
+ if (self.resumeMeta != nil) {
133
+ long long size = RCTPushyFileSize(self.savePath);
134
+ long long knownTotal = [self.resumeMeta[@"total"] longLongValue];
135
+ if (size > 0 && knownTotal > 0 && size == knownTotal) {
136
+ // Fully received in a previous attempt (the process died between
137
+ // download end and unzip): nothing left to transfer.
138
+ if (self.progressHandler) {
139
+ self.progressHandler(knownTotal, knownTotal);
140
+ }
141
+ [self completeWithError:nil];
142
+ return;
143
+ }
144
+ if (size > 0 && (knownTotal <= 0 || size < knownTotal)) {
145
+ self.resumeOffset = size;
146
+ }
147
+ }
148
+ if (self.resumeOffset == 0) {
149
+ [fileManager removeItemAtPath:self.savePath error:nil];
150
+ RCTPushyDeleteResumeSidecar(self.savePath);
151
+ }
152
+
153
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
154
+ if (self.resumeOffset > 0) {
155
+ // Only resume requests pin the encoding: Range offsets must address
156
+ // the same bytes that are on disk. Fresh downloads keep the system's
157
+ // transparent gzip handling, matching the pre-resume behaviour for
158
+ // servers that compress regardless.
159
+ [request setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"];
160
+ [request setValue:[NSString stringWithFormat:@"bytes=%lld-", self.resumeOffset]
161
+ forHTTPHeaderField:@"Range"];
162
+ NSString *validator = self.resumeMeta[@"etag"] ?: self.resumeMeta[@"lastModified"];
163
+ if (validator != nil) {
164
+ // With a validator the server falls back to a full 200 when the
165
+ // file changed instead of appending mismatched bytes.
166
+ [request setValue:validator forHTTPHeaderField:@"If-Range"];
167
+ }
168
+ }
169
+
47
170
  NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
48
171
  // Avoid hanging forever on a stalled connection (default resource timeout
49
172
  // is 7 days). The 30s idle timeout matches Android's readTimeout and is
@@ -51,15 +174,39 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
51
174
  // Android's 10min callTimeout — 300s made a 30MB full package on a slow
52
175
  // (<100KB/s) network fail on iOS while succeeding on Android.
53
176
  sessionConfig.timeoutIntervalForRequest = 30;
54
- sessionConfig.timeoutIntervalForResource = MAX(1, timeoutInterval);
177
+ sessionConfig.timeoutIntervalForResource = MAX(1, self.timeoutInterval);
55
178
  self.session = [NSURLSession sessionWithConfiguration:sessionConfig
56
179
  delegate:self
57
180
  delegateQueue:nil];
58
181
 
59
- NSURLSessionDownloadTask *task = [self.session downloadTaskWithURL:url];
182
+ NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request];
60
183
  [task resume];
61
184
  }
62
185
 
186
+ - (void)writeResumeMetaWithResponse:(NSHTTPURLResponse *)response total:(long long)total
187
+ {
188
+ NSMutableDictionary *meta = [NSMutableDictionary dictionary];
189
+ meta[@"url"] = self.urlString;
190
+ NSString *etag = response.allHeaderFields[@"ETag"] ?: self.resumeMeta[@"etag"];
191
+ NSString *lastModified =
192
+ response.allHeaderFields[@"Last-Modified"] ?: self.resumeMeta[@"lastModified"];
193
+ if (etag != nil) {
194
+ meta[@"etag"] = etag;
195
+ }
196
+ if (lastModified != nil) {
197
+ meta[@"lastModified"] = lastModified;
198
+ }
199
+ if (total > 0) {
200
+ meta[@"total"] = @(total);
201
+ }
202
+ NSData *data = [NSJSONSerialization dataWithJSONObject:meta options:0 error:nil];
203
+ if (data != nil) {
204
+ // Non-fatal on failure: without a sidecar the next attempt starts
205
+ // from zero.
206
+ [data writeToFile:RCTPushyResumeSidecarPath(self.savePath) atomically:YES];
207
+ }
208
+ }
209
+
63
210
  - (void)completeWithError:(NSError *)error
64
211
  {
65
212
  if (self.finished) {
@@ -67,6 +214,14 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
67
214
  }
68
215
  self.finished = YES;
69
216
 
217
+ if (self.fileHandle != nil) {
218
+ @try {
219
+ [self.fileHandle closeFile];
220
+ } @catch (NSException *exception) {
221
+ }
222
+ self.fileHandle = nil;
223
+ }
224
+
70
225
  void (^completionHandler)(NSString *, NSError *) = self.completionHandler;
71
226
  self.progressHandler = nil;
72
227
  self.completionHandler = nil;
@@ -80,97 +235,229 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
80
235
  }
81
236
  }
82
237
 
238
+ - (void)failWithDescription:(NSString *)description code:(NSInteger)code
239
+ {
240
+ self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain
241
+ code:code
242
+ userInfo:@{NSLocalizedDescriptionKey: description}];
243
+ }
244
+
245
+ // Drops the untrusted partial and reissues the whole request from zero, at
246
+ // most once per download (the stale-partial 416 case). Delegate callbacks
247
+ // from the superseded session are ignored via the session-identity guards.
248
+ - (void)restartFromZero
249
+ {
250
+ self.retriedFromZero = YES;
251
+ self.resumeMeta = nil;
252
+ self.resumeOffset = 0;
253
+ self.baseOffset = 0;
254
+ self.receivedBytes = 0;
255
+ self.contentLength = 0;
256
+ self.expectedTotal = 0;
257
+ self.encodedBody = NO;
258
+ self.lastReportedPercentage = 0;
259
+ self.lastReportedBytes = 0;
260
+ if (self.fileHandle != nil) {
261
+ @try {
262
+ [self.fileHandle closeFile];
263
+ } @catch (NSException *exception) {
264
+ }
265
+ self.fileHandle = nil;
266
+ }
267
+ [[NSFileManager defaultManager] removeItemAtPath:self.savePath error:nil];
268
+ RCTPushyDeleteResumeSidecar(self.savePath);
269
+ [self.session finishTasksAndInvalidate];
270
+ self.session = nil;
271
+ [self startTransfer];
272
+ }
273
+
83
274
  #pragma mark - session delegate
84
275
 
85
- - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
86
- didWriteData:(int64_t)bytesWritten
87
- totalBytesWritten:(int64_t)totalBytesWritten
88
- totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
276
+ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
277
+ didReceiveResponse:(NSURLResponse *)response
278
+ completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
89
279
  {
90
- if (!self.progressHandler) {
280
+ if (session != self.session) {
281
+ // A superseded session (restartFromZero) still delivering events.
282
+ completionHandler(NSURLSessionResponseCancel);
91
283
  return;
92
284
  }
93
- // Normalize an unknown total (NSURLSessionTransferSizeUnknown == -1) to 0 so
94
- // the JS side does not compute a negative/NaN percentage.
95
- long long total = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : 0;
96
- if (totalBytesWritten > total) {
97
- // Encoded responses (gzip): the expected total counts compressed bytes
98
- // while written counts decompressed ones, so the percentage would run
99
- // past 100%. Treat the total as unknown instead.
100
- total = 0;
285
+ NSHTTPURLResponse *httpResponse =
286
+ [response isKindOfClass:[NSHTTPURLResponse class]] ? (NSHTTPURLResponse *)response : nil;
287
+ NSInteger statusCode = httpResponse.statusCode;
288
+
289
+ if (statusCode == 416) {
290
+ long long knownTotal = [self.resumeMeta[@"total"] longLongValue];
291
+ completionHandler(NSURLSessionResponseCancel);
292
+ if (knownTotal > 0 && RCTPushyFileSize(self.savePath) == knownTotal) {
293
+ // The partial is actually the complete file.
294
+ [self completeWithError:nil];
295
+ } else if (!self.retriedFromZero) {
296
+ [self restartFromZero];
297
+ } else {
298
+ [self failWithDescription:@"server rejected the download range" code:statusCode];
299
+ [self completeWithError:self.fileError];
300
+ }
301
+ return;
101
302
  }
102
- if (total > 0) {
103
- int percentage = (int)((totalBytesWritten * 100.0 / total) + 0.5);
104
- if (percentage <= self.lastReportedPercentage) {
105
- return;
303
+ if (httpResponse != nil && (statusCode < 200 || statusCode >= 300)) {
304
+ [self failWithDescription:[NSString stringWithFormat:@"unexpected http status code %ld",
305
+ (long)statusCode]
306
+ code:statusCode];
307
+ completionHandler(NSURLSessionResponseCancel);
308
+ [self completeWithError:self.fileError];
309
+ return;
310
+ }
311
+
312
+ NSString *contentEncoding =
313
+ [httpResponse.allHeaderFields[@"Content-Encoding"] lowercaseString];
314
+ self.encodedBody =
315
+ contentEncoding.length > 0 && ![contentEncoding isEqualToString:@"identity"];
316
+
317
+ BOOL append = statusCode == 206 && self.resumeOffset > 0;
318
+ if (append && (self.encodedBody
319
+ || RCTPushyParseContentRange(httpResponse.allHeaderFields[@"Content-Range"],
320
+ self.resumeOffset) < 0)) {
321
+ // Encoded range bytes or a malformed/mismatched Content-Range: the
322
+ // appended bytes could not be trusted. Treat like a stale partial —
323
+ // one clean retry from zero — instead of failing terminally, which
324
+ // would keep the partial and hit the same wall on every attempt.
325
+ completionHandler(NSURLSessionResponseCancel);
326
+ if (!self.retriedFromZero) {
327
+ [self restartFromZero];
328
+ } else {
329
+ [self failWithDescription:@"untrusted resume response" code:-1];
330
+ [self completeWithError:self.fileError];
106
331
  }
107
- self.lastReportedPercentage = percentage;
332
+ return;
333
+ }
334
+ self.baseOffset = append ? self.resumeOffset : 0;
335
+ self.contentLength = response.expectedContentLength;
336
+ if (append) {
337
+ self.expectedTotal = RCTPushyParseContentRange(
338
+ httpResponse.allHeaderFields[@"Content-Range"], self.resumeOffset);
339
+ } else if (self.encodedBody) {
340
+ // Decoded bytes are being written; the encoded lengths say nothing.
341
+ self.expectedTotal = 0;
108
342
  } else {
109
- // Total unknown: throttle by bytes to avoid flooding the bridge.
110
- if (totalBytesWritten - self.lastReportedBytes < 256 * 1024) {
343
+ self.expectedTotal = self.contentLength > 0 ? self.contentLength : 0;
344
+ }
345
+
346
+ NSFileManager *fileManager = [NSFileManager defaultManager];
347
+ if (!append) {
348
+ // The server ignored the range (or none was sent): start over.
349
+ [fileManager removeItemAtPath:self.savePath error:nil];
350
+ [fileManager createFileAtPath:self.savePath contents:nil attributes:nil];
351
+ }
352
+ self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.savePath];
353
+ if (self.fileHandle == nil) {
354
+ [self failWithDescription:@"cannot open download file for writing" code:-1];
355
+ completionHandler(NSURLSessionResponseCancel);
356
+ [self completeWithError:self.fileError];
357
+ return;
358
+ }
359
+ if (append) {
360
+ @try {
361
+ [self.fileHandle seekToEndOfFile];
362
+ } @catch (NSException *exception) {
363
+ [self failWithDescription:@"cannot seek download file" code:-1];
364
+ completionHandler(NSURLSessionResponseCancel);
365
+ [self completeWithError:self.fileError];
111
366
  return;
112
367
  }
113
- self.lastReportedBytes = totalBytesWritten;
114
368
  }
115
- self.progressHandler(totalBytesWritten, total);
369
+ if (self.encodedBody) {
370
+ // No resume across an encoded transfer: on-disk bytes are decoded,
371
+ // Range offsets would address the encoded representation.
372
+ RCTPushyDeleteResumeSidecar(self.savePath);
373
+ } else if (httpResponse != nil) {
374
+ // Persist before streaming so a mid-stream crash can resume.
375
+ [self writeResumeMetaWithResponse:httpResponse total:self.expectedTotal];
376
+ }
377
+ completionHandler(NSURLSessionResponseAllow);
116
378
  }
117
379
 
118
- - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
119
- didFinishDownloadingToURL:(NSURL *)location
380
+ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
381
+ didReceiveData:(NSData *)data
120
382
  {
121
- // A completed transfer does not imply success: 404/500 pages, CDN error
122
- // bodies and captive-portal HTML all arrive here. Reject non-2xx responses
123
- // before touching savePath so an existing valid package is not destroyed.
124
- NSURLResponse *response = downloadTask.response;
125
- if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
126
- NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
127
- NSInteger statusCode = httpResponse.statusCode;
128
- if (statusCode < 200 || statusCode >= 300) {
129
- self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain
130
- code:statusCode
131
- userInfo:@{
132
- NSLocalizedDescriptionKey: [NSString stringWithFormat:@"unexpected http status code %ld", (long)statusCode],
133
- }];
383
+ if (session != self.session) {
384
+ return;
385
+ }
386
+ if (self.fileHandle == nil || self.fileError != nil) {
387
+ return;
388
+ }
389
+ @try {
390
+ [self.fileHandle writeData:data];
391
+ } @catch (NSException *exception) {
392
+ [self failWithDescription:[NSString stringWithFormat:@"write failed: %@",
393
+ exception.reason]
394
+ code:-1];
395
+ [dataTask cancel];
396
+ return;
397
+ }
398
+ self.receivedBytes += data.length;
399
+
400
+ if (!self.progressHandler) {
401
+ return;
402
+ }
403
+ long long overall = self.baseOffset + self.receivedBytes;
404
+ long long total = self.expectedTotal;
405
+ if (overall > total) {
406
+ // Should not happen with identity encoding; treat as unknown so the
407
+ // JS side never sees percentages past 100.
408
+ total = 0;
409
+ }
410
+ if (total > 0) {
411
+ int percentage = (int)((overall * 100.0 / total) + 0.5);
412
+ if (percentage <= self.lastReportedPercentage) {
134
413
  return;
135
414
  }
136
-
137
- // Reject truncated transfers like Android/Harmony do. Skip the check
138
- // for encoded responses (e.g. gzip): NSURLSession decompresses them
139
- // transparently, so the on-disk size legitimately differs from the
140
- // Content-Length of the encoded body.
141
- NSString *contentEncoding = [httpResponse.allHeaderFields[@"Content-Encoding"] lowercaseString];
142
- BOOL isEncodedBody = contentEncoding.length > 0 && ![contentEncoding isEqualToString:@"identity"];
143
- long long expectedLength = httpResponse.expectedContentLength;
144
- if (!isEncodedBody && expectedLength > 0) {
145
- NSNumber *fileSize = [[NSFileManager defaultManager] attributesOfItemAtPath:location.path
146
- error:nil][NSFileSize];
147
- if (fileSize != nil && fileSize.longLongValue != expectedLength) {
148
- self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain
149
- code:-1
150
- userInfo:@{
151
- NSLocalizedDescriptionKey: [NSString stringWithFormat:@"download incomplete: expected %lld bytes, got %lld", expectedLength, fileSize.longLongValue],
152
- }];
153
- return;
154
- }
415
+ self.lastReportedPercentage = percentage;
416
+ } else {
417
+ // Total unknown: throttle by bytes to avoid flooding the bridge.
418
+ if (overall - self.lastReportedBytes < 256 * 1024) {
419
+ return;
155
420
  }
421
+ self.lastReportedBytes = overall;
156
422
  }
157
-
158
- NSFileManager *fileManager = [NSFileManager defaultManager];
159
- [fileManager removeItemAtPath:self.savePath error:nil];
160
-
161
- NSError *fileError = nil;
162
- [fileManager moveItemAtURL:location
163
- toURL:[NSURL fileURLWithPath:self.savePath]
164
- error:&fileError];
165
- if (fileError != nil) {
166
- self.fileError = fileError;
167
- }
423
+ self.progressHandler(overall, total);
168
424
  }
169
425
 
170
426
  - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
171
427
  didCompleteWithError:(NSError *)error
172
428
  {
173
- [self completeWithError:error ?: self.fileError];
429
+ if (session != self.session) {
430
+ // A superseded session (restartFromZero) settling its cancelled
431
+ // task; only the current session may decide this download's fate.
432
+ return;
433
+ }
434
+ if (self.finished) {
435
+ return;
436
+ }
437
+ // The locally recorded failure (e.g. a write error that cancelled the
438
+ // task) is the actual cause; the session error would just say
439
+ // "cancelled".
440
+ NSError *finalError = self.fileError ?: error;
441
+ if (finalError == nil && !self.encodedBody) {
442
+ // Reject truncated transfers like Android/Harmony do. Skipped for
443
+ // encoded bodies: the on-disk size is decoded bytes while the
444
+ // expected lengths count encoded ones.
445
+ if (self.contentLength >= 0 && self.receivedBytes != self.contentLength) {
446
+ [self failWithDescription:[NSString stringWithFormat:
447
+ @"download incomplete: expected %lld bytes, got %lld",
448
+ self.contentLength, self.receivedBytes] code:-1];
449
+ finalError = self.fileError;
450
+ } else if (self.expectedTotal > 0
451
+ && RCTPushyFileSize(self.savePath) != self.expectedTotal) {
452
+ [self failWithDescription:[NSString stringWithFormat:
453
+ @"download incomplete: expected %lld total bytes, got %lld",
454
+ self.expectedTotal, RCTPushyFileSize(self.savePath)] code:-1];
455
+ finalError = self.fileError;
456
+ }
457
+ }
458
+ // On failure the partial + sidecar stay on disk — that is the resume
459
+ // state a later launch (or crash-rescue window) picks up.
460
+ [self completeWithError:finalError];
174
461
  }
175
462
 
176
463
  @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.51.1",
3
+ "version": "10.52.1",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
package/src/client.ts CHANGED
@@ -838,6 +838,14 @@ export class Pushy {
838
838
  }
839
839
  sharedState.marked = true;
840
840
  this.report({ type: 'markSuccess' });
841
+ // 救砖回执:这个版本是被救援通道送进来的,且活过了健康确认——遥测里
842
+ // 一条 rescue 回执就是"这台设备被捞回来了"的证据。
843
+ if (currentVersionInfo.forceBootRescue) {
844
+ this.report({ type: 'forceBootRescue' });
845
+ }
846
+ if (currentVersionInfo.crashRescue) {
847
+ this.report({ type: 'crashRescue' });
848
+ }
841
849
  };
842
850
  switchVersion = async (hash: string) => {
843
851
  if (!this.assertDebug('switchVersion()')) {
package/src/type.ts CHANGED
@@ -85,6 +85,12 @@ export type EventType =
85
85
  | 'errorUpdate'
86
86
  | 'markSuccess'
87
87
  | 'errorMarkSuccess'
88
+ // 救砖回执:被 forceBoot / 崩溃时刻救援送进来的版本活到了 markSuccess。
89
+ // 原生在下载提交时往 hashInfo 写对应标记(§10.7 / §11.3),JS 在
90
+ // markSuccess 后各补报一条;老服务端对未知 type 400,report 本就是
91
+ // best-effort 静默丢弃,无害。
92
+ | 'forceBootRescue'
93
+ | 'crashRescue'
88
94
  | 'reset'
89
95
  | 'errorReset'
90
96
  // Server reported bundleStatus: unknownBundle — the installed binary is not