// Copyright 2017 The Chromium 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:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:args/args.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as path; import 'package:process/process.dart'; const String CHROMIUM_REPO = 'https://chromium.googlesource.com/external/github.com/flutter/flutter'; const String GITHUB_REPO = 'https://github.com/flutter/flutter.git'; const String MINGIT_FOR_WINDOWS_URL = 'https://storage.googleapis.com/flutter_infra/mingit/' '603511c649b00bbef0a6122a827ac419b656bc19/mingit.zip'; /// Error class for when a process fails to run, so we can catch /// it and provide something more readable than a stack trace. class ProcessFailedException extends Error { ProcessFailedException([this.message, this.exitCode]); String message = ''; int exitCode = 0; @override String toString() => message; } /// Creates a pre-populated Flutter archive from a git repo. class ArchiveCreator { /// [_tempDir] is the directory to use for creating the archive. The script /// will place several GiB of data there, so it should have available space. /// /// The processManager argument is used to inject a mock of [ProcessManager] for /// testing purposes. /// /// If subprocessOutput is true, then output from processes invoked during /// archive creation is echoed to stderr and stdout. ArchiveCreator(this._tempDir, {ProcessManager processManager, bool subprocessOutput: true}) : _flutterRoot = new Directory(path.join(_tempDir.path, 'flutter')), _processManager = processManager ?? const LocalProcessManager(), _subprocessOutput = subprocessOutput { _flutter = path.join( _flutterRoot.absolute.path, 'bin', 'flutter', ); _environment = new Map.from(Platform.environment); _environment['PUB_CACHE'] = path.join(_flutterRoot.absolute.path, '.pub-cache'); } final Directory _flutterRoot; final Directory _tempDir; final bool _subprocessOutput; final ProcessManager _processManager; String _flutter; final Uri _minGitUri = Uri.parse(MINGIT_FOR_WINDOWS_URL); Map _environment; /// Returns a default archive name when given a Git revision. /// Used when an output filename is not given. static String defaultArchiveName(String revision) { final String os = Platform.operatingSystem.toLowerCase(); final String id = revision.length > 10 ? revision.substring(0, 10) : revision; final String suffix = Platform.isWindows ? 'zip' : 'tar.xz'; return 'flutter_${os}_$id.$suffix'; } /// Performs all of the steps needed to create an archive. Future createArchive(String revision, File outputFile) async { await _checkoutFlutter(revision); await _installMinGitIfNeeded(); await _populateCaches(); await _archiveFiles(outputFile); return outputFile; } /// Clone the Flutter repo and make sure that the git environment is sane /// for when the user will unpack it. Future _checkoutFlutter(String revision) async { // We want the user to start out the in the 'master' branch instead of a // detached head. To do that, we need to make sure master points at the // desired revision. await _runGit(['clone', '-b', 'master', CHROMIUM_REPO], workingDirectory: _tempDir); await _runGit(['reset', '--hard', revision]); // Make the origin point to github instead of the chromium mirror. await _runGit(['remote', 'remove', 'origin']); await _runGit(['remote', 'add', 'origin', GITHUB_REPO]); } /// Retrieve the MinGit executable from storage and unpack it. Future _installMinGitIfNeeded() async { if (!Platform.isWindows) { return; } final Uint8List data = await http.readBytes(_minGitUri); final File gitFile = new File(path.join(_tempDir.path, 'mingit.zip')); await gitFile.writeAsBytes(data, flush: true); final Directory minGitPath = new Directory(path.join(_flutterRoot.path, 'bin', 'mingit')); await minGitPath.create(recursive: true); await _unzipArchive(gitFile, currentDirectory: minGitPath); } /// Prepare the archive repo so that it has all of the caches warmed up and /// is configured for the user to begin working. Future _populateCaches() async { await _runFlutter(['doctor']); await _runFlutter(['update-packages']); await _runFlutter(['precache']); await _runFlutter(['ide-config']); // Create each of the templates, since they will call 'pub get' on // themselves when created, and this will warm the cache with their // dependencies too. for (String template in ['app', 'package', 'plugin']) { final String createName = path.join(_tempDir.path, 'create_$template'); await _runFlutter( ['create', '--template=$template', createName], ); } // Yes, we could just skip all .packages files when constructing // the archive, but some are checked in, and we don't want to skip // those. await _runGit(['clean', '-f', '-X', '**/.packages']); } /// Write the archive to the given output file. Future _archiveFiles(File outputFile) async { if (outputFile.path.toLowerCase().endsWith('.zip')) { await _createZipArchive(outputFile, _flutterRoot); } else if (outputFile.path.toLowerCase().endsWith('.tar.xz')) { await _createTarArchive(outputFile, _flutterRoot); } } Future _runFlutter(List args) => _runProcess([_flutter]..addAll(args)); Future _runGit(List args, {Directory workingDirectory}) { return _runProcess(['git']..addAll(args), workingDirectory: workingDirectory); } /// Unpacks the given zip file into the currentDirectory (if set), or the /// same directory as the archive. /// /// May only be run on Windows (since 7Zip is not available on other platforms). Future _unzipArchive(File archive, {Directory currentDirectory}) { assert(Platform.isWindows); // 7Zip is only available on Windows. currentDirectory ??= new Directory(path.dirname(archive.absolute.path)); final List commandLine = ['7za', 'x', archive.absolute.path]; return _runProcess(commandLine, workingDirectory: currentDirectory); } /// Create a zip archive from the directory source. /// /// May only be run on Windows (since 7Zip is not available on other platforms). Future _createZipArchive(File output, Directory source) { assert(Platform.isWindows); // 7Zip is only available on Windows. final List commandLine = [ '7za', 'a', '-tzip', '-mx=9', output.absolute.path, path.basename(source.absolute.path), ]; return _runProcess(commandLine, workingDirectory: new Directory(path.dirname(source.absolute.path))); } /// Create a tar archive from the directory source. Future _createTarArchive(File output, Directory source) { return _runProcess([ 'tar', 'cJf', output.absolute.path, path.basename(source.absolute.path), ], workingDirectory: new Directory(path.dirname(source.absolute.path))); } /// Run the command and arguments in commandLine as a sub-process from /// workingDirectory if set, or the current directory if not. Future _runProcess(List commandLine, {Directory workingDirectory}) async { workingDirectory ??= _flutterRoot; if (_subprocessOutput) { stderr.write('Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n'); } final List output = []; final Completer stdoutComplete = new Completer(); final Completer stderrComplete = new Completer(); Process process; Future allComplete() async { await stderrComplete.future; await stdoutComplete.future; return process.exitCode; } try { process = await _processManager.start( commandLine, workingDirectory: workingDirectory.absolute.path, environment: _environment, ); process.stdout.listen( (List event) { output.addAll(event); if (_subprocessOutput) { stdout.add(event); } }, onDone: () async => stdoutComplete.complete(), ); if (_subprocessOutput) { process.stderr.listen( (List event) { stderr.add(event); }, onDone: () async => stderrComplete.complete(), ); } else { stderrComplete.complete(); } } on ProcessException catch (e) { final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' 'failed with:\n${e.toString()}'; throw new ProcessFailedException(message, -1); } final int exitCode = await allComplete(); if (exitCode != 0) { final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' 'failed with $exitCode.'; throw new ProcessFailedException(message, exitCode); } return UTF8.decoder.convert(output).trim(); } } /// Prepares a flutter git repo to be packaged up for distribution. /// It mainly serves to populate the .pub-cache with any appropriate Dart /// packages, and the flutter cache in bin/cache with the appropriate /// dependencies and snapshots. /// /// Note that archives contain the executables and customizations for the /// platform that they are created on. Future main(List argList) async { final ArgParser argParser = new ArgParser(); argParser.addOption( 'temp_dir', defaultsTo: null, help: 'A location where temporary files may be written. Defaults to a ' 'directory in the system temp folder. Will write a few GiB of data, ' 'so it should have sufficient free space.', ); argParser.addOption( 'revision', defaultsTo: 'master', help: 'The Flutter revision to build the archive with. Defaults to the ' "master branch's HEAD revision.", ); argParser.addOption( 'output', defaultsTo: null, help: 'The path to the file where the output archive should be ' 'written. The output file must end in ".tar.xz" on Linux and Mac, ' 'and ".zip" on Windows. If --output is not specified, the archive will ' "be written to the current directory. If the output directory doesn't " 'exist, it, and the path to it, will be created.', ); final ArgResults args = argParser.parse(argList); void errorExit(String message, {int exitCode = -1}) { stderr.write('Error: $message\n\n'); stderr.write('${argParser.usage}\n'); exit(exitCode); } if (args['revision'].isEmpty) { errorExit('Invalid argument: --revision must be specified.'); } Directory tempDir; bool removeTempDir = false; if (args['temp_dir'] == null || args['temp_dir'].isEmpty) { tempDir = Directory.systemTemp.createTempSync('flutter_'); removeTempDir = true; } else { tempDir = new Directory(args['temp_dir']); if (!tempDir.existsSync()) { errorExit("Temporary directory ${args['temp_dir']} doesn't exist."); } } final String output = (args['output'] == null || args['output'].isEmpty) ? path.join(path.current, ArchiveCreator.defaultArchiveName(args['revision'])) : args['output']; /// Sanity check the output filename. final String outputFilename = path.basename(output); if (Platform.isWindows) { if (!outputFilename.endsWith('.zip')) { errorExit('The argument to --output must end in .zip on Windows.'); } } else { if (!outputFilename.endsWith('.tar.xz')) { errorExit('The argument to --output must end in .tar.xz on Linux and Mac.'); } } final Directory outputDirectory = new Directory(path.dirname(output)); if (!outputDirectory.existsSync()) { outputDirectory.createSync(recursive: true); } final File outputFile = new File(path.join(outputDirectory.absolute.path, outputFilename)); final ArchiveCreator preparer = new ArchiveCreator(tempDir); int exitCode = 0; String message; try { await preparer.createArchive(args['revision'], outputFile); } on ProcessFailedException catch (e) { exitCode = e.exitCode; message = e.message; } finally { if (removeTempDir) { tempDir.deleteSync(recursive: true); } if (exitCode != 0) { errorExit(message, exitCode: exitCode); } exit(0); } }