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.
@@ -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
- // Emit at most one progress event per whole-percent change (or per 256KB
358
- // when the total is unknown), and only from the dataReceive handler, so the
359
- // two HarmonyOS callbacks don't each fire an event per chunk.
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
- if (contentLength > 0) {
362
- const percentage = Math.round((received * 100) / contentLength);
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 (received - lastReportedBytes < 256 * 1024) {
539
+ } else if (overall - lastReportedBytes < 256 * 1024) {
368
540
  return;
369
541
  } else {
370
- lastReportedBytes = received;
542
+ lastReportedBytes = overall;
371
543
  }
372
- this.onProgressUpdate(received, contentLength);
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
- writeQueue
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 lengthKey = Object.keys(headers).find(
455
- key => key.toLowerCase() === 'content-length',
456
- );
457
- if (!lengthKey) {
458
- return;
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 length = parseInt(headers[lengthKey], 10);
461
- if (!Number.isNaN(length)) {
462
- contentLength = length;
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
- received += data.byteLength;
472
- writeQueue = writeQueue.then(async () => {
473
- if (!writer || writeError) {
474
- return;
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
- const fileSize = stats.size;
524
- if (contentLength > 0 && fileSize !== contentLength) {
525
- throw Error(
526
- `Download incomplete: expected ${contentLength} bytes but got ${stats.size} bytes`,
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
- try {
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
- try {
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
- try {
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
- }, START_DELAY_MS);
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