@appsonair.ir/react-native 1.0.2 → 1.0.3
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/lib/sync.d.ts +1 -0
- package/lib/sync.js +189 -67
- package/package.json +1 -1
package/lib/sync.d.ts
CHANGED
package/lib/sync.js
CHANGED
|
@@ -75,6 +75,7 @@ exports.defaultMessages = {
|
|
|
75
75
|
installing: 'در حال نصب به\u200cروزرسانی...',
|
|
76
76
|
restarting: 'در حال راه\u200cاندازی مجدد...',
|
|
77
77
|
error: 'خطا در به\u200cروزرسانی',
|
|
78
|
+
downloadInterrupted: 'دانلود قطع شد. لطفاً دوباره تلاش کنید',
|
|
78
79
|
upToDate: 'برنامه به\u200cروز است',
|
|
79
80
|
updateAvailable: 'نسخه جدیدی در دسترس است',
|
|
80
81
|
changelogTitle: 'تغییرات این نسخه',
|
|
@@ -413,6 +414,31 @@ async function ensureBundleDirectory() {
|
|
|
413
414
|
await ReactNativeBlobUtil.fs.mkdir(dir);
|
|
414
415
|
}
|
|
415
416
|
}
|
|
417
|
+
/** Keep only the active OTA package so leftover extracts don't fill disk / slow IO. */
|
|
418
|
+
async function cleanupOldUpdates(keepLabel) {
|
|
419
|
+
try {
|
|
420
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
421
|
+
const dir = getBundleDirectory();
|
|
422
|
+
const exists = await ReactNativeBlobUtil.fs.exists(dir);
|
|
423
|
+
if (!exists)
|
|
424
|
+
return;
|
|
425
|
+
const keep = new Set([
|
|
426
|
+
keepLabel,
|
|
427
|
+
`${keepLabel}.zip`,
|
|
428
|
+
`${keepLabel}.bundle`,
|
|
429
|
+
]);
|
|
430
|
+
const entries = await ReactNativeBlobUtil.fs.ls(dir);
|
|
431
|
+
await Promise.all(entries.map(async (entry) => {
|
|
432
|
+
if (keep.has(entry) || entry.endsWith('.apk')) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await removePathRecursive(`${dir}/${entry}`);
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Best-effort cleanup; a leftover folder must not fail install.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
416
442
|
async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, deviceAbi) {
|
|
417
443
|
if (expectedSize && expectedSize > 0) {
|
|
418
444
|
return expectedSize;
|
|
@@ -449,6 +475,80 @@ async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, dev
|
|
|
449
475
|
}
|
|
450
476
|
return expectedSize ?? 0;
|
|
451
477
|
}
|
|
478
|
+
const DOWNLOAD_MAX_ATTEMPTS = 3;
|
|
479
|
+
function getErrorMessage(error) {
|
|
480
|
+
if (error instanceof Error)
|
|
481
|
+
return error.message;
|
|
482
|
+
if (typeof error === 'string')
|
|
483
|
+
return error;
|
|
484
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
485
|
+
return String(error.message);
|
|
486
|
+
}
|
|
487
|
+
return String(error);
|
|
488
|
+
}
|
|
489
|
+
function isRetryableDownloadError(error) {
|
|
490
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
491
|
+
return (message.includes('download interrupted') ||
|
|
492
|
+
message.includes('network') ||
|
|
493
|
+
message.includes('timeout') ||
|
|
494
|
+
message.includes('timed out') ||
|
|
495
|
+
message.includes('connection') ||
|
|
496
|
+
message.includes('socket') ||
|
|
497
|
+
message.includes('econnreset') ||
|
|
498
|
+
message.includes('failed to connect') ||
|
|
499
|
+
message.includes('software caused connection abort'));
|
|
500
|
+
}
|
|
501
|
+
function toUserFacingDownloadError(error) {
|
|
502
|
+
if (isRetryableDownloadError(error)) {
|
|
503
|
+
return new Error(exports.defaultMessages.downloadInterrupted);
|
|
504
|
+
}
|
|
505
|
+
return error instanceof Error ? error : new Error(getErrorMessage(error));
|
|
506
|
+
}
|
|
507
|
+
async function cleanupPartialDownload(path) {
|
|
508
|
+
try {
|
|
509
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
510
|
+
const exists = await ReactNativeBlobUtil.fs.exists(path);
|
|
511
|
+
if (exists) {
|
|
512
|
+
await ReactNativeBlobUtil.fs.unlink(path);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Ignore cleanup errors.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* react-native-blob-util rejects with "Download interrupted." whenever
|
|
521
|
+
* bytesWritten !== Content-Length — including when Content-Length is wrong
|
|
522
|
+
* but the full file actually landed. Recover those cases via size/hash checks.
|
|
523
|
+
*/
|
|
524
|
+
async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label) {
|
|
525
|
+
try {
|
|
526
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
527
|
+
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
528
|
+
if (!exists)
|
|
529
|
+
return null;
|
|
530
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
531
|
+
const minSize = extension === 'apk' ? 1024 * 100 : 1024;
|
|
532
|
+
if (!stat || stat.size < minSize)
|
|
533
|
+
return null;
|
|
534
|
+
const sizeLooksComplete = totalBytes <= 0 || Math.abs(stat.size - totalBytes) <= 1;
|
|
535
|
+
if (expectedHash) {
|
|
536
|
+
const valid = await verifyBundleHash(destPath, expectedHash);
|
|
537
|
+
if (!valid)
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
else if (!sizeLooksComplete) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
544
|
+
return extractBundleUpdate(label, destPath);
|
|
545
|
+
}
|
|
546
|
+
return destPath;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
452
552
|
async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash) {
|
|
453
553
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
454
554
|
const isZipBundle = extension === 'bundle' && packageFormat === 'zip';
|
|
@@ -462,76 +562,95 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
|
|
|
462
562
|
if (isZipBundle) {
|
|
463
563
|
await removePathRecursive(getBundleReleaseDir(label));
|
|
464
564
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
});
|
|
477
|
-
const reportDownloadProgress = (received, total) => {
|
|
478
|
-
if (!onProgress)
|
|
479
|
-
return;
|
|
480
|
-
const resolvedTotal = total > 0 ? total : totalBytes;
|
|
481
|
-
if (resolvedTotal <= 0)
|
|
482
|
-
return;
|
|
483
|
-
onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
|
|
484
|
-
};
|
|
485
|
-
let pollTimer = null;
|
|
486
|
-
if (onProgress && totalBytes > 0) {
|
|
487
|
-
pollTimer = setInterval(async () => {
|
|
488
|
-
try {
|
|
489
|
-
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
490
|
-
if (!exists)
|
|
491
|
-
return;
|
|
492
|
-
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
493
|
-
reportDownloadProgress(stat.size, totalBytes);
|
|
494
|
-
}
|
|
495
|
-
catch {
|
|
496
|
-
// Ignore polling errors while the file is being written.
|
|
497
|
-
}
|
|
498
|
-
}, 150);
|
|
499
|
-
}
|
|
500
|
-
if (onProgress) {
|
|
501
|
-
task.progress({ interval: 100, count: 10 }, (received, total) => {
|
|
502
|
-
reportDownloadProgress(received, total);
|
|
565
|
+
let lastError;
|
|
566
|
+
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
|
|
567
|
+
await cleanupPartialDownload(destPath);
|
|
568
|
+
const task = ReactNativeBlobUtil.config({
|
|
569
|
+
path: destPath,
|
|
570
|
+
fileCache: false,
|
|
571
|
+
overwrite: true,
|
|
572
|
+
timeout: 10 * 60 * 1000,
|
|
573
|
+
}).fetch('GET', downloadUrl, {
|
|
574
|
+
'X-Deployment-Key': deploymentKey,
|
|
575
|
+
...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
|
|
503
576
|
});
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
577
|
+
const reportDownloadProgress = (received, total) => {
|
|
578
|
+
if (!onProgress)
|
|
579
|
+
return;
|
|
580
|
+
const resolvedTotal = total > 0 ? total : totalBytes;
|
|
581
|
+
if (resolvedTotal <= 0)
|
|
582
|
+
return;
|
|
583
|
+
onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
|
|
584
|
+
};
|
|
585
|
+
let pollTimer = null;
|
|
586
|
+
if (onProgress && totalBytes > 0) {
|
|
587
|
+
pollTimer = setInterval(async () => {
|
|
588
|
+
try {
|
|
589
|
+
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
590
|
+
if (!exists)
|
|
591
|
+
return;
|
|
592
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
593
|
+
reportDownloadProgress(stat.size, totalBytes);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Ignore polling errors while the file is being written.
|
|
597
|
+
}
|
|
598
|
+
}, 150);
|
|
513
599
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
600
|
+
if (onProgress) {
|
|
601
|
+
task.progress({ interval: 100, count: 10 }, (received, total) => {
|
|
602
|
+
reportDownloadProgress(received, total);
|
|
603
|
+
});
|
|
518
604
|
}
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
if (
|
|
522
|
-
|
|
605
|
+
try {
|
|
606
|
+
const response = await task;
|
|
607
|
+
if (onProgress) {
|
|
608
|
+
onProgress(1);
|
|
523
609
|
}
|
|
610
|
+
const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
|
|
611
|
+
if (downloadedPath !== destPath) {
|
|
612
|
+
await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
|
|
613
|
+
}
|
|
614
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
615
|
+
const minSize = extension === 'apk' ? 1024 * 100 : 1024;
|
|
616
|
+
if (!stat || stat.size < minSize) {
|
|
617
|
+
throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
|
|
618
|
+
}
|
|
619
|
+
if (expectedHash) {
|
|
620
|
+
const valid = await verifyBundleHash(destPath, expectedHash);
|
|
621
|
+
if (!valid) {
|
|
622
|
+
throw new Error('Package hash verification failed');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
626
|
+
return extractBundleUpdate(label, destPath);
|
|
627
|
+
}
|
|
628
|
+
return destPath;
|
|
524
629
|
}
|
|
525
|
-
|
|
526
|
-
|
|
630
|
+
catch (error) {
|
|
631
|
+
lastError = error;
|
|
632
|
+
if (isRetryableDownloadError(error)) {
|
|
633
|
+
const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label);
|
|
634
|
+
if (recovered) {
|
|
635
|
+
if (onProgress)
|
|
636
|
+
onProgress(1);
|
|
637
|
+
return recovered;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
await cleanupPartialDownload(destPath);
|
|
641
|
+
if (attempt < DOWNLOAD_MAX_ATTEMPTS && isRetryableDownloadError(error)) {
|
|
642
|
+
await delay(attempt * 750);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
throw toUserFacingDownloadError(error);
|
|
527
646
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
clearInterval(pollTimer);
|
|
647
|
+
finally {
|
|
648
|
+
if (pollTimer) {
|
|
649
|
+
clearInterval(pollTimer);
|
|
650
|
+
}
|
|
533
651
|
}
|
|
534
652
|
}
|
|
653
|
+
throw toUserFacingDownloadError(lastError);
|
|
535
654
|
}
|
|
536
655
|
async function verifyBundleHash(filePath, expectedHash) {
|
|
537
656
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
@@ -725,6 +844,7 @@ async function performSync(options = {}, onProgress, messages) {
|
|
|
725
844
|
packageHash: update.packageHash,
|
|
726
845
|
installedAt: new Date().toISOString(),
|
|
727
846
|
});
|
|
847
|
+
await cleanupOldUpdates(update.label);
|
|
728
848
|
const shouldRestartNow = installMode === 'immediate' || update.isMandatory === true;
|
|
729
849
|
if (shouldRestartNow) {
|
|
730
850
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.RESTARTING, 1, {
|
|
@@ -793,12 +913,8 @@ async function performApkSync(update, showUI, messages, onProgress, changelog) {
|
|
|
793
913
|
label: update.label,
|
|
794
914
|
description: notes,
|
|
795
915
|
isMandatory: update.isMandatory,
|
|
796
|
-
}), 'apk', update.deviceAbi);
|
|
916
|
+
}), 'apk', update.deviceAbi, 'bundle', update.packageHash);
|
|
797
917
|
onProgress?.(types_1.SyncStatus.INSTALLING, 1);
|
|
798
|
-
const valid = await verifyBundleHash(apkPath, update.packageHash);
|
|
799
|
-
if (!valid) {
|
|
800
|
-
throw new Error('APK hash verification failed');
|
|
801
|
-
}
|
|
802
918
|
// Drop the OTA JS path before opening the installer so the new APK does not
|
|
803
919
|
// keep loading the previous bundle on first launch after install.
|
|
804
920
|
await clearUpdate();
|
|
@@ -855,10 +971,16 @@ async function getInstalledBundlePath() {
|
|
|
855
971
|
return null;
|
|
856
972
|
}
|
|
857
973
|
async function clearUpdate() {
|
|
974
|
+
const meta = await getStoredBundleMeta();
|
|
858
975
|
await clearStoredBundleMeta();
|
|
859
976
|
if (OTAUpdaterNative?.clearBundlePath) {
|
|
860
977
|
await OTAUpdaterNative.clearBundlePath();
|
|
861
978
|
}
|
|
979
|
+
if (meta?.label) {
|
|
980
|
+
await removePathRecursive(getBundleReleaseDir(meta.label));
|
|
981
|
+
await cleanupPartialDownload(getBundleZipPath(meta.label));
|
|
982
|
+
await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
|
|
983
|
+
}
|
|
862
984
|
}
|
|
863
985
|
async function notifyAppReady() {
|
|
864
986
|
const meta = await getStoredBundleMeta();
|