import 'dart:async';
import 'dart:convert';
import 'dart:io' hide Platform;
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:crypto/crypto.dart';
import 'package:crypto/src/digest_sink.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'package:platform/platform.dart' show Platform, LocalPlatform;
import 'package:process/process.dart';
const String chromiumRepo = 'https://chromium.googlesource.com/external/github.com/flutter/flutter';
const String githubRepo = 'https://github.com/flutter/flutter.git';
const String mingitForWindowsUrl = 'https://storage.googleapis.com/flutter_infra/mingit/'
'603511c649b00bbef0a6122a827ac419b656bc19/mingit.zip';
const String gsBase = 'gs://flutter_infra';
const String releaseFolder = '/releases';
const String gsReleaseFolder = '$gsBase$releaseFolder';
const String baseUrl = 'https://storage.googleapis.com/flutter_infra';
class PreparePackageException implements Exception {
PreparePackageException(this.message, [this.result]);
final String message;
final ProcessResult result;
int get exitCode => result?.exitCode ?? -1;
@override
String toString() {
String output = runtimeType.toString();
if (message != null) {
output += ': $message';
}
final String stderr = result?.stderr ?? '';
if (stderr.isNotEmpty) {
output += ':\n$stderr';
}
return output;
}
}
enum Branch { dev, beta, stable }
String getBranchName(Branch branch) {
switch (branch) {
case Branch.beta:
return 'beta';
case Branch.dev:
return 'dev';
case Branch.stable:
return 'stable';
}
return null;
}
Branch fromBranchName(String name) {
switch (name) {
case 'beta':
return Branch.beta;
case 'dev':
return Branch.dev;
case 'stable':
return Branch.stable;
default:
throw ArgumentError('Invalid branch name.');
}
}
class ProcessRunner {
ProcessRunner({
ProcessManager processManager,
this.subprocessOutput = true,
this.defaultWorkingDirectory,
this.platform = const LocalPlatform(),
}) : processManager = processManager ?? const LocalProcessManager() {
environment = Map<String, String>.from(platform.environment);
}
final Platform platform;
final bool subprocessOutput;
final ProcessManager processManager;
final Directory defaultWorkingDirectory;
Map<String, String> environment;
Future<String> runProcess(
List<String> commandLine, {
Directory workingDirectory,
bool failOk = false,
}) async {
workingDirectory ??= defaultWorkingDirectory ?? Directory.current;
if (subprocessOutput) {
stderr.write('Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n');
}
final List<int> output = <int>[];
final Completer<void> stdoutComplete = Completer<void>();
final Completer<void> stderrComplete = Completer<void>();
Process process;
Future<int> 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<int> event) {
output.addAll(event);
if (subprocessOutput) {
stdout.add(event);
}
},
onDone: () async => stdoutComplete.complete(),
);
if (subprocessOutput) {
process.stderr.listen(
(List<int> 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 PreparePackageException(message);
} on ArgumentError catch (e) {
final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
'failed with:\n${e.toString()}';
throw PreparePackageException(message);
}
final int exitCode = await allComplete();
if (exitCode != 0 && !failOk) {
final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} failed';
throw PreparePackageException(
message,
ProcessResult(0, exitCode, null, 'returned $exitCode'),
);
}
return utf8.decoder.convert(output).trim();
}
}
typedef HttpReader = Future<Uint8List> Function(Uri url, {Map<String, String> headers});
class ArchiveCreator {
ArchiveCreator(
this.tempDir,
this.outputDir,
this.revision,
this.branch, {
this.strict = true,
ProcessManager processManager,
bool subprocessOutput = true,
this.platform = const LocalPlatform(),
HttpReader httpReader,
}) : assert(revision.length == 40),
flutterRoot = Directory(path.join(tempDir.path, 'flutter')),
httpReader = httpReader ?? http.readBytes,
_processRunner = ProcessRunner(
processManager: processManager,
subprocessOutput: subprocessOutput,
platform: platform,
) {
_flutter = path.join(
flutterRoot.absolute.path,
'bin',
'flutter',
);
_processRunner.environment['PUB_CACHE'] = path.join(flutterRoot.absolute.path, '.pub-cache');
}
final Platform platform;
final Branch branch;
final String revision;
final Directory flutterRoot;
final Directory tempDir;
final Directory outputDir;
final bool strict;
final Uri _minGitUri = Uri.parse(mingitForWindowsUrl);
final ProcessRunner _processRunner;
final HttpReader httpReader;
File _outputFile;
String _version;
String _flutter;
String get branchName => getBranchName(branch);
String get _archiveName {
final String os = platform.operatingSystem.toLowerCase();
final String suffix = platform.isLinux ? 'tar.xz' : 'zip';
return 'flutter_${os}_$_version-$branchName.$suffix';
}
Future<String> initializeRepo() async {
await _checkoutFlutter();
_version = await _getVersion();
return _version;
}
Future<File> createArchive() async {
assert(_version != null, 'Must run initializeRepo before createArchive');
_outputFile = File(path.join(outputDir.absolute.path, _archiveName));
await _installMinGitIfNeeded();
await _populateCaches();
await _archiveFiles(_outputFile);
return _outputFile;
}
Future<String> _getVersion() async {
if (strict) {
try {
return _runGit(<String>['describe', '--tags', '--exact-match', revision]);
} on PreparePackageException catch (exception) {
throw PreparePackageException(
'Git error when checking for a version tag attached to revision $revision.\n'
'Perhaps there is no tag at that revision?:\n'
'$exception'
);
}
} else {
return _runGit(<String>['describe', '--tags', '--abbrev=0', revision]);
}
}
Future<void> _checkoutFlutter() async {
await _runGit(<String>['clone', '-b', branchName, chromiumRepo], workingDirectory: tempDir);
await _runGit(<String>['reset', '--hard', revision]);
await _runGit(<String>['remote', 'set-url', 'origin', githubRepo]);
}
Future<void> _installMinGitIfNeeded() async {
if (!platform.isWindows) {
return;
}
final Uint8List data = await httpReader(_minGitUri);
final File gitFile = File(path.join(tempDir.absolute.path, 'mingit.zip'));
await gitFile.writeAsBytes(data, flush: true);
final Directory minGitPath = Directory(path.join(flutterRoot.absolute.path, 'bin', 'mingit'));
await minGitPath.create(recursive: true);
await _unzipArchive(gitFile, workingDirectory: minGitPath);
}
Future<void> _populateCaches() async {
await _runFlutter(<String>['doctor']);
await _runFlutter(<String>['update-packages']);
await _runFlutter(<String>['precache']);
await _runFlutter(<String>['ide-config']);
for (String template in <String>['app', 'package', 'plugin']) {
final String createName = path.join(tempDir.path, 'create_$template');
await _runFlutter(
<String>['create', '--template=$template', createName],
workingDirectory: tempDir,
);
}
await _runGit(<String>['clean', '-f', '-X', '**/.packages']);
}
Future<void> _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<String> _runFlutter(List<String> args, {Directory workingDirectory}) {
return _processRunner.runProcess(
<String>[_flutter, ...args],
workingDirectory: workingDirectory ?? flutterRoot,
);
}
Future<String> _runGit(List<String> args, {Directory workingDirectory}) {
return _processRunner.runProcess(
<String>['git', ...args],
workingDirectory: workingDirectory ?? flutterRoot,
);
}
Future<String> _unzipArchive(File archive, {Directory workingDirectory}) {
workingDirectory ??= Directory(path.dirname(archive.absolute.path));
List<String> commandLine;
if (platform.isWindows) {
commandLine = <String>[
'7za',
'x',
archive.absolute.path,
];
} else {
commandLine = <String>[
'unzip',
archive.absolute.path,
];
}
return _processRunner.runProcess(commandLine, workingDirectory: workingDirectory);
}
Future<String> _createZipArchive(File output, Directory source) {
List<String> commandLine;
if (platform.isWindows) {
commandLine = <String>[
'7za',
'a',
'-tzip',
'-mx=9',
output.absolute.path,
path.basename(source.path),
];
} else {
commandLine = <String>[
'zip',
'-r',
'-9',
output.absolute.path,
path.basename(source.path),
];
}
return _processRunner.runProcess(
commandLine,
workingDirectory: Directory(path.dirname(source.absolute.path)),
);
}
Future<String> _createTarArchive(File output, Directory source) {
return _processRunner.runProcess(<String>[
'tar',
'cJf',
output.absolute.path,
path.basename(source.absolute.path),
], workingDirectory: Directory(path.dirname(source.absolute.path)));
}
}
class ArchivePublisher {
ArchivePublisher(
this.tempDir,
this.revision,
this.branch,
this.version,
this.outputFile, {
ProcessManager processManager,
bool subprocessOutput = true,
this.platform = const LocalPlatform(),
}) : assert(revision.length == 40),
platformName = platform.operatingSystem.toLowerCase(),
metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}',
_processRunner = ProcessRunner(
processManager: processManager,
subprocessOutput: subprocessOutput,
);
final Platform platform;
final String platformName;
final String metadataGsPath;
final Branch branch;
final String revision;
final String version;
final Directory tempDir;
final File outputFile;
final ProcessRunner _processRunner;
String get branchName => getBranchName(branch);
String get destinationArchivePath => '$branchName/$platformName/${path.basename(outputFile.path)}';
static String getMetadataFilename(Platform platform) => 'releases_${platform.operatingSystem.toLowerCase()}.json';
Future<String> _getChecksum(File archiveFile) async {
final DigestSink digestSink = DigestSink();
final ByteConversionSink sink = sha256.startChunkedConversion(digestSink);
final Stream<List<int>> stream = archiveFile.openRead();
await stream.forEach((List<int> chunk) {
sink.add(chunk);
});
sink.close();
return digestSink.value.toString();
}
Future<void> publishArchive() async {
final String destGsPath = '$gsReleaseFolder/$destinationArchivePath';
await _cloudCopy(outputFile.absolute.path, destGsPath);
assert(tempDir.existsSync());
await _updateMetadata();
}
Future<Map<String, dynamic>> _addRelease(Map<String, dynamic> jsonData) async {
jsonData['base_url'] = '$baseUrl$releaseFolder';
if (!jsonData.containsKey('current_release')) {
jsonData['current_release'] = <String, String>{};
}
jsonData['current_release'][branchName] = revision;
if (!jsonData.containsKey('releases')) {
jsonData['releases'] = <Map<String, dynamic>>[];
}
final Map<String, dynamic> newEntry = <String, dynamic>{};
newEntry['hash'] = revision;
newEntry['channel'] = branchName;
newEntry['version'] = version;
newEntry['release_date'] = DateTime.now().toUtc().toIso8601String();
newEntry['archive'] = destinationArchivePath;
newEntry['sha256'] = await _getChecksum(outputFile);
final List<dynamic> releases = jsonData['releases'];
final List<Map<String, dynamic>> prunedReleases = <Map<String, dynamic>>[];
for (Map<String, dynamic> entry in releases) {
if (entry['hash'] != newEntry['hash'] || entry['channel'] != newEntry['channel']) {
prunedReleases.add(entry);
}
}
prunedReleases.add(newEntry);
prunedReleases.sort((Map<String, dynamic> a, Map<String, dynamic> b) {
final DateTime aDate = DateTime.parse(a['release_date']);
final DateTime bDate = DateTime.parse(b['release_date']);
return bDate.compareTo(aDate);
});
jsonData['releases'] = prunedReleases;
return jsonData;
}
Future<void> _updateMetadata() async {
final File metadataFile = File(
path.join(tempDir.absolute.path, getMetadataFilename(platform)),
);
await _runGsUtil(<String>['cp', metadataGsPath, metadataFile.absolute.path]);
final String currentMetadata = metadataFile.readAsStringSync();
if (currentMetadata.isEmpty) {
throw PreparePackageException('Empty metadata received from server');
}
Map<String, dynamic> jsonData;
try {
jsonData = json.decode(currentMetadata);
} on FormatException catch (e) {
throw PreparePackageException('Unable to parse JSON metadata received from cloud: $e');
}
jsonData = await _addRelease(jsonData);
const JsonEncoder encoder = JsonEncoder.withIndent(' ');
metadataFile.writeAsStringSync(encoder.convert(jsonData));
await _cloudCopy(metadataFile.absolute.path, metadataGsPath);
}
Future<String> _runGsUtil(
List<String> args, {
Directory workingDirectory,
bool failOk = false,
}) async {
if (platform.isWindows) {
return _processRunner.runProcess(
<String>['python', path.join(platform.environment['DEPOT_TOOLS'], 'gsutil.py'), '--', ...args],
workingDirectory: workingDirectory,
failOk: failOk,
);
}
return _processRunner.runProcess(
<String>['gsutil.py', '--', ...args],
workingDirectory: workingDirectory,
failOk: failOk,
);
}
Future<String> _cloudCopy(String src, String dest) async {
await _runGsUtil(<String>['rm', dest], failOk: true);
String mimeType;
if (dest.endsWith('.tar.xz')) {
mimeType = 'application/x-gtar';
}
if (dest.endsWith('.zip')) {
mimeType = 'application/zip';
}
if (dest.endsWith('.json')) {
mimeType = 'application/json';
}
return await _runGsUtil(<String>[
if (mimeType != null) ...<String>['-h', 'Content-Type:$mimeType'],
'cp',
src,
dest,
]);
}
}
Future<void> main(List<String> rawArguments) async {
final ArgParser argParser = 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. If a temp_dir is not '
'specified, then the default temp_dir will be created, used, and '
'removed automatically.',
);
argParser.addOption('revision',
defaultsTo: null,
help: 'The Flutter git repo revision to build the '
'archive with. Must be the full 40-character hash. Required.');
argParser.addOption(
'branch',
defaultsTo: null,
allowed: Branch.values.map<String>((Branch branch) => getBranchName(branch)),
help: 'The Flutter branch to build the archive with. Required.',
);
argParser.addOption(
'output',
defaultsTo: null,
help: 'The path to the directory where the output archive should be '
'written. 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.',
);
argParser.addFlag(
'publish',
defaultsTo: false,
help: 'If set, will publish the archive to Google Cloud Storage upon '
'successful creation of the archive. Will publish under this '
'directory: $baseUrl$releaseFolder',
);
argParser.addFlag(
'help',
defaultsTo: false,
negatable: false,
help: 'Print help for this command.',
);
final ArgResults parsedArguments = argParser.parse(rawArguments);
if (parsedArguments['help']) {
print(argParser.usage);
exit(0);
}
void errorExit(String message, {int exitCode = -1}) {
stderr.write('Error: $message\n\n');
stderr.write('${argParser.usage}\n');
exit(exitCode);
}
final String revision = parsedArguments['revision'];
if (revision.isEmpty) {
errorExit('Invalid argument: --revision must be specified.');
}
if (revision.length != 40) {
errorExit('Invalid argument: --revision must be the entire hash, not just a prefix.');
}
if (parsedArguments['branch'].isEmpty) {
errorExit('Invalid argument: --branch must be specified.');
}
Directory tempDir;
bool removeTempDir = false;
if (parsedArguments['temp_dir'] == null || parsedArguments['temp_dir'].isEmpty) {
tempDir = Directory.systemTemp.createTempSync('flutter_package.');
removeTempDir = true;
} else {
tempDir = Directory(parsedArguments['temp_dir']);
if (!tempDir.existsSync()) {
errorExit("Temporary directory ${parsedArguments['temp_dir']} doesn't exist.");
}
}
Directory outputDir;
if (parsedArguments['output'] == null) {
outputDir = tempDir;
} else {
outputDir = Directory(parsedArguments['output']);
if (!outputDir.existsSync()) {
outputDir.createSync(recursive: true);
}
}
final Branch branch = fromBranchName(parsedArguments['branch']);
final ArchiveCreator creator = ArchiveCreator(tempDir, outputDir, revision, branch, strict: parsedArguments['publish']);
int exitCode = 0;
String message;
try {
final String version = await creator.initializeRepo();
final File outputFile = await creator.createArchive();
if (parsedArguments['publish']) {
final ArchivePublisher publisher = ArchivePublisher(
tempDir,
revision,
branch,
version,
outputFile,
);
await publisher.publishArchive();
}
} on PreparePackageException catch (e) {
exitCode = e.exitCode;
message = e.message;
} catch (e) {
exitCode = -1;
message = e.toString();
} finally {
if (removeTempDir) {
tempDir.deleteSync(recursive: true);
}
if (exitCode != 0) {
errorExit(message, exitCode: exitCode);
}
exit(0);
}
}