react-native-update 10.51.1 → 10.52.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.
- package/NATIVE_CHECKUPDATE_DESIGN.md +108 -0
- package/NATIVE_CHECK_FOLLOWUPS.md +46 -0
- package/README-CN.md +2 -0
- package/README.md +2 -0
- package/android/src/main/java/cn/reactnative/modules/update/CrashRescue.java +115 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +259 -26
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +209 -19
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +6 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +381 -72
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +32 -2
- package/harmony/pushy/src/main/ets/UpdateContext.ts +5 -0
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +308 -25
- package/ios/RCTPushy/RCTPushyDownloader.mm +361 -74
- package/package.json +1 -1
- package/src/client.ts +8 -0
- package/src/type.ts +6 -0
|
@@ -100,16 +100,117 @@ const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js';
|
|
|
100
100
|
const FILE_COPY_BUFFER_SIZE = 64 * 1024;
|
|
101
101
|
const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
102
102
|
|
|
103
|
+
// 断点续传 sidecar(NATIVE_CHECKUPDATE_DESIGN §11.4):记录 partial 属于
|
|
104
|
+
// 哪个 url、验证器与总长。归档与 sidecar 同生共死;有归档无 sidecar(或
|
|
105
|
+
// url 不符)一律视为不可信,删掉重来。
|
|
106
|
+
interface ResumeMeta {
|
|
107
|
+
url: string;
|
|
108
|
+
etag?: string;
|
|
109
|
+
lastModified?: string;
|
|
110
|
+
total?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// "bytes <start>-<end>/<total>"。返回总长(“*”记 0);缺失/畸形/起点与
|
|
114
|
+
// 本地 partial 不符返回 -1——那样追加的字节不可信。
|
|
115
|
+
export function parseContentRangeTotal(
|
|
116
|
+
header: string,
|
|
117
|
+
expectedStart: number,
|
|
118
|
+
): number {
|
|
119
|
+
if (!header.startsWith('bytes ')) {
|
|
120
|
+
return -1;
|
|
121
|
+
}
|
|
122
|
+
const range = header.substring('bytes '.length).trim();
|
|
123
|
+
const slash = range.indexOf('/');
|
|
124
|
+
const dash = range.indexOf('-');
|
|
125
|
+
if (slash < 0 || dash < 0 || dash > slash) {
|
|
126
|
+
return -1;
|
|
127
|
+
}
|
|
128
|
+
const start = parseInt(range.substring(0, dash).trim(), 10);
|
|
129
|
+
if (Number.isNaN(start) || start !== expectedStart) {
|
|
130
|
+
return -1;
|
|
131
|
+
}
|
|
132
|
+
const totalPart = range.substring(slash + 1).trim();
|
|
133
|
+
if (totalPart === '*') {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const total = parseInt(totalPart, 10);
|
|
137
|
+
return Number.isNaN(total) || total <= 0 ? -1 : total;
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
export class DownloadTask {
|
|
104
141
|
private context: common.Context;
|
|
105
142
|
private hash = '';
|
|
106
143
|
private eventHub: EventHub;
|
|
144
|
+
// 一经 downloadFile 正常返回即置位:之后的失败是 patch 应用失败,归档
|
|
145
|
+
// 已被判定有毒,清理时必须连 sidecar 一起删,绝不能续传进同一个失败。
|
|
146
|
+
private downloadPhaseCompleted = false;
|
|
107
147
|
|
|
108
148
|
constructor(context: common.Context) {
|
|
109
149
|
this.context = context;
|
|
110
150
|
this.eventHub = EventHub.getInstance();
|
|
111
151
|
}
|
|
112
152
|
|
|
153
|
+
private resumeSidecarPath(targetFile: string): string {
|
|
154
|
+
return `${targetFile}.resume`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async readResumeMeta(
|
|
158
|
+
targetFile: string,
|
|
159
|
+
url: string,
|
|
160
|
+
): Promise<ResumeMeta | null> {
|
|
161
|
+
const sidecar = this.resumeSidecarPath(targetFile);
|
|
162
|
+
if (!fileIo.accessSync(sidecar)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const meta = this.parseJsonEntry(
|
|
167
|
+
await this.readFileContent(sidecar),
|
|
168
|
+
) as ResumeMeta;
|
|
169
|
+
if (meta && meta.url === url) {
|
|
170
|
+
return meta;
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// 坏 sidecar 只意味着“无法续传”。
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async writeResumeMeta(
|
|
179
|
+
targetFile: string,
|
|
180
|
+
meta: ResumeMeta,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
const encoded = new util.TextEncoder().encodeInto(JSON.stringify(meta));
|
|
184
|
+
await this.writeFileContent(this.resumeSidecarPath(targetFile), encoded);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// 非致命:没有 sidecar,下次从零开始。
|
|
187
|
+
console.error('Failed to persist resume sidecar:', e);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async deleteResumeSidecar(targetFile: string): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const sidecar = this.resumeSidecarPath(targetFile);
|
|
194
|
+
if (fileIo.accessSync(sidecar)) {
|
|
195
|
+
await fileIo.unlink(sidecar);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error('Failed to delete resume sidecar:', e);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 归档连同 sidecar 一起删除(被消费或被判定有毒)。
|
|
203
|
+
private async deleteArchiveAndSidecar(targetFile: string): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
if (fileIo.accessSync(targetFile)) {
|
|
206
|
+
await fileIo.unlink(targetFile);
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error('Failed to delete archive:', e);
|
|
210
|
+
}
|
|
211
|
+
await this.deleteResumeSidecar(targetFile);
|
|
212
|
+
}
|
|
213
|
+
|
|
113
214
|
private async removeDirectory(path: string): Promise<void> {
|
|
114
215
|
try {
|
|
115
216
|
const res = fileIo.accessSync(path);
|
|
@@ -338,15 +439,45 @@ export class DownloadTask {
|
|
|
338
439
|
}
|
|
339
440
|
|
|
340
441
|
private async downloadFile(params: DownloadTaskParams): Promise<void> {
|
|
341
|
-
const httpRequest = http.createHttp();
|
|
342
442
|
this.hash = params.hash;
|
|
443
|
+
// 跨启动断点续传(§11.4):砖机每次启动只有几百毫秒,每个已到手的字节
|
|
444
|
+
// 都必须在进程死亡后仍然算数、单调累积。
|
|
445
|
+
const outcome = await this.transferArchive(params, true);
|
|
446
|
+
if (outcome === 'staleRange') {
|
|
447
|
+
// 416 且本地 partial 与服务端不再匹配:删掉重来,仅此一次。
|
|
448
|
+
await this.deleteArchiveAndSidecar(params.targetFile);
|
|
449
|
+
const retry = await this.transferArchive(params, false);
|
|
450
|
+
if (retry !== 'done') {
|
|
451
|
+
throw Error(`Server rejected the download range for ${params.url}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
this.downloadPhaseCompleted = true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async transferArchive(
|
|
458
|
+
params: DownloadTaskParams,
|
|
459
|
+
allowResume: boolean,
|
|
460
|
+
): Promise<'done' | 'staleRange'> {
|
|
461
|
+
const httpRequest = http.createHttp();
|
|
343
462
|
let writer: fileIo.File | null = null;
|
|
344
|
-
let contentLength = 0;
|
|
463
|
+
let contentLength = 0; // 本次响应体长度(206 时是剩余部分)
|
|
464
|
+
let totalAll = 0; // 整个文件的总长(0 = 未知)
|
|
465
|
+
let baseOffset = 0; // 响应许可的续传起点
|
|
345
466
|
let received = 0;
|
|
346
467
|
let writeError: Error | null = null;
|
|
347
468
|
let writeQueue = Promise.resolve();
|
|
348
469
|
let lastReportedPercentage = -1;
|
|
349
470
|
let lastReportedBytes = 0;
|
|
471
|
+
let etagHeader = '';
|
|
472
|
+
let lastModifiedHeader = '';
|
|
473
|
+
let contentRangeHeader = '';
|
|
474
|
+
let contentEncodingHeader = '';
|
|
475
|
+
// headersReceive 与 requestInStream promise 的先后顺序平台不保证;
|
|
476
|
+
// 依赖 Content-Range 的续传路径必须显式等到响应头到达。
|
|
477
|
+
let headersResolve: (() => void) | null = null;
|
|
478
|
+
const headersPromise = new Promise<void>(resolve => {
|
|
479
|
+
headersResolve = resolve;
|
|
480
|
+
});
|
|
350
481
|
const deadlineUptimeMs = params.deadlineUptimeMs > 0
|
|
351
482
|
? params.deadlineUptimeMs
|
|
352
483
|
: monotonicNowMs() + DOWNLOAD_CALL_TIMEOUT_MS;
|
|
@@ -354,22 +485,63 @@ export class DownloadTask {
|
|
|
354
485
|
throw Error('Download deadline expired before start');
|
|
355
486
|
}
|
|
356
487
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
488
|
+
// 续传状态盘点:有匹配的 sidecar 且 partial 未收齐才发 Range。
|
|
489
|
+
let resumeMeta: ResumeMeta | null = allowResume
|
|
490
|
+
? await this.readResumeMeta(params.targetFile, params.url)
|
|
491
|
+
: null;
|
|
492
|
+
let resumeOffset = 0;
|
|
493
|
+
if (resumeMeta && fileIo.accessSync(params.targetFile)) {
|
|
494
|
+
const stat = await fileIo.stat(params.targetFile);
|
|
495
|
+
const knownTotal = resumeMeta.total ?? 0;
|
|
496
|
+
if (stat.size > 0 && knownTotal > 0 && stat.size === knownTotal) {
|
|
497
|
+
// 上次尝试已收齐(进程死在下载结束与解压之间):无需再传。
|
|
498
|
+
httpRequest.destroy();
|
|
499
|
+
this.onProgressUpdate(knownTotal, knownTotal);
|
|
500
|
+
return 'done';
|
|
501
|
+
}
|
|
502
|
+
if (stat.size > 0 && (knownTotal <= 0 || stat.size < knownTotal)) {
|
|
503
|
+
resumeOffset = stat.size;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (resumeOffset === 0) {
|
|
507
|
+
if (fileIo.accessSync(params.targetFile)) {
|
|
508
|
+
await fileIo.unlink(params.targetFile);
|
|
509
|
+
} else {
|
|
510
|
+
await this.ensureParentDirectory(params.targetFile);
|
|
511
|
+
}
|
|
512
|
+
await this.deleteResumeSidecar(params.targetFile);
|
|
513
|
+
resumeMeta = null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 响应体在状态码判定前一律只进内存缓冲:416/5xx 的错误体若被追加进
|
|
517
|
+
// partial,续传状态就永久污染了。requestInStream 的 promise 在响应头
|
|
518
|
+
// 到达时解决,缓冲窗口只有头与首批数据之间的间隙。
|
|
519
|
+
let pendingChunks: ArrayBuffer[] | null = [];
|
|
520
|
+
let discardBody = false;
|
|
521
|
+
let authorize: (() => void) | null = null;
|
|
522
|
+
const authorization = new Promise<void>(resolve => {
|
|
523
|
+
authorize = resolve;
|
|
524
|
+
});
|
|
525
|
+
|
|
360
526
|
const reportProgress = () => {
|
|
361
|
-
|
|
362
|
-
|
|
527
|
+
const overall = baseOffset + received;
|
|
528
|
+
const overallTotal = totalAll > 0
|
|
529
|
+
? totalAll
|
|
530
|
+
: contentLength > 0
|
|
531
|
+
? baseOffset + contentLength
|
|
532
|
+
: 0;
|
|
533
|
+
if (overallTotal > 0) {
|
|
534
|
+
const percentage = Math.round((overall * 100) / overallTotal);
|
|
363
535
|
if (percentage <= lastReportedPercentage) {
|
|
364
536
|
return;
|
|
365
537
|
}
|
|
366
538
|
lastReportedPercentage = percentage;
|
|
367
|
-
} else if (
|
|
539
|
+
} else if (overall - lastReportedBytes < 256 * 1024) {
|
|
368
540
|
return;
|
|
369
541
|
} else {
|
|
370
|
-
lastReportedBytes =
|
|
542
|
+
lastReportedBytes = overall;
|
|
371
543
|
}
|
|
372
|
-
this.onProgressUpdate(
|
|
544
|
+
this.onProgressUpdate(overall, overallTotal);
|
|
373
545
|
};
|
|
374
546
|
|
|
375
547
|
const closeWriter = async () => {
|
|
@@ -379,6 +551,20 @@ export class DownloadTask {
|
|
|
379
551
|
}
|
|
380
552
|
};
|
|
381
553
|
|
|
554
|
+
const enqueueWrite = (data: ArrayBuffer) => {
|
|
555
|
+
received += data.byteLength;
|
|
556
|
+
writeQueue = writeQueue.then(async () => {
|
|
557
|
+
if (!writer || writeError) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
await fileIo.write(writer.fd, data);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
writeError = error as Error;
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
};
|
|
567
|
+
|
|
382
568
|
// Watchdog: reject the download if no data is received for a while, so a
|
|
383
569
|
// stalled transfer after requestInStream resolves cannot hang the download
|
|
384
570
|
// Promise (and the JS caller) forever.
|
|
@@ -411,7 +597,10 @@ export class DownloadTask {
|
|
|
411
597
|
const dataEndPromise = new Promise<void>((resolve, reject) => {
|
|
412
598
|
httpRequest.on('dataEnd', () => {
|
|
413
599
|
clearWatchdog();
|
|
414
|
-
|
|
600
|
+
// dataEnd 可能先于状态码判定到达(小响应):flush 必须等鉴权,
|
|
601
|
+
// 否则缓冲中的字节还没进 writeQueue 就被当成“全部写完”。
|
|
602
|
+
authorization
|
|
603
|
+
.then(() => writeQueue)
|
|
415
604
|
.then(async () => {
|
|
416
605
|
if (writeError) {
|
|
417
606
|
throw writeError;
|
|
@@ -434,51 +623,44 @@ export class DownloadTask {
|
|
|
434
623
|
});
|
|
435
624
|
|
|
436
625
|
try {
|
|
437
|
-
let exists = fileIo.accessSync(params.targetFile);
|
|
438
|
-
if (exists) {
|
|
439
|
-
await fileIo.unlink(params.targetFile);
|
|
440
|
-
} else {
|
|
441
|
-
await this.ensureParentDirectory(params.targetFile);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
writer = await fileIo.open(
|
|
445
|
-
params.targetFile,
|
|
446
|
-
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE,
|
|
447
|
-
);
|
|
448
|
-
|
|
449
626
|
httpRequest.on('headersReceive', (header: Object) => {
|
|
450
627
|
if (!header) {
|
|
451
628
|
return;
|
|
452
629
|
}
|
|
453
630
|
const headers = header as Record<string, string>;
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
631
|
+
for (const key of Object.keys(headers)) {
|
|
632
|
+
const lower = key.toLowerCase();
|
|
633
|
+
if (lower === 'content-length') {
|
|
634
|
+
const length = parseInt(headers[key], 10);
|
|
635
|
+
if (!Number.isNaN(length)) {
|
|
636
|
+
contentLength = length;
|
|
637
|
+
}
|
|
638
|
+
} else if (lower === 'etag') {
|
|
639
|
+
etagHeader = headers[key];
|
|
640
|
+
} else if (lower === 'last-modified') {
|
|
641
|
+
lastModifiedHeader = headers[key];
|
|
642
|
+
} else if (lower === 'content-range') {
|
|
643
|
+
contentRangeHeader = headers[key];
|
|
644
|
+
} else if (lower === 'content-encoding') {
|
|
645
|
+
contentEncodingHeader = headers[key];
|
|
646
|
+
}
|
|
459
647
|
}
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
462
|
-
|
|
648
|
+
const signalHeaders = headersResolve as (() => void) | null;
|
|
649
|
+
if (signalHeaders) {
|
|
650
|
+
signalHeaders();
|
|
463
651
|
}
|
|
464
652
|
});
|
|
465
653
|
|
|
466
654
|
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
|
|
467
655
|
refreshWatchdog();
|
|
468
|
-
if (writeError) {
|
|
656
|
+
if (writeError || discardBody) {
|
|
469
657
|
return;
|
|
470
658
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
try {
|
|
477
|
-
await fileIo.write(writer.fd, data);
|
|
478
|
-
} catch (error) {
|
|
479
|
-
writeError = error as Error;
|
|
480
|
-
}
|
|
481
|
-
});
|
|
659
|
+
if (pendingChunks) {
|
|
660
|
+
pendingChunks.push(data);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
enqueueWrite(data);
|
|
482
664
|
reportProgress();
|
|
483
665
|
});
|
|
484
666
|
|
|
@@ -493,6 +675,22 @@ export class DownloadTask {
|
|
|
493
675
|
},
|
|
494
676
|
);
|
|
495
677
|
|
|
678
|
+
const requestHeader: Record<string, string> = {
|
|
679
|
+
'Content-Type': 'application/octet-stream',
|
|
680
|
+
};
|
|
681
|
+
if (resumeOffset > 0) {
|
|
682
|
+
// 只有续传请求钉死编码:Range 偏移必须与盘上字节一一对应。
|
|
683
|
+
// 全新下载保留平台默认的压缩处理,与引入续传前的行为一致。
|
|
684
|
+
requestHeader['Accept-Encoding'] = 'identity';
|
|
685
|
+
requestHeader.Range = `bytes=${resumeOffset}-`;
|
|
686
|
+
const validator = resumeMeta?.etag || resumeMeta?.lastModified;
|
|
687
|
+
if (validator) {
|
|
688
|
+
// 带验证器时文件已变更的服务端会退回完整 200,而不是把不匹配
|
|
689
|
+
// 的字节接上来。
|
|
690
|
+
requestHeader['If-Range'] = validator;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
496
694
|
const deadlinePromise = new Promise<never>((_, reject) => {
|
|
497
695
|
deadlineTimer = setTimeout(() => {
|
|
498
696
|
reject(Error('Download exceeded its whole-call deadline'));
|
|
@@ -503,29 +701,143 @@ export class DownloadTask {
|
|
|
503
701
|
method: http.RequestMethod.GET,
|
|
504
702
|
readTimeout: 60000,
|
|
505
703
|
connectTimeout: 60000,
|
|
506
|
-
header:
|
|
507
|
-
'Content-Type': 'application/octet-stream',
|
|
508
|
-
},
|
|
704
|
+
header: requestHeader,
|
|
509
705
|
}),
|
|
510
706
|
deadlinePromise,
|
|
511
707
|
]);
|
|
708
|
+
|
|
709
|
+
if (responseCode === 416) {
|
|
710
|
+
discardBody = true;
|
|
711
|
+
pendingChunks = null;
|
|
712
|
+
const knownTotal = resumeMeta?.total ?? 0;
|
|
713
|
+
if (knownTotal > 0 && fileIo.accessSync(params.targetFile)) {
|
|
714
|
+
const stat = await fileIo.stat(params.targetFile);
|
|
715
|
+
if (stat.size === knownTotal) {
|
|
716
|
+
// partial 实际上就是完整文件。
|
|
717
|
+
this.onProgressUpdate(knownTotal, knownTotal);
|
|
718
|
+
return 'done';
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return 'staleRange';
|
|
722
|
+
}
|
|
512
723
|
if (responseCode > 299) {
|
|
724
|
+
// 响应体从未落盘,partial + sidecar 原样保留——那就是续传状态。
|
|
725
|
+
discardBody = true;
|
|
726
|
+
pendingChunks = null;
|
|
513
727
|
throw Error(`Server error: ${responseCode}`);
|
|
514
728
|
}
|
|
515
729
|
|
|
730
|
+
let encodedBody = false;
|
|
731
|
+
if (resumeOffset > 0 && responseCode === 206) {
|
|
732
|
+
// Content-Range 只能从 headersReceive 拿到,而它与 promise 的先后
|
|
733
|
+
// 顺序不保证——等头到达(dataEnd 前必到,兜底 5s)再判定。
|
|
734
|
+
let headersTimer = 0;
|
|
735
|
+
await Promise.race([
|
|
736
|
+
headersPromise,
|
|
737
|
+
new Promise<void>(resolve => {
|
|
738
|
+
headersTimer = setTimeout(resolve, 5000);
|
|
739
|
+
}),
|
|
740
|
+
]);
|
|
741
|
+
clearTimeout(headersTimer);
|
|
742
|
+
encodedBody =
|
|
743
|
+
!!contentEncodingHeader &&
|
|
744
|
+
contentEncodingHeader.toLowerCase() !== 'identity';
|
|
745
|
+
const parsedTotal = encodedBody
|
|
746
|
+
? -1
|
|
747
|
+
: parseContentRangeTotal(contentRangeHeader, resumeOffset);
|
|
748
|
+
if (parsedTotal < 0) {
|
|
749
|
+
// 编码过的 range 字节、或 Content-Range 缺失/畸形/起点不符:
|
|
750
|
+
// 追加不可信。按“partial 已过期”处理(删掉、干净地重来一次),
|
|
751
|
+
// 而不是抛错——抛错会保留 partial,之后每次都撞同一堵墙。
|
|
752
|
+
discardBody = true;
|
|
753
|
+
pendingChunks = null;
|
|
754
|
+
return 'staleRange';
|
|
755
|
+
}
|
|
756
|
+
baseOffset = resumeOffset;
|
|
757
|
+
totalAll = parsedTotal;
|
|
758
|
+
writer = await fileIo.open(
|
|
759
|
+
params.targetFile,
|
|
760
|
+
fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.APPEND,
|
|
761
|
+
);
|
|
762
|
+
} else {
|
|
763
|
+
// 服务端忽略了 Range(或本就没发):从零开始。
|
|
764
|
+
if (fileIo.accessSync(params.targetFile)) {
|
|
765
|
+
await fileIo.unlink(params.targetFile);
|
|
766
|
+
}
|
|
767
|
+
baseOffset = 0;
|
|
768
|
+
totalAll = contentLength > 0 ? contentLength : 0;
|
|
769
|
+
writer = await fileIo.open(
|
|
770
|
+
params.targetFile,
|
|
771
|
+
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
// 先落 sidecar 再放行写入,流中崩溃才有得续。
|
|
775
|
+
const meta: ResumeMeta = { url: params.url };
|
|
776
|
+
const etag = etagHeader || resumeMeta?.etag;
|
|
777
|
+
const lastModified = lastModifiedHeader || resumeMeta?.lastModified;
|
|
778
|
+
if (etag) {
|
|
779
|
+
meta.etag = etag;
|
|
780
|
+
}
|
|
781
|
+
if (lastModified) {
|
|
782
|
+
meta.lastModified = lastModified;
|
|
783
|
+
}
|
|
784
|
+
if (totalAll > 0) {
|
|
785
|
+
meta.total = totalAll;
|
|
786
|
+
}
|
|
787
|
+
await this.writeResumeMeta(params.targetFile, meta);
|
|
788
|
+
|
|
789
|
+
// 鉴权通过:回放缓冲,之后的数据直写。
|
|
790
|
+
const buffered = pendingChunks ?? [];
|
|
791
|
+
pendingChunks = null;
|
|
792
|
+
for (const chunk of buffered) {
|
|
793
|
+
enqueueWrite(chunk);
|
|
794
|
+
}
|
|
795
|
+
if (buffered.length > 0) {
|
|
796
|
+
reportProgress();
|
|
797
|
+
}
|
|
798
|
+
const grant = authorize as (() => void) | null;
|
|
799
|
+
if (grant) {
|
|
800
|
+
grant();
|
|
801
|
+
}
|
|
802
|
+
|
|
516
803
|
// watchdog 到这里才首次启动:requestInStream 阶段已有 connect/readTimeout
|
|
517
804
|
// 覆盖;若提早启动,连接超过 60s 时 inactivityPromise 会在 race 尚无订阅者
|
|
518
805
|
// 时 reject(unhandled rejection),且 promise 一经 reject 无法复活——
|
|
519
806
|
// 即使随后数据正常流入,race 也必然以 "Download stalled" 失败。
|
|
520
807
|
refreshWatchdog();
|
|
521
808
|
await Promise.race([dataEndPromise, inactivityPromise, deadlinePromise]);
|
|
809
|
+
// dataEnd 之后响应头必然已到齐,此时才能可靠判定编码。
|
|
810
|
+
const finalEncoded =
|
|
811
|
+
!!contentEncodingHeader &&
|
|
812
|
+
contentEncodingHeader.toLowerCase() !== 'identity';
|
|
522
813
|
const stats = await fileIo.stat(params.targetFile);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
814
|
+
if (!finalEncoded) {
|
|
815
|
+
if (contentLength > 0 && received !== contentLength) {
|
|
816
|
+
throw Error(
|
|
817
|
+
`Download incomplete: expected ${contentLength} bytes but got ${received} bytes`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (totalAll > 0 && stats.size !== totalAll) {
|
|
821
|
+
throw Error(
|
|
822
|
+
`Download incomplete: expected ${totalAll} total bytes but got ${stats.size} bytes`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
// 用最终观测值刷新 sidecar:首次写入可能因头部竞态缺 total/验证器,
|
|
826
|
+
// 补齐后“下载完、解压前死”的窗口才能被 complete 检测覆盖。
|
|
827
|
+
const finalMeta: ResumeMeta = { url: params.url, total: stats.size };
|
|
828
|
+
if (etagHeader || resumeMeta?.etag) {
|
|
829
|
+
finalMeta.etag = etagHeader || resumeMeta?.etag;
|
|
830
|
+
}
|
|
831
|
+
if (lastModifiedHeader || resumeMeta?.lastModified) {
|
|
832
|
+
finalMeta.lastModified =
|
|
833
|
+
lastModifiedHeader || resumeMeta?.lastModified;
|
|
834
|
+
}
|
|
835
|
+
await this.writeResumeMeta(params.targetFile, finalMeta);
|
|
836
|
+
} else {
|
|
837
|
+
// 编码传输不可续传:盘上是解码后的字节,编码域的长度与偏移都失效。
|
|
838
|
+
await this.deleteResumeSidecar(params.targetFile);
|
|
528
839
|
}
|
|
840
|
+
return 'done';
|
|
529
841
|
} catch (error) {
|
|
530
842
|
console.error('Download failed:', error);
|
|
531
843
|
throw error;
|
|
@@ -534,6 +846,11 @@ export class DownloadTask {
|
|
|
534
846
|
if (deadlineTimer !== null) {
|
|
535
847
|
clearTimeout(deadlineTimer);
|
|
536
848
|
}
|
|
849
|
+
// 解除 dataEnd 对鉴权的等待(失败路径上可能从未放行)。
|
|
850
|
+
const release = authorize as (() => void) | null;
|
|
851
|
+
if (release) {
|
|
852
|
+
release();
|
|
853
|
+
}
|
|
537
854
|
try {
|
|
538
855
|
await closeWriter();
|
|
539
856
|
} catch (closeError) {
|
|
@@ -559,13 +876,7 @@ export class DownloadTask {
|
|
|
559
876
|
await this.downloadFile(params);
|
|
560
877
|
await this.recreateDirectory(params.unzipDirectory);
|
|
561
878
|
await zlib.decompressFile(params.targetFile, params.unzipDirectory);
|
|
562
|
-
|
|
563
|
-
if (fileIo.accessSync(params.targetFile)) {
|
|
564
|
-
await fileIo.unlink(params.targetFile);
|
|
565
|
-
}
|
|
566
|
-
} catch (e) {
|
|
567
|
-
console.error('Failed to delete temporary zip file after decompression:', e);
|
|
568
|
-
}
|
|
879
|
+
await this.deleteArchiveAndSidecar(params.targetFile);
|
|
569
880
|
}
|
|
570
881
|
|
|
571
882
|
private async doPatchFromApp(params: DownloadTaskParams): Promise<void> {
|
|
@@ -633,13 +944,7 @@ export class DownloadTask {
|
|
|
633
944
|
params.unzipDirectory,
|
|
634
945
|
crcByFrom,
|
|
635
946
|
);
|
|
636
|
-
|
|
637
|
-
if (fileIo.accessSync(params.targetFile)) {
|
|
638
|
-
await fileIo.unlink(params.targetFile);
|
|
639
|
-
}
|
|
640
|
-
} catch (e) {
|
|
641
|
-
console.error('Failed to delete temporary zip file after patching:', e);
|
|
642
|
-
}
|
|
947
|
+
await this.deleteArchiveAndSidecar(params.targetFile);
|
|
643
948
|
}
|
|
644
949
|
|
|
645
950
|
private async doPatchFromPpk(params: DownloadTaskParams): Promise<void> {
|
|
@@ -686,13 +991,7 @@ export class DownloadTask {
|
|
|
686
991
|
bundleHbcTransformMeta: manifestArrays.hbcTransformMeta,
|
|
687
992
|
});
|
|
688
993
|
console.info('Patch from PPK completed');
|
|
689
|
-
|
|
690
|
-
if (fileIo.accessSync(params.targetFile)) {
|
|
691
|
-
await fileIo.unlink(params.targetFile);
|
|
692
|
-
}
|
|
693
|
-
} catch (e) {
|
|
694
|
-
console.error('Failed to delete temporary patch file after patching:', e);
|
|
695
|
-
}
|
|
994
|
+
await this.deleteArchiveAndSidecar(params.targetFile);
|
|
696
995
|
}
|
|
697
996
|
|
|
698
997
|
private async copyFromResource(
|
|
@@ -840,6 +1139,9 @@ export class DownloadTask {
|
|
|
840
1139
|
break;
|
|
841
1140
|
case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
|
|
842
1141
|
await this.downloadFile(params);
|
|
1142
|
+
// 文件在完成时即是最终产物——残留的 sidecar 会让下次同 URL 的
|
|
1143
|
+
// 下载不发请求直接返回这份旧字节。
|
|
1144
|
+
await this.deleteResumeSidecar(params.targetFile);
|
|
843
1145
|
break;
|
|
844
1146
|
default:
|
|
845
1147
|
throw Error(`Unknown task type: ${params.type}`);
|
|
@@ -856,6 +1158,7 @@ export class DownloadTask {
|
|
|
856
1158
|
try {
|
|
857
1159
|
if (params.type === DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD) {
|
|
858
1160
|
await fileIo.unlink(params.targetFile);
|
|
1161
|
+
await this.deleteResumeSidecar(params.targetFile);
|
|
859
1162
|
} else if (
|
|
860
1163
|
!fileIo.accessSync(
|
|
861
1164
|
`${params.unzipDirectory}/${VERSION_COMPLETE_FILE_NAME}`,
|
|
@@ -865,6 +1168,12 @@ export class DownloadTask {
|
|
|
865
1168
|
// Never let a failed duplicate task remove an install already
|
|
866
1169
|
// handed off by an earlier task via marker + bundle.
|
|
867
1170
|
await this.removeDirectory(params.unzipDirectory);
|
|
1171
|
+
if (this.downloadPhaseCompleted) {
|
|
1172
|
+
// 收齐后才失败的是解压/patch 失败:归档有毒,续传只会撞上
|
|
1173
|
+
// 同一个失败。下载阶段的失败则保留 partial + sidecar——那
|
|
1174
|
+
// 就是续传状态。
|
|
1175
|
+
await this.deleteArchiveAndSidecar(params.targetFile);
|
|
1176
|
+
}
|
|
868
1177
|
}
|
|
869
1178
|
} catch (cleanupError: any) {
|
|
870
1179
|
console.error('Cleanup after error failed:', cleanupError.message);
|
|
@@ -17,6 +17,11 @@ const TAG = 'NativeCheck';
|
|
|
17
17
|
export const KEY_CONFIG = 'nativeConfig';
|
|
18
18
|
// 供 JS 侧复用的原始响应缓存(§10.3),同时记录请求与配置指纹以限定命中范围。
|
|
19
19
|
export const KEY_RESP_CACHE = 'nativeCheckResp';
|
|
20
|
+
// 轮次开始落盘、结束清除(§11.4)。下次启动读到残留 = 上个进程死于轮中
|
|
21
|
+
// (砖机的签名),该次启动跳过 5s 延迟立即续传。鸿蒙本版不做 crash-hold
|
|
22
|
+
// (errorManager 的同步回调驱动不了 ArkTS 异步网络 IO),续传 + 零延迟
|
|
23
|
+
// 标记是收敛砖机的全部机制。
|
|
24
|
+
export const KEY_ROUND_INCOMPLETE = 'nativeCheckIncomplete';
|
|
20
25
|
const REQUEST_TIMEOUT_MS = 10000;
|
|
21
26
|
const REQUEST_CALL_TIMEOUT_MS = 15000;
|
|
22
27
|
const MAX_CHECK_HTTP_ATTEMPTS = 8;
|
|
@@ -96,13 +101,15 @@ export function scheduleNativeCheck(
|
|
|
96
101
|
return;
|
|
97
102
|
}
|
|
98
103
|
scheduled = true;
|
|
99
|
-
// 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)
|
|
104
|
+
// 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)——
|
|
105
|
+
// 除非上个进程死于轮中(残留标记),那时每一秒启动时间都要用来续传。
|
|
106
|
+
const delayMs = context.getKv(KEY_ROUND_INCOMPLETE) ? 0 : START_DELAY_MS;
|
|
100
107
|
setTimeout(() => {
|
|
101
108
|
runOnce(context, launchRolledBackVersion).catch((e: Object) => {
|
|
102
109
|
// 救援路径自身绝不能把应用拖垮。
|
|
103
110
|
logger.error(TAG, `native check failed: ${e}`);
|
|
104
111
|
});
|
|
105
|
-
},
|
|
112
|
+
}, delayMs);
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
async function runOnce(
|
|
@@ -130,7 +137,30 @@ async function runOnce(
|
|
|
130
137
|
if (!appKey) {
|
|
131
138
|
return;
|
|
132
139
|
}
|
|
140
|
+
// 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。
|
|
141
|
+
context.setKv(KEY_ROUND_INCOMPLETE, '1');
|
|
142
|
+
try {
|
|
143
|
+
await runConfiguredRound(
|
|
144
|
+
context,
|
|
145
|
+
launchRolledBackVersion,
|
|
146
|
+
resetGeneration,
|
|
147
|
+
configJson,
|
|
148
|
+
config,
|
|
149
|
+
appKey,
|
|
150
|
+
);
|
|
151
|
+
} finally {
|
|
152
|
+
context.removeKv(KEY_ROUND_INCOMPLETE);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
133
155
|
|
|
156
|
+
async function runConfiguredRound(
|
|
157
|
+
context: UpdateContext,
|
|
158
|
+
launchRolledBackVersion: string,
|
|
159
|
+
resetGeneration: number,
|
|
160
|
+
configJson: string,
|
|
161
|
+
config: NativeConfig,
|
|
162
|
+
appKey: string,
|
|
163
|
+
): Promise<void> {
|
|
134
164
|
const currentVersion = context.getCurrentVersion();
|
|
135
165
|
// getConstants consumes the persisted rollback marker during startup; use
|
|
136
166
|
// the launch-path snapshot captured before that happens.
|
|
@@ -384,6 +384,11 @@ export class UpdateContext {
|
|
|
384
384
|
return this.readString(key);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
public removeKv(key: string): void {
|
|
388
|
+
this.preferences.deleteSync(key);
|
|
389
|
+
this.flushPreferences(`remove key ${key}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
387
392
|
public isFirstTime(): boolean {
|
|
388
393
|
return this.getStateSnapshot().firstTime;
|
|
389
394
|
}
|
package/harmony/pushy.har
CHANGED
|
Binary file
|