@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 CHANGED
@@ -25,6 +25,7 @@ export declare const defaultMessages: {
25
25
  installing: string;
26
26
  restarting: string;
27
27
  error: string;
28
+ downloadInterrupted: string;
28
29
  upToDate: string;
29
30
  updateAvailable: string;
30
31
  changelogTitle: string;
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
- const destExists = await ReactNativeBlobUtil.fs.exists(destPath);
466
- if (destExists) {
467
- await ReactNativeBlobUtil.fs.unlink(destPath);
468
- }
469
- const task = ReactNativeBlobUtil.config({
470
- path: destPath,
471
- fileCache: false,
472
- overwrite: true,
473
- }).fetch('GET', downloadUrl, {
474
- 'X-Deployment-Key': deploymentKey,
475
- ...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
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
- try {
506
- const response = await task;
507
- if (onProgress) {
508
- onProgress(1);
509
- }
510
- const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
511
- if (downloadedPath !== destPath) {
512
- await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
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
- const stat = await ReactNativeBlobUtil.fs.stat(destPath);
515
- const minSize = extension === 'apk' ? 1024 * 100 : 1024;
516
- if (!stat || stat.size < minSize) {
517
- throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
600
+ if (onProgress) {
601
+ task.progress({ interval: 100, count: 10 }, (received, total) => {
602
+ reportDownloadProgress(received, total);
603
+ });
518
604
  }
519
- if (expectedHash) {
520
- const valid = await verifyBundleHash(destPath, expectedHash);
521
- if (!valid) {
522
- throw new Error('Package hash verification failed');
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
- if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
526
- return extractBundleUpdate(label, destPath);
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
- return destPath;
529
- }
530
- finally {
531
- if (pollTimer) {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsonair.ir/react-native",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "React Native client SDK for self-hosted OTA updates",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",