tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

install.ts (14088B)


      1 /**
      2 * @license
      3 * Copyright 2017 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import assert from 'node:assert';
      8 import {spawnSync} from 'node:child_process';
      9 import {existsSync, readFileSync} from 'node:fs';
     10 import {mkdir, unlink} from 'node:fs/promises';
     11 import os from 'node:os';
     12 import path from 'node:path';
     13 
     14 import type * as ProgressBar from 'progress';
     15 import ProgressBarClass from 'progress';
     16 
     17 import {
     18  Browser,
     19  BrowserPlatform,
     20  downloadUrls,
     21 } from './browser-data/browser-data.js';
     22 import {Cache, InstalledBrowser} from './Cache.js';
     23 import {debug} from './debug.js';
     24 import {detectBrowserPlatform} from './detectPlatform.js';
     25 import {unpackArchive} from './fileUtil.js';
     26 import {downloadFile, getJSON, headHttpRequest} from './httpUtil.js';
     27 
     28 const debugInstall = debug('puppeteer:browsers:install');
     29 
     30 const times = new Map<string, [number, number]>();
     31 function debugTime(label: string) {
     32  times.set(label, process.hrtime());
     33 }
     34 
     35 function debugTimeEnd(label: string) {
     36  const end = process.hrtime();
     37  const start = times.get(label);
     38  if (!start) {
     39    return;
     40  }
     41  const duration =
     42    end[0] * 1000 + end[1] / 1e6 - (start[0] * 1000 + start[1] / 1e6); // calculate duration in milliseconds
     43  debugInstall(`Duration for ${label}: ${duration}ms`);
     44 }
     45 
     46 /**
     47 * @public
     48 */
     49 export interface InstallOptions {
     50  /**
     51   * Determines the path to download browsers to.
     52   */
     53  cacheDir: string;
     54  /**
     55   * Determines which platform the browser will be suited for.
     56   *
     57   * @defaultValue **Auto-detected.**
     58   */
     59  platform?: BrowserPlatform;
     60  /**
     61   * Determines which browser to install.
     62   */
     63  browser: Browser;
     64  /**
     65   * Determines which buildId to download. BuildId should uniquely identify
     66   * binaries and they are used for caching.
     67   */
     68  buildId: string;
     69  /**
     70   * An alias for the provided `buildId`. It will be used to maintain local
     71   * metadata to support aliases in the `launch` command.
     72   *
     73   * @example 'canary'
     74   */
     75  buildIdAlias?: string;
     76  /**
     77   * Provides information about the progress of the download. If set to
     78   * 'default', the default callback implementing a progress bar will be
     79   * used.
     80   */
     81  downloadProgressCallback?:
     82    | 'default'
     83    | ((downloadedBytes: number, totalBytes: number) => void);
     84  /**
     85   * Determines the host that will be used for downloading.
     86   *
     87   * @defaultValue Either
     88   *
     89   * - https://storage.googleapis.com/chrome-for-testing-public or
     90   * - https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central
     91   *
     92   */
     93  baseUrl?: string;
     94  /**
     95   * Whether to unpack and install browser archives.
     96   *
     97   * @defaultValue `true`
     98   */
     99  unpack?: boolean;
    100  /**
    101   * @internal
    102   * @defaultValue `false`
    103   */
    104  forceFallbackForTesting?: boolean;
    105 
    106  /**
    107   * Whether to attempt to install system-level dependencies required
    108   * for the browser.
    109   *
    110   * Only supported for Chrome on Debian or Ubuntu.
    111   * Requires system-level privileges to run `apt-get`.
    112   *
    113   * @defaultValue `false`
    114   */
    115  installDeps?: boolean;
    116 }
    117 
    118 /**
    119 * Downloads and unpacks the browser archive according to the
    120 * {@link InstallOptions}.
    121 *
    122 * @returns a {@link InstalledBrowser} instance.
    123 *
    124 * @public
    125 */
    126 export function install(
    127  options: InstallOptions & {unpack?: true},
    128 ): Promise<InstalledBrowser>;
    129 /**
    130 * Downloads the browser archive according to the {@link InstallOptions} without
    131 * unpacking.
    132 *
    133 * @returns the absolute path to the archive.
    134 *
    135 * @public
    136 */
    137 export function install(
    138  options: InstallOptions & {unpack: false},
    139 ): Promise<string>;
    140 export async function install(
    141  options: InstallOptions,
    142 ): Promise<InstalledBrowser | string> {
    143  options.platform ??= detectBrowserPlatform();
    144  options.unpack ??= true;
    145  if (!options.platform) {
    146    throw new Error(
    147      `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`,
    148    );
    149  }
    150  const url = getDownloadUrl(
    151    options.browser,
    152    options.platform,
    153    options.buildId,
    154    options.baseUrl,
    155  );
    156  try {
    157    return await installUrl(url, options);
    158  } catch (err) {
    159    // If custom baseUrl is provided, do not fall back to CfT dashboard.
    160    if (options.baseUrl && !options.forceFallbackForTesting) {
    161      throw err;
    162    }
    163    debugInstall(`Error downloading from ${url}.`);
    164    switch (options.browser) {
    165      case Browser.CHROME:
    166      case Browser.CHROMEDRIVER:
    167      case Browser.CHROMEHEADLESSSHELL: {
    168        debugInstall(
    169          `Trying to find download URL via https://googlechromelabs.github.io/chrome-for-testing.`,
    170        );
    171        interface Version {
    172          downloads: Record<string, Array<{platform: string; url: string}>>;
    173        }
    174        const version = (await getJSON(
    175          new URL(
    176            `https://googlechromelabs.github.io/chrome-for-testing/${options.buildId}.json`,
    177          ),
    178        )) as Version;
    179        let platform = '';
    180        switch (options.platform) {
    181          case BrowserPlatform.LINUX:
    182            platform = 'linux64';
    183            break;
    184          case BrowserPlatform.MAC_ARM:
    185            platform = 'mac-arm64';
    186            break;
    187          case BrowserPlatform.MAC:
    188            platform = 'mac-x64';
    189            break;
    190          case BrowserPlatform.WIN32:
    191            platform = 'win32';
    192            break;
    193          case BrowserPlatform.WIN64:
    194            platform = 'win64';
    195            break;
    196        }
    197        const backupUrl = version.downloads[options.browser]?.find(link => {
    198          return link['platform'] === platform;
    199        })?.url;
    200        if (backupUrl) {
    201          // If the URL is the same, skip the retry.
    202          if (backupUrl === url.toString()) {
    203            throw err;
    204          }
    205          debugInstall(`Falling back to downloading from ${backupUrl}.`);
    206          return await installUrl(new URL(backupUrl), options);
    207        }
    208        throw err;
    209      }
    210      default:
    211        throw err;
    212    }
    213  }
    214 }
    215 
    216 async function installDeps(installedBrowser: InstalledBrowser) {
    217  if (
    218    process.platform !== 'linux' ||
    219    installedBrowser.platform !== BrowserPlatform.LINUX
    220  ) {
    221    return;
    222  }
    223  // Currently, only Debian-like deps are supported.
    224  const depsPath = path.join(
    225    path.dirname(installedBrowser.executablePath),
    226    'deb.deps',
    227  );
    228  if (!existsSync(depsPath)) {
    229    debugInstall(`deb.deps file was not found at ${depsPath}`);
    230    return;
    231  }
    232  const data = readFileSync(depsPath, 'utf-8').split('\n').join(',');
    233  if (process.getuid?.() !== 0) {
    234    throw new Error('Installing system dependencies requires root privileges');
    235  }
    236  let result = spawnSync('apt-get', ['-v']);
    237  if (result.status !== 0) {
    238    throw new Error(
    239      'Failed to install system dependencies: apt-get does not seem to be available',
    240    );
    241  }
    242  debugInstall(`Trying to install dependencies: ${data}`);
    243  result = spawnSync('apt-get', [
    244    'satisfy',
    245    '-y',
    246    data,
    247    '--no-install-recommends',
    248  ]);
    249  if (result.status !== 0) {
    250    throw new Error(
    251      `Failed to install system dependencies: status=${result.status},error=${result.error},stdout=${result.stdout.toString('utf8')},stderr=${result.stderr.toString('utf8')}`,
    252    );
    253  }
    254  debugInstall(`Installed system dependencies ${data}`);
    255 }
    256 
    257 async function installUrl(
    258  url: URL,
    259  options: InstallOptions,
    260 ): Promise<InstalledBrowser | string> {
    261  options.platform ??= detectBrowserPlatform();
    262  if (!options.platform) {
    263    throw new Error(
    264      `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`,
    265    );
    266  }
    267  let downloadProgressCallback = options.downloadProgressCallback;
    268  if (downloadProgressCallback === 'default') {
    269    downloadProgressCallback = await makeProgressCallback(
    270      options.browser,
    271      options.buildIdAlias ?? options.buildId,
    272    );
    273  }
    274  const fileName = decodeURIComponent(url.toString()).split('/').pop();
    275  assert(fileName, `A malformed download URL was found: ${url}.`);
    276  const cache = new Cache(options.cacheDir);
    277  const browserRoot = cache.browserRoot(options.browser);
    278  const archivePath = path.join(browserRoot, `${options.buildId}-${fileName}`);
    279  if (!existsSync(browserRoot)) {
    280    await mkdir(browserRoot, {recursive: true});
    281  }
    282 
    283  if (!options.unpack) {
    284    if (existsSync(archivePath)) {
    285      return archivePath;
    286    }
    287    debugInstall(`Downloading binary from ${url}`);
    288    debugTime('download');
    289    await downloadFile(url, archivePath, downloadProgressCallback);
    290    debugTimeEnd('download');
    291    return archivePath;
    292  }
    293 
    294  const outputPath = cache.installationDir(
    295    options.browser,
    296    options.platform,
    297    options.buildId,
    298  );
    299 
    300  try {
    301    if (existsSync(outputPath)) {
    302      const installedBrowser = new InstalledBrowser(
    303        cache,
    304        options.browser,
    305        options.buildId,
    306        options.platform,
    307      );
    308      if (!existsSync(installedBrowser.executablePath)) {
    309        throw new Error(
    310          `The browser folder (${outputPath}) exists but the executable (${installedBrowser.executablePath}) is missing`,
    311        );
    312      }
    313      await runSetup(installedBrowser);
    314      if (options.installDeps) {
    315        await installDeps(installedBrowser);
    316      }
    317      return installedBrowser;
    318    }
    319    debugInstall(`Downloading binary from ${url}`);
    320    try {
    321      debugTime('download');
    322      await downloadFile(url, archivePath, downloadProgressCallback);
    323    } finally {
    324      debugTimeEnd('download');
    325    }
    326 
    327    debugInstall(`Installing ${archivePath} to ${outputPath}`);
    328    try {
    329      debugTime('extract');
    330      await unpackArchive(archivePath, outputPath);
    331    } finally {
    332      debugTimeEnd('extract');
    333    }
    334 
    335    const installedBrowser = new InstalledBrowser(
    336      cache,
    337      options.browser,
    338      options.buildId,
    339      options.platform,
    340    );
    341    if (options.buildIdAlias) {
    342      const metadata = installedBrowser.readMetadata();
    343      metadata.aliases[options.buildIdAlias] = options.buildId;
    344      installedBrowser.writeMetadata(metadata);
    345    }
    346 
    347    await runSetup(installedBrowser);
    348    if (options.installDeps) {
    349      await installDeps(installedBrowser);
    350    }
    351    return installedBrowser;
    352  } finally {
    353    if (existsSync(archivePath)) {
    354      await unlink(archivePath);
    355    }
    356  }
    357 }
    358 
    359 async function runSetup(installedBrowser: InstalledBrowser): Promise<void> {
    360  // On Windows for Chrome invoke setup.exe to configure sandboxes.
    361  if (
    362    (installedBrowser.platform === BrowserPlatform.WIN32 ||
    363      installedBrowser.platform === BrowserPlatform.WIN64) &&
    364    installedBrowser.browser === Browser.CHROME &&
    365    installedBrowser.platform === detectBrowserPlatform()
    366  ) {
    367    try {
    368      debugTime('permissions');
    369      const browserDir = path.dirname(installedBrowser.executablePath);
    370      const setupExePath = path.join(browserDir, 'setup.exe');
    371      if (!existsSync(setupExePath)) {
    372        return;
    373      }
    374      spawnSync(
    375        path.join(browserDir, 'setup.exe'),
    376        [`--configure-browser-in-directory=` + browserDir],
    377        {
    378          shell: true,
    379        },
    380      );
    381      // TODO: Handle error here. Currently the setup.exe sometimes
    382      // errors although it sets the permissions correctly.
    383    } finally {
    384      debugTimeEnd('permissions');
    385    }
    386  }
    387 }
    388 
    389 /**
    390 * @public
    391 */
    392 export interface UninstallOptions {
    393  /**
    394   * Determines the platform for the browser binary.
    395   *
    396   * @defaultValue **Auto-detected.**
    397   */
    398  platform?: BrowserPlatform;
    399  /**
    400   * The path to the root of the cache directory.
    401   */
    402  cacheDir: string;
    403  /**
    404   * Determines which browser to uninstall.
    405   */
    406  browser: Browser;
    407  /**
    408   * The browser build to uninstall
    409   */
    410  buildId: string;
    411 }
    412 
    413 /**
    414 *
    415 * @public
    416 */
    417 export async function uninstall(options: UninstallOptions): Promise<void> {
    418  options.platform ??= detectBrowserPlatform();
    419  if (!options.platform) {
    420    throw new Error(
    421      `Cannot detect the browser platform for: ${os.platform()} (${os.arch()})`,
    422    );
    423  }
    424 
    425  new Cache(options.cacheDir).uninstall(
    426    options.browser,
    427    options.platform,
    428    options.buildId,
    429  );
    430 }
    431 
    432 /**
    433 * @public
    434 */
    435 export interface GetInstalledBrowsersOptions {
    436  /**
    437   * The path to the root of the cache directory.
    438   */
    439  cacheDir: string;
    440 }
    441 
    442 /**
    443 * Returns metadata about browsers installed in the cache directory.
    444 *
    445 * @public
    446 */
    447 export async function getInstalledBrowsers(
    448  options: GetInstalledBrowsersOptions,
    449 ): Promise<InstalledBrowser[]> {
    450  return new Cache(options.cacheDir).getInstalledBrowsers();
    451 }
    452 
    453 /**
    454 * @public
    455 */
    456 export async function canDownload(options: InstallOptions): Promise<boolean> {
    457  options.platform ??= detectBrowserPlatform();
    458  if (!options.platform) {
    459    throw new Error(
    460      `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`,
    461    );
    462  }
    463  return await headHttpRequest(
    464    getDownloadUrl(
    465      options.browser,
    466      options.platform,
    467      options.buildId,
    468      options.baseUrl,
    469    ),
    470  );
    471 }
    472 
    473 /**
    474 * Retrieves a URL for downloading the binary archive of a given browser.
    475 *
    476 * The archive is bound to the specific platform and build ID specified.
    477 *
    478 * @public
    479 */
    480 export function getDownloadUrl(
    481  browser: Browser,
    482  platform: BrowserPlatform,
    483  buildId: string,
    484  baseUrl?: string,
    485 ): URL {
    486  return new URL(downloadUrls[browser](platform, buildId, baseUrl));
    487 }
    488 
    489 /**
    490 * @public
    491 */
    492 export function makeProgressCallback(
    493  browser: Browser,
    494  buildId: string,
    495 ): (downloadedBytes: number, totalBytes: number) => void {
    496  let progressBar: ProgressBar;
    497 
    498  let lastDownloadedBytes = 0;
    499  return (downloadedBytes: number, totalBytes: number) => {
    500    if (!progressBar) {
    501      progressBar = new ProgressBarClass(
    502        `Downloading ${browser} ${buildId} - ${toMegabytes(
    503          totalBytes,
    504        )} [:bar] :percent :etas `,
    505        {
    506          complete: '=',
    507          incomplete: ' ',
    508          width: 20,
    509          total: totalBytes,
    510        },
    511      );
    512    }
    513    const delta = downloadedBytes - lastDownloadedBytes;
    514    lastDownloadedBytes = downloadedBytes;
    515    progressBar.tick(delta);
    516  };
    517 }
    518 
    519 function toMegabytes(bytes: number) {
    520  const mb = bytes / 1000 / 1000;
    521  return `${Math.round(mb * 10) / 10} MB`;
    522 }