Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 57 additions & 24 deletions src/common/downloadHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,77 @@

import * as fs from "fs";
import * as https from "https";
import { pipeline } from "stream";
import { URL } from "url";
import * as vscode from "vscode";
import { OutputChannelLogger } from "../extension/log/OutputChannelLogger";

export async function downloadFile(url: any, targetFile: any) {
const MAX_REDIRECTS = 5;

export function downloadFile(
url: string,
targetFile: string,
redirectsRemaining: number = MAX_REDIRECTS,
): Promise<void> {
const logger = OutputChannelLogger.getMainChannel();
let progress = 0;
let newProgress = 0;
return await new Promise((resolve, reject) => {
let downloadedLength = 0;

return new Promise<void>((resolve, reject) => {
const request = https
.get(url, response => {
const code = response.statusCode ?? 0;

if (code >= 400) {
return reject(new Error(response.statusMessage));
if (code >= 300 && code < 400 && response.headers.location) {
response.resume();
if (redirectsRemaining === 0) {
reject(new Error(`Too many redirects while downloading ${url}`));
return;
}

const redirectUrl = new URL(response.headers.location, url).toString();
downloadFile(redirectUrl, targetFile, redirectsRemaining - 1).then(
resolve,
reject,
);
return;
}

if (code < 200 || code >= 300) {
response.resume();
reject(
new Error(
`Download failed with HTTP status ${code}${
response.statusMessage ? ` ${response.statusMessage}` : ""
}`,
),
);
return;
}

const file = fs.createWriteStream(targetFile);
const totalLength = parseInt(response.headers["content-length"] as string, 10);
const totalLength = Number(response.headers["content-length"]);

response.pipe(file);
response.on("data", async function (chunk) {
newProgress += chunk.length;
const currentProgress =
parseFloat(getDownloadProgress(newProgress, totalLength)) * 100;
if (currentProgress - progress >= 5) {
response.on("data", (chunk: Buffer) => {
downloadedLength += chunk.length;
const currentProgress = getDownloadProgress(downloadedLength, totalLength);
if (currentProgress !== undefined && currentProgress - progress >= 5) {
progress = currentProgress;
logger.logStream(
`Current progress: ${currentProgress}%, please wait... \n`,
);
}
});

file.on("finish", async () => {
file.close();
logger.logStream(`Download Expo Go Completed: ${targetFile as string} \n`);
void vscode.window.showInformationMessage("Download Expo Go Completed.");
});
pipeline(response, file, error => {
if (error) {
fs.unlink(targetFile, () => reject(error));
return;
}

response.on("end", function () {
resolve(() => {
console.log("Progress end.");
});
logger.logStream(`Download Expo Go Completed: ${targetFile} \n`);
void vscode.window.showInformationMessage("Download Expo Go Completed.");
resolve();
});
})
.on("error", error => {
Expand All @@ -55,10 +84,14 @@ export async function downloadFile(url: any, targetFile: any) {
});
}

export async function downloadExpoGo(url: string, targetFile: string) {
export async function downloadExpoGo(url: string, targetFile: string): Promise<void> {
await downloadFile(url, targetFile);
}

function getDownloadProgress(currentLength: number, totalLength: number): string {
return (currentLength / totalLength).toFixed(2);
function getDownloadProgress(currentLength: number, totalLength: number): number | undefined {
if (!Number.isFinite(totalLength) || totalLength <= 0) {
return undefined;
}

return Math.floor((currentLength / totalLength) * 100);
}
86 changes: 86 additions & 0 deletions test/common/downloadHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for details.

import assert = require("assert");
import { EventEmitter } from "events";
import proxyquire = require("proxyquire");

suite("downloadHelper", function () {
type Pipeline = (
source: unknown,
destination: unknown,
callback: (error?: Error | null) => void,
) => void;
type Unlink = (path: string, callback: (error?: NodeJS.ErrnoException) => void) => void;

function createDownloadHelper(
pipelineStub: Pipeline,
unlinkStub: Unlink = () => undefined,
): { downloadFile: (url: string, targetFile: string) => Promise<void> } {
const response = new EventEmitter() as any;
response.statusCode = 200;
response.headers = { "content-length": "10" };
response.resume = () => undefined;

const request = new EventEmitter() as any;
request.end = () => undefined;

const getStub = (_url: string, callback: (value: any) => void) => {
callback(response);
return request;
};

return proxyquire.noCallThru()("../../src/common/downloadHelper", {
fs: {
createWriteStream: () => ({}),
unlink: unlinkStub,
},
https: { get: getStub },
stream: { pipeline: pipelineStub },
vscode: { window: { showInformationMessage: () => undefined } },
"../extension/log/OutputChannelLogger": {
OutputChannelLogger: {
getMainChannel: () => ({ logStream: () => undefined }),
},
},
});
}

test("resolves only after the file pipeline finishes", async function () {
let pipelineCallback: ((error?: Error | null) => void) | undefined;
const pipelineStub: Pipeline = (_source, _destination, callback) => {
pipelineCallback = callback;
};
const { downloadFile } = createDownloadHelper(pipelineStub);

let resolved = false;
const download = downloadFile("https://example.com/expo.apk", "expo.apk").then(() => {
resolved = true;
});
await Promise.resolve();

assert.strictEqual(resolved, false);
pipelineCallback?.();
await download;
assert.strictEqual(resolved, true);
});

test("removes the partial file when the pipeline fails", async function () {
const pipelineError = new Error("write failed");
const pipelineStub: Pipeline = (_source, _destination, callback) => {
callback(pipelineError);
};
let removedPath: string | undefined;
const unlinkStub: Unlink = (path, callback) => {
removedPath = path;
callback();
};
const { downloadFile } = createDownloadHelper(pipelineStub, unlinkStub);

await assert.rejects(
downloadFile("https://example.com/expo.apk", "expo.apk"),
pipelineError,
);
assert.strictEqual(removedPath, "expo.apk");
});
});