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 }