// Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // @dart = 2.6 import 'dart:io' as io; import 'package:args/args.dart'; import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:yaml/yaml.dart'; import 'common.dart'; import 'environment.dart'; import 'exceptions.dart'; class FirefoxArgParser extends BrowserArgParser { static final FirefoxArgParser _singletonInstance = FirefoxArgParser._(); /// The [FirefoxArgParser] singleton. static FirefoxArgParser get instance => _singletonInstance; String _version; FirefoxArgParser._(); @override void populateOptions(ArgParser argParser) { final YamlMap browserLock = BrowserLock.instance.configuration; String firefoxVersion = browserLock['firefox']['version'] as String; argParser ..addOption( 'firefox-version', defaultsTo: '$firefoxVersion', help: 'The Firefox version to use while running tests. If the requested ' 'version has not been installed, it will be downloaded and installed ' 'automatically. Value "latest" will use the latest ' 'stable build of Firefox, installing it if necessary. Value "system" ' 'will use the manually installed version of Firefox on this computer.', ); } @override void parseOptions(ArgResults argResults) { _version = argResults['firefox-version'] as String; } @override String get version => _version; } /// Returns the installation of Firefox, installing it if necessary. /// /// If [requestedVersion] is null, uses the version specified on the /// command-line. If not specified on the command-line, uses the version /// specified in the "browser_lock.yaml" file. /// /// If [requestedVersion] is not null, installs that version. The value /// may be "latest" (the latest stable Firefox version), "system" /// (manually installed Firefox on the current operating system), or an /// exact version number such as 69.0.3. Versions of Firefox can be found here: /// /// https://download-installer.cdn.mozilla.net/pub/firefox/releases/ Future getOrInstallFirefox( String requestedVersion, { StringSink infoLog, }) async { // These tests are aimed to run only on the Linux containers in Cirrus. // Therefore Firefox installation is implemented only for Linux now. if (!io.Platform.isLinux && !io.Platform.isMacOS) { throw UnimplementedError('Firefox Installer is only supported on Linux ' 'and Mac operating systems'); } infoLog ??= io.stdout; if (requestedVersion == 'system') { return BrowserInstallation( version: 'system', executable: await _findSystemFirefoxExecutable(), ); } FirefoxInstaller installer; try { installer = requestedVersion == 'latest' ? await FirefoxInstaller.latest() : FirefoxInstaller(version: requestedVersion); if (installer.isInstalled) { infoLog.writeln( 'Installation was skipped because Firefox version ${installer.version} is already installed.'); } else { infoLog.writeln('Installing Firefox version: ${installer.version}'); await installer.install(); final BrowserInstallation installation = installer.getInstallation(); infoLog.writeln( 'Installations complete. To launch it run ${installation.executable}'); } return installer.getInstallation(); } finally { installer?.close(); } } /// Manages the installation of a particular [version] of Firefox. class FirefoxInstaller { factory FirefoxInstaller({ @required String version, }) { if (version == 'system') { throw BrowserInstallerException( 'Cannot install system version of Firefox. System Firefox must be installed manually.'); } if (version == 'latest') { throw BrowserInstallerException( 'Expected a concrete Firefox version, but got $version. Maybe use FirefoxInstaller.latest()?'); } final io.Directory firefoxInstallationDir = io.Directory( path.join(environment.webUiDartToolDir.path, 'firefox'), ); final io.Directory versionDir = io.Directory( path.join(firefoxInstallationDir.path, version), ); return FirefoxInstaller._( version: version, firefoxInstallationDir: firefoxInstallationDir, versionDir: versionDir, ); } static Future latest() async { final String latestVersion = io.Platform.isLinux ? await fetchLatestFirefoxVersionLinux() : await fetchLatestFirefoxVersionMacOS(); return FirefoxInstaller(version: latestVersion); } FirefoxInstaller._({ @required this.version, @required this.firefoxInstallationDir, @required this.versionDir, }); /// Firefox version managed by this installer. final String version; /// HTTP client used to download Firefox. final Client client = Client(); /// Root directory that contains Firefox versions. final io.Directory firefoxInstallationDir; /// Installation directory for Firefox of the requested [version]. final io.Directory versionDir; bool get isInstalled { return versionDir.existsSync(); } BrowserInstallation getInstallation() { if (!isInstalled) { return null; } return BrowserInstallation( version: version, executable: PlatformBinding.instance.getFirefoxExecutablePath(versionDir), ); } /// Install the browser by downloading from the web. Future install() async { final io.File downloadedFile = await _download(); if (io.Platform.isLinux) { await _uncompress(downloadedFile); } else if (io.Platform.isMacOS) { await _mountDmgAndCopy(downloadedFile); } downloadedFile.deleteSync(); } /// Downloads the browser version from web into a target file. /// See [version]. Future _download() async { if (versionDir.existsSync()) { versionDir.deleteSync(recursive: true); } versionDir.createSync(recursive: true); final String url = PlatformBinding.instance.getFirefoxDownloadUrl(version); final StreamedResponse download = await client.send(Request( 'GET', Uri.parse(url), )); final io.File downloadedFile = io.File(path.join(versionDir.path, PlatformBinding.instance.getFirefoxDownloadFilename(version))); io.IOSink sink = downloadedFile.openWrite(); await download.stream.pipe(sink); await sink.flush(); await sink.close(); return downloadedFile; } /// Uncompress the downloaded browser files for operating systems that /// use a zip archive. /// See [version]. Future _uncompress(io.File downloadedFile) async { final io.ProcessResult unzipResult = await io.Process.run('tar', [ '-x', '-f', downloadedFile.path, '-C', versionDir.path, ]); if (unzipResult.exitCode != 0) { throw BrowserInstallerException( 'Failed to unzip the downloaded Firefox archive ${downloadedFile.path}.\n' 'The unzip process exited with code ${unzipResult.exitCode}.'); } } /// Mounts the dmg file using hdiutil, copies content of the volume to /// target path and then unmounts dmg ready for deletion. Future _mountDmgAndCopy(io.File dmgFile) async { String volumeName = await _hdiUtilMount(dmgFile); final String sourcePath = '$volumeName/Firefox.app'; final String targetPath = path.dirname(dmgFile.path); try { io.ProcessResult installResult = await io.Process.run('cp', [ '-r', sourcePath, targetPath, ]); if (installResult.exitCode != 0) { throw BrowserInstallerException( 'Failed to copy Firefox disk image contents from ' '$sourcePath to $targetPath.\n' 'Exit code ${installResult.exitCode}.\n' '${installResult.stderr}'); } } finally { await _hdiUtilUnmount(volumeName); } } Future _hdiUtilMount(io.File dmgFile) async { io.ProcessResult mountResult = await io.Process.run('hdiutil', [ 'attach', '-readonly', '${dmgFile.path}', ]); if (mountResult.exitCode != 0) { throw BrowserInstallerException( 'Failed to mount Firefox disk image ${dmgFile.path}.\n' 'Exit code ${mountResult.exitCode}.\n${mountResult.stderr}'); } List processOutput = (mountResult.stdout as String).split('\n'); String volumePath = _volumeFromMountResult(processOutput); if (volumePath == null) { throw BrowserInstallerException( 'Failed to parse mount dmg result ${processOutput.join('\n')}.\n' 'Expected /Volumes/{volume name}'); } return volumePath; } // Parses volume from mount result. // Output is of form: {devicename} /Volumes/{name}. String _volumeFromMountResult(List lines) { for (String line in lines) { int pos = line.indexOf('/Volumes'); if (pos != -1) { return line.substring(pos); } } return null; } Future _hdiUtilUnmount(String volumeName) async { io.ProcessResult unmountResult = await io.Process.run('hdiutil', [ 'unmount', '$volumeName', ]); if (unmountResult.exitCode != 0) { throw BrowserInstallerException( 'Failed to unmount Firefox disk image ${volumeName}.\n' 'Exit code ${unmountResult.exitCode}. ${unmountResult.stderr}'); } } void close() { client.close(); } } Future _findSystemFirefoxExecutable() async { final io.ProcessResult which = await io.Process.run('which', ['firefox']); bool found = which.exitCode != 0; const String fireFoxDefaultInstallPath = '/Applications/Firefox.app/Contents/MacOS/firefox'; if (!found) { if (io.Platform.isMacOS && io.File(fireFoxDefaultInstallPath).existsSync()) { return Future.value(fireFoxDefaultInstallPath); } throw BrowserInstallerException( 'Failed to locate system Firefox installation.'); } return which.stdout as String; } /// Fetches the latest available Firefox build version on Linux. Future fetchLatestFirefoxVersionLinux() async { final RegExp forFirefoxVersion = RegExp("firefox-[0-9.]\+[0-9]"); final io.HttpClientRequest request = await io.HttpClient() .getUrl(Uri.parse(PlatformBinding.instance.getFirefoxLatestVersionUrl())); request.followRedirects = false; // We will parse the HttpHeaders to find the redirect location. final io.HttpClientResponse response = await request.close(); final String location = response.headers.value('location'); final String version = forFirefoxVersion.stringMatch(location); return version.substring(version.lastIndexOf('-') + 1); } /// Fetches the latest available Firefox build version on Mac OS. Future fetchLatestFirefoxVersionMacOS() async { final RegExp forFirefoxVersion = RegExp("firefox\/releases\/[0-9.]\+[0-9]"); final io.HttpClientRequest request = await io.HttpClient() .getUrl(Uri.parse(PlatformBinding.instance.getFirefoxLatestVersionUrl())); request.followRedirects = false; // We will parse the HttpHeaders to find the redirect location. final io.HttpClientResponse response = await request.close(); final String location = response.headers.value('location'); final String version = forFirefoxVersion.stringMatch(location); return version.substring(version.lastIndexOf('/') + 1); } Future getInstaller({String requestedVersion = 'latest'}) async { FirefoxInstaller installer; try { installer = requestedVersion == 'latest' ? await FirefoxInstaller.latest() : FirefoxInstaller(version: requestedVersion); if (installer.isInstalled) { print('Installation was skipped because Firefox version ' '${installer.version} is already installed.'); } else { print('Installing Firefox version: ${installer.version}'); await installer.install(); final BrowserInstallation installation = installer.getInstallation(); print( 'Installations complete. To launch it run ${installation.executable}'); } return installer.getInstallation(); } finally { installer?.close(); } }