| // 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. |
| |
| import 'dart:io' as io; |
| |
| import 'package:http/http.dart'; |
| import 'package:path/path.dart' as path; |
| |
| import 'common.dart'; |
| import 'environment.dart'; |
| import 'exceptions.dart'; |
| |
| const String _firefoxExecutableVar = 'FIREFOX_EXECUTABLE'; |
| |
| /// 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 "package_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<BrowserInstallation> 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; |
| |
| // When running on LUCI, if we specify the "firefox" dependency, then the |
| // bot will download Firefox from CIPD and place it in a cache and set the |
| // environment variable FIREFOX_EXECUTABLE. |
| if (io.Platform.environment.containsKey(_firefoxExecutableVar)) { |
| infoLog.writeln('Using Firefox from $_firefoxExecutableVar variable: ' |
| '${io.Platform.environment[_firefoxExecutableVar]}'); |
| return BrowserInstallation( |
| version: 'cipd', |
| executable: io.Platform.environment[_firefoxExecutableVar]!, |
| ); |
| } |
| |
| 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, |
| ); |
| } |
| |
| FirefoxInstaller._({ |
| required this.version, |
| required this.firefoxInstallationDir, |
| required this.versionDir, |
| }); |
| |
| static Future<FirefoxInstaller> latest() async { |
| final String latestVersion = io.Platform.isLinux |
| ? await fetchLatestFirefoxVersionLinux() |
| : await fetchLatestFirefoxVersionMacOS(); |
| return FirefoxInstaller(version: latestVersion); |
| } |
| |
| /// 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<void> 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<io.File> _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))); |
| final 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<void> _uncompress(io.File downloadedFile) async { |
| final io.ProcessResult unzipResult = await io.Process.run('tar', <String>[ |
| '-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<void> _mountDmgAndCopy(io.File dmgFile) async { |
| final String volumeName = await _hdiUtilMount(dmgFile); |
| |
| final String sourcePath = '$volumeName/Firefox.app'; |
| final String targetPath = path.dirname(dmgFile.path); |
| try { |
| final io.ProcessResult installResult = await io.Process.run('cp', <String>[ |
| '-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<String> _hdiUtilMount(io.File dmgFile) async { |
| final io.ProcessResult mountResult = await io.Process.run('hdiutil', <String>[ |
| '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}'); |
| } |
| |
| final List<String> processOutput = (mountResult.stdout as String).split('\n'); |
| final 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<String> lines) { |
| for (final String line in lines) { |
| final int pos = line.indexOf('/Volumes'); |
| if (pos != -1) { |
| return line.substring(pos); |
| } |
| } |
| return null; |
| } |
| |
| Future<void> _hdiUtilUnmount(String volumeName) async { |
| final io.ProcessResult unmountResult = await io.Process.run('hdiutil', <String>[ |
| '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<String> _findSystemFirefoxExecutable() async { |
| final io.ProcessResult which = |
| await io.Process.run('which', <String>['firefox']); |
| final 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<String>.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<String> 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<String> 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<BrowserInstallation> 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(); |
| } |
| } |