BrowserFetcher.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. /**
  2. * Copyright 2017 Google Inc. All rights reserved.
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. const os = require('os');
  17. const fs = require('fs');
  18. const path = require('path');
  19. const util = require('util');
  20. const extract = require('extract-zip');
  21. const URL = require('url');
  22. const {helper, assert} = require('./helper');
  23. const removeRecursive = require('rimraf');
  24. // @ts-ignore
  25. const ProxyAgent = require('https-proxy-agent');
  26. // @ts-ignore
  27. const getProxyForUrl = require('proxy-from-env').getProxyForUrl;
  28. const DEFAULT_DOWNLOAD_HOST = 'https://storage.googleapis.com';
  29. const supportedPlatforms = ['mac', 'linux', 'win32', 'win64'];
  30. const downloadURLs = {
  31. linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip',
  32. mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip',
  33. win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip',
  34. win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip',
  35. };
  36. /**
  37. * @param {string} platform
  38. * @param {string} revision
  39. * @return {string}
  40. */
  41. function archiveName(platform, revision) {
  42. if (platform === 'linux')
  43. return 'chrome-linux';
  44. if (platform === 'mac')
  45. return 'chrome-mac';
  46. if (platform === 'win32' || platform === 'win64') {
  47. // Windows archive name changed at r591479.
  48. return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32';
  49. }
  50. return null;
  51. }
  52. /**
  53. * @param {string} platform
  54. * @param {string} host
  55. * @param {string} revision
  56. * @return {string}
  57. */
  58. function downloadURL(platform, host, revision) {
  59. return util.format(downloadURLs[platform], host, revision, archiveName(platform, revision));
  60. }
  61. const readdirAsync = helper.promisify(fs.readdir.bind(fs));
  62. const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
  63. const unlinkAsync = helper.promisify(fs.unlink.bind(fs));
  64. const chmodAsync = helper.promisify(fs.chmod.bind(fs));
  65. function existsAsync(filePath) {
  66. let fulfill = null;
  67. const promise = new Promise(x => fulfill = x);
  68. fs.access(filePath, err => fulfill(!err));
  69. return promise;
  70. }
  71. class BrowserFetcher {
  72. /**
  73. * @param {string} projectRoot
  74. * @param {!BrowserFetcher.Options=} options
  75. */
  76. constructor(projectRoot, options = {}) {
  77. this._downloadsFolder = options.path || path.join(projectRoot, '.local-chromium');
  78. this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST;
  79. this._platform = options.platform || '';
  80. if (!this._platform) {
  81. const platform = os.platform();
  82. if (platform === 'darwin')
  83. this._platform = 'mac';
  84. else if (platform === 'linux')
  85. this._platform = 'linux';
  86. else if (platform === 'win32')
  87. this._platform = os.arch() === 'x64' ? 'win64' : 'win32';
  88. assert(this._platform, 'Unsupported platform: ' + os.platform());
  89. }
  90. assert(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform);
  91. }
  92. /**
  93. * @return {string}
  94. */
  95. platform() {
  96. return this._platform;
  97. }
  98. /**
  99. * @param {string} revision
  100. * @return {!Promise<boolean>}
  101. */
  102. canDownload(revision) {
  103. const url = downloadURL(this._platform, this._downloadHost, revision);
  104. let resolve;
  105. const promise = new Promise(x => resolve = x);
  106. const request = httpRequest(url, 'HEAD', response => {
  107. resolve(response.statusCode === 200);
  108. });
  109. request.on('error', error => {
  110. console.error(error);
  111. resolve(false);
  112. });
  113. return promise;
  114. }
  115. /**
  116. * @param {string} revision
  117. * @param {?function(number, number):void} progressCallback
  118. * @return {!Promise<!BrowserFetcher.RevisionInfo>}
  119. */
  120. async download(revision, progressCallback) {
  121. const url = downloadURL(this._platform, this._downloadHost, revision);
  122. const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`);
  123. const folderPath = this._getFolderPath(revision);
  124. if (await existsAsync(folderPath))
  125. return this.revisionInfo(revision);
  126. if (!(await existsAsync(this._downloadsFolder)))
  127. await mkdirAsync(this._downloadsFolder);
  128. try {
  129. await downloadFile(url, zipPath, progressCallback);
  130. await extractZip(zipPath, folderPath);
  131. } finally {
  132. if (await existsAsync(zipPath))
  133. await unlinkAsync(zipPath);
  134. }
  135. const revisionInfo = this.revisionInfo(revision);
  136. if (revisionInfo)
  137. await chmodAsync(revisionInfo.executablePath, 0o755);
  138. return revisionInfo;
  139. }
  140. /**
  141. * @return {!Promise<!Array<string>>}
  142. */
  143. async localRevisions() {
  144. if (!await existsAsync(this._downloadsFolder))
  145. return [];
  146. const fileNames = await readdirAsync(this._downloadsFolder);
  147. return fileNames.map(fileName => parseFolderPath(fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision);
  148. }
  149. /**
  150. * @param {string} revision
  151. */
  152. async remove(revision) {
  153. const folderPath = this._getFolderPath(revision);
  154. assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
  155. await new Promise(fulfill => removeRecursive(folderPath, fulfill));
  156. }
  157. /**
  158. * @param {string} revision
  159. * @return {!BrowserFetcher.RevisionInfo}
  160. */
  161. revisionInfo(revision) {
  162. const folderPath = this._getFolderPath(revision);
  163. let executablePath = '';
  164. if (this._platform === 'mac')
  165. executablePath = path.join(folderPath, archiveName(this._platform, revision), 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
  166. else if (this._platform === 'linux')
  167. executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome');
  168. else if (this._platform === 'win32' || this._platform === 'win64')
  169. executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome.exe');
  170. else
  171. throw new Error('Unsupported platform: ' + this._platform);
  172. const url = downloadURL(this._platform, this._downloadHost, revision);
  173. const local = fs.existsSync(folderPath);
  174. return {revision, executablePath, folderPath, local, url};
  175. }
  176. /**
  177. * @param {string} revision
  178. * @return {string}
  179. */
  180. _getFolderPath(revision) {
  181. return path.join(this._downloadsFolder, this._platform + '-' + revision);
  182. }
  183. }
  184. module.exports = BrowserFetcher;
  185. /**
  186. * @param {string} folderPath
  187. * @return {?{platform: string, revision: string}}
  188. */
  189. function parseFolderPath(folderPath) {
  190. const name = path.basename(folderPath);
  191. const splits = name.split('-');
  192. if (splits.length !== 2)
  193. return null;
  194. const [platform, revision] = splits;
  195. if (!supportedPlatforms.includes(platform))
  196. return null;
  197. return {platform, revision};
  198. }
  199. /**
  200. * @param {string} url
  201. * @param {string} destinationPath
  202. * @param {?function(number, number):void} progressCallback
  203. * @return {!Promise}
  204. */
  205. function downloadFile(url, destinationPath, progressCallback) {
  206. let fulfill, reject;
  207. let downloadedBytes = 0;
  208. let totalBytes = 0;
  209. const promise = new Promise((x, y) => { fulfill = x; reject = y; });
  210. const request = httpRequest(url, 'GET', response => {
  211. if (response.statusCode !== 200) {
  212. const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
  213. // consume response data to free up memory
  214. response.resume();
  215. reject(error);
  216. return;
  217. }
  218. const file = fs.createWriteStream(destinationPath);
  219. file.on('finish', () => fulfill());
  220. file.on('error', error => reject(error));
  221. response.pipe(file);
  222. totalBytes = parseInt(/** @type {string} */ (response.headers['content-length']), 10);
  223. if (progressCallback)
  224. response.on('data', onData);
  225. });
  226. request.on('error', error => reject(error));
  227. return promise;
  228. function onData(chunk) {
  229. downloadedBytes += chunk.length;
  230. progressCallback(downloadedBytes, totalBytes);
  231. }
  232. }
  233. /**
  234. * @param {string} zipPath
  235. * @param {string} folderPath
  236. * @return {!Promise<?Error>}
  237. */
  238. function extractZip(zipPath, folderPath) {
  239. return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => {
  240. if (err)
  241. reject(err);
  242. else
  243. fulfill();
  244. }));
  245. }
  246. function httpRequest(url, method, response) {
  247. /** @type {Object} */
  248. let options = URL.parse(url);
  249. options.method = method;
  250. const proxyURL = getProxyForUrl(url);
  251. if (proxyURL) {
  252. if (url.startsWith('http:')) {
  253. const proxy = URL.parse(proxyURL);
  254. options = {
  255. path: options.href,
  256. host: proxy.hostname,
  257. port: proxy.port,
  258. };
  259. } else {
  260. /** @type {Object} */
  261. const parsedProxyURL = URL.parse(proxyURL);
  262. parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:';
  263. options.agent = new ProxyAgent(parsedProxyURL);
  264. options.rejectUnauthorized = false;
  265. }
  266. }
  267. const requestCallback = res => {
  268. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
  269. httpRequest(res.headers.location, method, response);
  270. else
  271. response(res);
  272. };
  273. const request = options.protocol === 'https:' ?
  274. require('https').request(options, requestCallback) :
  275. require('http').request(options, requestCallback);
  276. request.end();
  277. return request;
  278. }
  279. /**
  280. * @typedef {Object} BrowserFetcher.Options
  281. * @property {string=} platform
  282. * @property {string=} path
  283. * @property {string=} host
  284. */
  285. /**
  286. * @typedef {Object} BrowserFetcher.RevisionInfo
  287. * @property {string} folderPath
  288. * @property {string} executablePath
  289. * @property {string} url
  290. * @property {boolean} local
  291. * @property {string} revision
  292. */