diff --git a/packages/core/package.json b/packages/core/package.json index 6f773b5a1..eb0a78cae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,6 +36,7 @@ "fs-extra": "^11.1.1", "glob": "^10.3.10", "gradient-string": "^2.0.2", + "progress": "^2.0.3", "tsup": "^7.2.0", "unzipper": "^0.10.14", "zod": "^3.22.4" @@ -45,6 +46,7 @@ "@types/fs-extra": "^11.0.2", "@types/gradient-string": "^1.1.4", "@types/node": "^20.8.0", + "@types/progress": "^2.0.7", "@types/unzipper": "^0.10.7", "@vitest/coverage-v8": "1.0.0-beta.0", "archiver": "^6.0.1", diff --git a/packages/core/src/downloadFile.ts b/packages/core/src/downloadFile.ts index ce6645040..c2d71e6c5 100644 --- a/packages/core/src/downloadFile.ts +++ b/packages/core/src/downloadFile.ts @@ -1,6 +1,7 @@ import path from 'node:path' import axios from 'axios' import fs from 'fs-extra' +import { onDownloadProgress } from './utils/onDownloadProgress' type DownloaderOptions = { url: string @@ -34,7 +35,8 @@ export async function downloadFile(options: DownloaderOptions) { axios .get(url, { - responseType: 'stream' + responseType: 'stream', + onDownloadProgress: onDownloadProgress(url, fileName) }) .then((response) => { const writer = fs.createWriteStream(filePath) diff --git a/packages/core/src/utils/bytesToMegabytes.ts b/packages/core/src/utils/bytesToMegabytes.ts new file mode 100644 index 000000000..a4bb1682a --- /dev/null +++ b/packages/core/src/utils/bytesToMegabytes.ts @@ -0,0 +1,4 @@ +export function bytesToMegabytes(bytes: number) { + const megabytes = bytes / 1024 / 1024 + return Math.round(megabytes * 10) / 10 +} diff --git a/packages/core/src/utils/onDownloadProgress.ts b/packages/core/src/utils/onDownloadProgress.ts new file mode 100644 index 000000000..1b78442d1 --- /dev/null +++ b/packages/core/src/utils/onDownloadProgress.ts @@ -0,0 +1,40 @@ +import type { AxiosProgressEvent } from 'axios' +import ProgressBar from 'progress' +import { bytesToMegabytes } from './bytesToMegabytes' + +export function onDownloadProgress(url: string, fileName: string) { + let progressBar: ProgressBar + let lastDownloadedBytes = 0 + + return ({ loaded: downloadedBytes, total: totalDownloadBytes }: AxiosProgressEvent) => { + if (!totalDownloadBytes) { + throw new Error( + `[DownloadFile] Request returned total download bytes as 0. This should never happen, and it means that the target file is empty. URL: ${url}` + ) + } + + if (!progressBar) { + progressBar = getDownloadProgressBar(url, fileName, totalDownloadBytes) + } else { + const delta = downloadedBytes - lastDownloadedBytes + lastDownloadedBytes = downloadedBytes + progressBar.tick(delta) + } + } +} + +function getDownloadProgressBar(url: string, fileName: string, totalBytes: number) { + // TODO: This header should be based on the wallet config. + const barHeader = url.startsWith('https://github.com/MetaMask/metamask-extension/releases/download/') + ? '🦊 MetaMask' + : fileName + + const downloadSize = `${bytesToMegabytes(totalBytes)} MB` + + return new ProgressBar(`${barHeader} (${downloadSize}) [:bar] :percent :etas`, { + width: 20, + complete: '=', + incomplete: ' ', + total: totalBytes + }) +} diff --git a/packages/core/test/downloadFile.test.ts b/packages/core/test/downloadFile.test.ts index 0d42eefdc..ff30149ea 100644 --- a/packages/core/test/downloadFile.test.ts +++ b/packages/core/test/downloadFile.test.ts @@ -14,7 +14,11 @@ const MOCK_URL = `https://example.com/${FILE_NAME}` const server = setupServer( http.get(MOCK_URL, () => { - return HttpResponse.text(FILE_CONTENT) + return HttpResponse.text(FILE_CONTENT, { + headers: { + 'Content-Length': new Blob([FILE_CONTENT]).size.toString() + } + }) }) ) @@ -55,7 +59,8 @@ describe('downloadFile', () => { expect(axiosGetSpy).toHaveBeenCalledOnce() expect(axiosGetSpy).toHaveBeenCalledWith(MOCK_URL, { - responseType: 'stream' + responseType: 'stream', + onDownloadProgress: expect.any(Function) }) }) diff --git a/packages/core/test/utils/bytesToMegabytes.test.ts b/packages/core/test/utils/bytesToMegabytes.test.ts new file mode 100644 index 000000000..5881c293a --- /dev/null +++ b/packages/core/test/utils/bytesToMegabytes.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { bytesToMegabytes } from '../../src/utils/bytesToMegabytes' + +describe('bytesToMegabytes', () => { + it('converts bytes to megabytes and rounds the result', async () => { + const result = bytesToMegabytes(21_260_893) + + expect(result).to.equal(20.3) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab9e5df6f..96e4a3866 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: playwright-core: specifier: 1.40.0 version: 1.40.0 + progress: + specifier: ^2.0.3 + version: 2.0.3 tsup: specifier: ^7.2.0 version: 7.2.0(typescript@5.2.2) @@ -109,6 +112,9 @@ importers: '@types/node': specifier: ^20.8.0 version: 20.8.0 + '@types/progress': + specifier: ^2.0.7 + version: 2.0.7 '@types/unzipper': specifier: ^0.10.7 version: 0.10.7 @@ -1255,6 +1261,12 @@ packages: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true + /@types/progress@2.0.7: + resolution: {integrity: sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==} + dependencies: + '@types/node': 20.10.2 + dev: true + /@types/readdir-glob@1.1.2: resolution: {integrity: sha512-vwAYrNN/8yhp/FJRU6HUSD0yk6xfoOS8HrZa8ZL7j+X8hJpaC1hTcAiXX2IxaAkkvrz9mLyoEhYZTE3cEYvA9Q==} dependencies: @@ -3849,6 +3861,11 @@ packages: /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + /progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: false