warn about outdated Flutter installations (#9163)
diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index de8f3e9..82846c6 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart
@@ -74,6 +74,16 @@ _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 {
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart index 9218b0b..d4d25f7 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
@@ -236,6 +236,7 @@ flutterUsage.suppressAnalytics = true; _checkFlutterCopy(); + await FlutterVersion.instance.checkFlutterVersionFreshness(); if (globalResults.wasParsed('packages')) PackageMap.globalPackagesPath = fs.path.normalize(fs.path.absolute(globalResults['packages']));
diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart index c15db62..f61327e 100644 --- a/packages/flutter_tools/lib/src/version.dart +++ b/packages/flutter_tools/lib/src/version.dart
@@ -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 @@ ]); 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 @@ _frameworkAge = _runGit('git log -n 1 --pretty=format:%ar'); } + final Clock _clock; + String _repositoryUrl; String get repositoryUrl => _repositoryUrl; @@ -72,20 +83,64 @@ } /// 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); } - static FlutterVersion get instance => context.putIfAbsent(FlutterVersion, () => new FlutterVersion._()); + /// 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(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 @@ 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) {
diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index be09aee..e50e24c 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml
@@ -10,6 +10,7 @@ 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 @@ 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'
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index c679f94..8231365 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart
@@ -20,6 +20,7 @@ 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 @@ 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 @@ @override void finish() { } } + +class MockFlutterVersion extends Mock implements FlutterVersion {}
diff --git a/packages/flutter_tools/test/src/runner/flutter_command_runner_test.dart b/packages/flutter_tools/test/src/runner/flutter_command_runner_test.dart new file mode 100644 index 0000000..b3d9932 --- /dev/null +++ b/packages/flutter_tools/test/src/runner/flutter_command_runner_test.dart
@@ -0,0 +1,29 @@ +// 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); + }); + }); +}
diff --git a/packages/flutter_tools/test/src/version_test.dart b/packages/flutter_tools/test/src/version_test.dart new file mode 100644 index 0000000..47da067 --- /dev/null +++ b/packages/flutter_tools/test/src/version_test.dart
@@ -0,0 +1,223 @@ +// 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 {}