Commit 93126a85 authored by Yegor's avatar Yegor Committed by GitHub

warn about outdated Flutter installations (#9163)

parent eb9046b1
......@@ -74,6 +74,16 @@ class Cache {
_lock = null;
}
/// Checks if the current process owns the lock for the cache directory at
/// this very moment; throws a [StateError] if it doesn't.
static void checkLockAcquired() {
if (_lockEnabled && _lock == null) {
throw new StateError(
'The current process does not own the lock for the cache directory. This is a bug in Flutter CLI tools.',
);
}
}
static String _dartSdkVersion;
static String get dartSdkVersion {
......
......@@ -236,6 +236,7 @@ class FlutterCommandRunner extends CommandRunner<Null> {
flutterUsage.suppressAnalytics = true;
_checkFlutterCopy();
await FlutterVersion.instance.checkFlutterVersionFreshness();
if (globalResults.wasParsed('packages'))
PackageMap.globalPackagesPath = fs.path.normalize(fs.path.absolute(globalResults['packages']));
......
......@@ -2,11 +2,19 @@
// 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 'package:meta/meta.dart';
import 'package:quiver/time.dart';
import 'base/common.dart';
import 'base/context.dart';
import 'base/io.dart';
import 'base/process.dart';
import 'base/process_manager.dart';
import 'cache.dart';
import 'globals.dart';
final Set<String> kKnownBranchNames = new Set<String>.from(<String>[
'master',
......@@ -17,7 +25,8 @@ final Set<String> kKnownBranchNames = new Set<String>.from(<String>[
]);
class FlutterVersion {
FlutterVersion._() {
@visibleForTesting
FlutterVersion(this._clock) {
_channel = _runGit('git rev-parse --abbrev-ref --symbolic @{u}');
final int slash = _channel.indexOf('/');
......@@ -33,6 +42,8 @@ class FlutterVersion {
_frameworkAge = _runGit('git log -n 1 --pretty=format:%ar');
}
final Clock _clock;
String _repositoryUrl;
String get repositoryUrl => _repositoryUrl;
......@@ -72,20 +83,64 @@ class FlutterVersion {
}
/// A date String describing the last framework commit.
static String get frameworkCommitDate {
return _runSync(<String>['git', 'log', '-n', '1', '--pretty=format:%ad', '--date=format:%Y-%m-%d %H:%M:%S'], Cache.flutterRoot);
String get frameworkCommitDate => _latestGitCommitDate();
static String _latestGitCommitDate([String branch]) {
final List<String> args = <String>['git', 'log'];
if (branch != null)
args.add(branch);
args.addAll(<String>['-n', '1', '--pretty=format:%ad', '--date=iso']);
return _runSync(args, lenient: false);
}
/// The name of the temporary git remote used to check for the latest
/// available Flutter framework version.
///
/// In the absence of bugs and crashes a Flutter developer should never see
/// this remote appear in their `git remote` list, but also if it happens to
/// persist we do the proper clean-up for extra robustness.
static const String _kVersionCheckRemote = '__flutter_version_check__';
/// The date of the latest framework commit in the remote repository.
///
/// Throws [ToolExit] if a git command fails, for example, when the remote git
/// repository is not reachable due to a network issue.
static Future<String> fetchRemoteFrameworkCommitDate() async {
await _removeVersionCheckRemoteIfExists();
try {
await _run(<String>[
'git',
'remote',
'add',
_kVersionCheckRemote,
'https://github.com/flutter/flutter.git',
]);
await _run(<String>['git', 'fetch', _kVersionCheckRemote, 'master']);
return _latestGitCommitDate('$_kVersionCheckRemote/master');
} finally {
await _removeVersionCheckRemoteIfExists();
}
}
static Future<Null> _removeVersionCheckRemoteIfExists() async {
final List<String> remotes = (await _run(<String>['git', 'remote']))
.split('\n')
.map((String name) => name.trim()) // to account for OS-specific line-breaks
.toList();
if (remotes.contains(_kVersionCheckRemote))
await _run(<String>['git', 'remote', 'remove', _kVersionCheckRemote]);
}
static FlutterVersion get instance => context.putIfAbsent(FlutterVersion, () => new FlutterVersion._());
static FlutterVersion get instance => context.putIfAbsent(FlutterVersion, () => new FlutterVersion(const Clock()));
/// Return a short string for the version (`alpha/a76bc8e22b`).
static String getVersionString({ bool whitelistBranchName: false }) {
final String cwd = Cache.flutterRoot;
String commit = _shortGitRevision(_runSync(<String>['git', 'rev-parse', 'HEAD'], cwd));
String commit = _shortGitRevision(_runSync(<String>['git', 'rev-parse', 'HEAD']));
commit = commit.isEmpty ? 'unknown' : commit;
String branch = _runSync(<String>['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd);
String branch = _runSync(<String>['git', 'rev-parse', '--abbrev-ref', 'HEAD']);
branch = branch == 'HEAD' ? 'master' : branch;
if (whitelistBranchName || branch.isEmpty) {
......@@ -96,11 +151,148 @@ class FlutterVersion {
return '$branch/$commit';
}
/// The amount of time we wait before pinging the server to check for the
/// availability of a newer version of Flutter.
@visibleForTesting
static const Duration kCheckAgeConsideredUpToDate = const Duration(days: 7);
/// We warn the user if the age of their Flutter installation is greater than
/// this duration.
@visibleForTesting
static final Duration kVersionAgeConsideredUpToDate = kCheckAgeConsideredUpToDate * 4;
/// The prefix of the stamp file where we cache Flutter version check data.
@visibleForTesting
static const String kFlutterVersionCheckStampFile = 'flutter_version_check';
/// Checks if the currently installed version of Flutter is up-to-date, and
/// warns the user if it isn't.
///
/// This function must run while [Cache.lock] is acquired because it reads and
/// writes shared cache files.
Future<Null> checkFlutterVersionFreshness() async {
final DateTime localFrameworkCommitDate = DateTime.parse(frameworkCommitDate);
final Duration frameworkAge = _clock.now().difference(localFrameworkCommitDate);
final bool installationSeemsOutdated = frameworkAge > kVersionAgeConsideredUpToDate;
Future<bool> newerFrameworkVersionAvailable() async {
final DateTime latestFlutterCommitDate = await _getLatestAvailableFlutterVersion();
if (latestFlutterCommitDate == null)
return false;
return latestFlutterCommitDate.isAfter(localFrameworkCommitDate);
}
if (installationSeemsOutdated && await newerFrameworkVersionAvailable())
printStatus(versionOutOfDateMessage(frameworkAge), emphasis: true);
}
@visibleForTesting
static String versionOutOfDateMessage(Duration frameworkAge) {
String warning = 'WARNING: your installation of Flutter is ${frameworkAge.inDays} days old.';
// Append enough spaces to match the message box width.
warning += ' ' * (74 - warning.length);
return '''
╔════════════════════════════════════════════════════════════════════════════╗
$warning
║ ║
║ To update to the latest version, run "flutter upgrade". ║
╚════════════════════════════════════════════════════════════════════════════╝
''';
}
/// Gets the release date of the latest available Flutter version.
///
/// This method sends a server request if it's been more than
/// [kCheckAgeConsideredUpToDate] since the last version check.
///
/// Returns `null` if the cached version is out-of-date or missing, and we are
/// unable to reach the server to get the latest version.
Future<DateTime> _getLatestAvailableFlutterVersion() async {
Cache.checkLockAcquired();
const JsonEncoder kPrettyJsonEncoder = const JsonEncoder.withIndent(' ');
final String versionCheckStamp = Cache.instance.getStampFor(kFlutterVersionCheckStampFile);
if (versionCheckStamp != null) {
final Map<String, String> data = JSON.decode(versionCheckStamp);
final DateTime lastTimeVersionWasChecked = DateTime.parse(data['lastTimeVersionWasChecked']);
final Duration timeSinceLastCheck = _clock.now().difference(lastTimeVersionWasChecked);
// Don't ping the server too often. Return cached value if it's fresh.
if (timeSinceLastCheck < kCheckAgeConsideredUpToDate)
return DateTime.parse(data['lastKnownRemoteVersion']);
}
// Cache is empty or it's been a while since the last server ping. Ping the server.
try {
final DateTime remoteFrameworkCommitDate = DateTime.parse(await FlutterVersion.fetchRemoteFrameworkCommitDate());
Cache.instance.setStampFor(kFlutterVersionCheckStampFile, kPrettyJsonEncoder.convert(<String, String>{
'lastTimeVersionWasChecked': '${_clock.now()}',
'lastKnownRemoteVersion': '$remoteFrameworkCommitDate',
}));
return remoteFrameworkCommitDate;
} on VersionCheckError catch (error) {
// This happens when any of the git commands fails, which can happen when
// there's no Internet connectivity. Remote version check is best effort
// only. We do not prevent the command from running when it fails.
printTrace('Failed to check Flutter version in the remote repository: $error');
return null;
}
}
}
String _runSync(List<String> command, String cwd) {
final ProcessResult results = processManager.runSync(command, workingDirectory: cwd);
return results.exitCode == 0 ? results.stdout.trim() : '';
/// Thrown when we fail to check Flutter version.
///
/// This can happen when we attempt to `git fetch` but there is no network, or
/// when the installation is not git-based (e.g. a user clones the repo but
/// then removes .git).
class VersionCheckError implements Exception {
VersionCheckError(this.message);
final String message;
@override
String toString() => '$VersionCheckError: $message';
}
/// Runs [command] and returns the standard output as a string.
///
/// If [lenient] is `true` and the command fails, returns an empty string.
/// Otherwise, throws a [ToolExit] exception.
String _runSync(List<String> command, {bool lenient: true}) {
final ProcessResult results = processManager.runSync(command, workingDirectory: Cache.flutterRoot);
if (results.exitCode == 0)
return results.stdout.trim();
if (!lenient) {
throw new VersionCheckError(
'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
'Standard error: ${results.stderr}'
);
}
return '';
}
/// Runs [command] in the root of the Flutter installation and returns the
/// standard output as a string.
///
/// If the command fails, throws a [ToolExit] exception.
Future<String> _run(List<String> command) async {
final ProcessResult results = await processManager.run(command, workingDirectory: Cache.flutterRoot);
if (results.exitCode == 0)
return results.stdout.trim();
throw new VersionCheckError(
'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
'Standard error: ${results.stderr}'
);
}
String _shortGitRevision(String revision) {
......
......@@ -10,6 +10,7 @@ environment:
dependencies:
archive: ^1.0.20
args: ^0.13.4
collection: '>=1.9.1 <2.0.0'
coverage: ^0.8.0
crypto: '>=1.1.1 <3.0.0'
file: 2.3.2
......@@ -23,6 +24,7 @@ dependencies:
package_config: '>=0.1.5 <2.0.0'
platform: 1.1.1
process: 2.0.1
quiver: ^0.24.0
stack_trace: ^1.4.0
usage: ^3.0.1
vm_service_client: '0.2.2+4'
......
......@@ -20,6 +20,7 @@ import 'package:flutter_tools/src/ios/mac.dart';
import 'package:flutter_tools/src/ios/simulators.dart';
import 'package:flutter_tools/src/run_hot.dart';
import 'package:flutter_tools/src/usage.dart';
import 'package:flutter_tools/src/version.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import 'package:test/test.dart';
......@@ -53,7 +54,8 @@ void _defaultInitializeContext(AppContext testContext) {
return mock;
})
..putIfAbsent(SimControl, () => new MockSimControl())
..putIfAbsent(Usage, () => new MockUsage());
..putIfAbsent(Usage, () => new MockUsage())
..putIfAbsent(FlutterVersion, () => new MockFlutterVersion());
}
void testUsingContext(String description, dynamic testMethod(), {
......@@ -230,3 +232,5 @@ class _MockUsageTimer implements UsageTimer {
@override
void finish() { }
}
class MockFlutterVersion extends Mock implements FlutterVersion {}
// 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 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:flutter_tools/src/version.dart';
import '../context.dart';
import '../common.dart';
import 'flutter_command_test.dart';
void main() {
group('FlutterCommandRunner', () {
testUsingContext('checks that Flutter installation is up-to-date', () async {
final MockFlutterVersion version = FlutterVersion.instance;
bool versionChecked = false;
when(version.checkFlutterVersionFreshness()).thenAnswer((_) async {
versionChecked = true;
});
await createTestCommandRunner(new DummyFlutterCommand(shouldUpdateCache: false))
.run(<String>['dummy']);
expect(versionChecked, isTrue);
});
});
}
// 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:convert';
import 'package:collection/collection.dart';
import 'package:meta/meta.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import 'package:quiver/time.dart';
import 'package:test/test.dart';
import 'package:flutter_tools/src/base/context.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/version.dart';
import 'context.dart';
const JsonEncoder _kPrettyJsonEncoder = const JsonEncoder.withIndent(' ');
final Clock _testClock = new Clock.fixed(new DateTime(2015, 1, 1));
final DateTime _upToDateVersion = _testClock.agoBy(FlutterVersion.kVersionAgeConsideredUpToDate ~/ 2);
final DateTime _outOfDateVersion = _testClock.agoBy(FlutterVersion.kVersionAgeConsideredUpToDate * 2);
final DateTime _stampUpToDate = _testClock.agoBy(FlutterVersion.kCheckAgeConsideredUpToDate ~/ 2);
final DateTime _stampOutOfDate = _testClock.agoBy(FlutterVersion.kCheckAgeConsideredUpToDate * 2);
const String _stampMissing = '____stamp_missing____';
void main() {
group('FlutterVersion', () {
setUpAll(() {
Cache.disableLocking();
});
testFlutterVersion('prints nothing when Flutter installation looks fresh', () async {
fakeData(localCommitDate: _upToDateVersion);
await FlutterVersion.instance.checkFlutterVersionFreshness();
_expectVersionMessage('');
});
testFlutterVersion('prints nothing when Flutter installation looks out-of-date by is actually up-to-date', () async {
final FlutterVersion version = FlutterVersion.instance;
fakeData(
localCommitDate: _outOfDateVersion,
versionCheckStamp: _testStamp(
lastTimeVersionWasChecked: _stampOutOfDate,
lastKnownRemoteVersion: _outOfDateVersion,
),
remoteCommitDate: _outOfDateVersion,
expectSetStamp: true,
);
await version.checkFlutterVersionFreshness();
_expectVersionMessage('');
});
testFlutterVersion('does not ping server when version stamp is up-to-date', () async {
final FlutterVersion version = FlutterVersion.instance;
fakeData(
localCommitDate: _outOfDateVersion,
versionCheckStamp: _testStamp(
lastTimeVersionWasChecked: _stampUpToDate,
lastKnownRemoteVersion: _upToDateVersion,
),
);
await version.checkFlutterVersionFreshness();
_expectVersionMessage(FlutterVersion.versionOutOfDateMessage(_testClock.now().difference(_outOfDateVersion)));
});
testFlutterVersion('pings server when version stamp is missing', () async {
final FlutterVersion version = FlutterVersion.instance;
fakeData(
localCommitDate: _outOfDateVersion,
versionCheckStamp: _stampMissing,
remoteCommitDate: _upToDateVersion,
expectSetStamp: true,
);
await version.checkFlutterVersionFreshness();
_expectVersionMessage(FlutterVersion.versionOutOfDateMessage(_testClock.now().difference(_outOfDateVersion)));
});
testFlutterVersion('pings server when version stamp is out-of-date', () async {
final FlutterVersion version = FlutterVersion.instance;
fakeData(
localCommitDate: _outOfDateVersion,
versionCheckStamp: _testStamp(
lastTimeVersionWasChecked: _stampOutOfDate,
lastKnownRemoteVersion: _testClock.ago(days: 2),
),
remoteCommitDate: _upToDateVersion,
expectSetStamp: true,
);
await version.checkFlutterVersionFreshness();
_expectVersionMessage(FlutterVersion.versionOutOfDateMessage(_testClock.now().difference(_outOfDateVersion)));
});
testFlutterVersion('ignores network issues', () async {
final FlutterVersion version = FlutterVersion.instance;
fakeData(
localCommitDate: _outOfDateVersion,
versionCheckStamp: _stampMissing,
errorOnFetch: true,
);
await version.checkFlutterVersionFreshness();
_expectVersionMessage('');
});
});
}
void _expectVersionMessage(String message) {
final BufferLogger logger = context[Logger];
expect(logger.statusText.trim(), message.trim());
}
String _testStamp({@required DateTime lastTimeVersionWasChecked, @required DateTime lastKnownRemoteVersion}) {
return _kPrettyJsonEncoder.convert(<String, String>{
'lastTimeVersionWasChecked': '$lastTimeVersionWasChecked',
'lastKnownRemoteVersion': '$lastKnownRemoteVersion',
});
}
void testFlutterVersion(String description, dynamic testMethod()) {
testUsingContext(
description,
testMethod,
overrides: <Type, Generator>{
FlutterVersion: () => new FlutterVersion(_testClock),
ProcessManager: () => new MockProcessManager(),
Cache: () => new MockCache(),
},
);
}
void fakeData({
@required DateTime localCommitDate,
DateTime remoteCommitDate,
String versionCheckStamp,
bool expectSetStamp: false,
bool errorOnFetch: false,
}) {
final MockProcessManager pm = context[ProcessManager];
final MockCache cache = context[Cache];
ProcessResult success(String standardOutput) {
return new ProcessResult(1, 0, standardOutput, '');
}
ProcessResult failure(int exitCode) {
return new ProcessResult(1, exitCode, '', 'error');
}
when(cache.getStampFor(any)).thenAnswer((Invocation invocation) {
expect(invocation.positionalArguments.single, FlutterVersion.kFlutterVersionCheckStampFile);
if (versionCheckStamp == _stampMissing) {
return null;
}
if (versionCheckStamp != null) {
return versionCheckStamp;
}
throw new StateError('Unexpected call to Cache.getStampFor(${invocation.positionalArguments}, ${invocation.namedArguments})');
});
when(cache.setStampFor(any, any)).thenAnswer((Invocation invocation) {
expect(invocation.positionalArguments.first, FlutterVersion.kFlutterVersionCheckStampFile);
if (expectSetStamp) {
expect(invocation.positionalArguments[1], _testStamp(
lastKnownRemoteVersion: remoteCommitDate,
lastTimeVersionWasChecked: _testClock.now(),
));
return null;
}
throw new StateError('Unexpected call to Cache.setStampFor(${invocation.positionalArguments}, ${invocation.namedArguments})');
});
final Answering syncAnswer = (Invocation invocation) {
bool argsAre(String a1, [String a2, String a3, String a4, String a5, String a6, String a7, String a8]) {
const ListEquality<String> equality = const ListEquality<String>();
final List<String> args = invocation.positionalArguments.single;
final List<String> expectedArgs =
<String>[a1, a2, a3, a4, a5, a6, a7, a8]
.where((String arg) => arg != null)
.toList();
return equality.equals(args, expectedArgs);
}
if (argsAre('git', 'log', '-n', '1', '--pretty=format:%ad', '--date=iso')) {
return success(localCommitDate.toString());
} else if (argsAre('git', 'remote')) {
return success('');
} else if (argsAre('git', 'remote', 'add', '__flutter_version_check__', 'https://github.com/flutter/flutter.git')) {
return success('');
} else if (argsAre('git', 'fetch', '__flutter_version_check__', 'master')) {
return errorOnFetch ? failure(128) : success('');
} else if (remoteCommitDate != null && argsAre('git', 'log', '__flutter_version_check__/master', '-n', '1', '--pretty=format:%ad', '--date=iso')) {
return success(remoteCommitDate.toString());
}
throw new StateError('Unexpected call to ProcessManager.run(${invocation.positionalArguments}, ${invocation.namedArguments})');
};
when(pm.runSync(any, workingDirectory: any)).thenAnswer(syncAnswer);
when(pm.run(any, workingDirectory: any)).thenAnswer((Invocation invocation) async {
return syncAnswer(invocation);
});
}
class MockProcessManager extends Mock implements ProcessManager {}
class MockCache extends Mock implements Cache {}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment