• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2015 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6
7import 'package:meta/meta.dart';
8
9import 'base/common.dart';
10import 'base/context.dart';
11import 'base/file_system.dart';
12import 'base/io.dart';
13import 'base/process.dart';
14import 'base/process_manager.dart';
15import 'base/time.dart';
16import 'cache.dart';
17import 'convert.dart';
18import 'globals.dart';
19
20class FlutterVersion {
21  @visibleForTesting
22  FlutterVersion([this._clock = const SystemClock()]) {
23    _frameworkRevision = _runGit('git log -n 1 --pretty=format:%H');
24    _frameworkVersion = GitTagVersion.determine().frameworkVersionFor(_frameworkRevision);
25  }
26
27  final SystemClock _clock;
28
29  String _repositoryUrl;
30  String get repositoryUrl {
31    final String _ = channel;
32    return _repositoryUrl;
33  }
34
35  /// Whether we are currently on the master branch.
36  bool get isMaster {
37    final String branchName = getBranchName();
38    return !<String>['dev', 'beta', 'stable'].contains(branchName);
39  }
40
41  static const Set<String> officialChannels = <String>{
42    'master',
43    'dev',
44    'beta',
45    'stable',
46  };
47
48  /// This maps old branch names to the names of branches that replaced them.
49  ///
50  /// For example, in early 2018 we changed from having an "alpha" branch to
51  /// having a "dev" branch, so anyone using "alpha" now gets transitioned to
52  /// "dev".
53  static Map<String, String> obsoleteBranches = <String, String>{
54    'alpha': 'dev',
55    'hackathon': 'dev',
56    'codelab': 'dev',
57  };
58
59  String _channel;
60  /// The channel is the upstream branch.
61  /// `master`, `dev`, `beta`, `stable`; or old ones, like `alpha`, `hackathon`, ...
62  String get channel {
63    if (_channel == null) {
64      final String channel = _runGit('git rev-parse --abbrev-ref --symbolic @{u}');
65      final int slash = channel.indexOf('/');
66      if (slash != -1) {
67        final String remote = channel.substring(0, slash);
68        _repositoryUrl = _runGit('git ls-remote --get-url $remote');
69        _channel = channel.substring(slash + 1);
70       } else if (channel.isEmpty) {
71        _channel = 'unknown';
72      } else {
73        _channel = channel;
74      }
75    }
76    return _channel;
77  }
78
79  /// The name of the local branch.
80  /// Use getBranchName() to read this.
81  String _branch;
82
83  String _frameworkRevision;
84  String get frameworkRevision => _frameworkRevision;
85  String get frameworkRevisionShort => _shortGitRevision(frameworkRevision);
86
87  String _frameworkAge;
88  String get frameworkAge {
89    return _frameworkAge ??= _runGit('git log -n 1 --pretty=format:%ar');
90  }
91
92  String _frameworkVersion;
93  String get frameworkVersion => _frameworkVersion;
94
95  String get frameworkDate => frameworkCommitDate;
96
97  String get dartSdkVersion => Cache.instance.dartSdkVersion;
98
99  String get engineRevision => Cache.instance.engineRevision;
100  String get engineRevisionShort => _shortGitRevision(engineRevision);
101
102  Future<void> ensureVersionFile() async {
103    fs.file(fs.path.join(Cache.flutterRoot, 'version')).writeAsStringSync(_frameworkVersion);
104  }
105
106  @override
107  String toString() {
108    final String versionText = frameworkVersion == 'unknown' ? '' : ' $frameworkVersion';
109    final String flutterText = 'Flutter$versionText • channel $channel • ${repositoryUrl ?? 'unknown source'}';
110    final String frameworkText = 'Framework • revision $frameworkRevisionShort ($frameworkAge) • $frameworkCommitDate';
111    final String engineText = 'Engine • revision $engineRevisionShort';
112    final String toolsText = 'Tools • Dart $dartSdkVersion';
113
114    // Flutter 1.10.2-pre.69 • channel master • https://github.com/flutter/flutter.git
115    // Framework • revision 340c158f32 (84 minutes ago) • 2018-10-26 11:27:22 -0400
116    // Engine • revision 9c46333e14
117    // Tools • Dart 2.1.0 (build 2.1.0-dev.8.0 bf26f760b1)
118
119    return '$flutterText\n$frameworkText\n$engineText\n$toolsText';
120  }
121
122  Map<String, Object> toJson() => <String, Object>{
123        'frameworkVersion': frameworkVersion ?? 'unknown',
124        'channel': channel,
125        'repositoryUrl': repositoryUrl ?? 'unknown source',
126        'frameworkRevision': frameworkRevision,
127        'frameworkCommitDate': frameworkCommitDate,
128        'engineRevision': engineRevision,
129        'dartSdkVersion': dartSdkVersion,
130      };
131
132  /// A date String describing the last framework commit.
133  String get frameworkCommitDate => _latestGitCommitDate();
134
135  static String _latestGitCommitDate([ String branch ]) {
136    final List<String> args = <String>[
137      'git',
138      'log',
139      if (branch != null) branch,
140      '-n',
141      '1',
142      '--pretty=format:%ad',
143      '--date=iso',
144    ];
145    return _runSync(args, lenient: false);
146  }
147
148  /// The name of the temporary git remote used to check for the latest
149  /// available Flutter framework version.
150  ///
151  /// In the absence of bugs and crashes a Flutter developer should never see
152  /// this remote appear in their `git remote` list, but also if it happens to
153  /// persist we do the proper clean-up for extra robustness.
154  static const String _versionCheckRemote = '__flutter_version_check__';
155
156  /// The date of the latest framework commit in the remote repository.
157  ///
158  /// Throws [ToolExit] if a git command fails, for example, when the remote git
159  /// repository is not reachable due to a network issue.
160  static Future<String> fetchRemoteFrameworkCommitDate(String branch) async {
161    await _removeVersionCheckRemoteIfExists();
162    try {
163      await _run(<String>[
164        'git',
165        'remote',
166        'add',
167        _versionCheckRemote,
168        'https://github.com/flutter/flutter.git',
169      ]);
170      await _run(<String>['git', 'fetch', _versionCheckRemote, branch]);
171      return _latestGitCommitDate('$_versionCheckRemote/$branch');
172    } finally {
173      await _removeVersionCheckRemoteIfExists();
174    }
175  }
176
177  static Future<void> _removeVersionCheckRemoteIfExists() async {
178    final List<String> remotes = (await _run(<String>['git', 'remote']))
179        .split('\n')
180        .map<String>((String name) => name.trim()) // to account for OS-specific line-breaks
181        .toList();
182    if (remotes.contains(_versionCheckRemote))
183      await _run(<String>['git', 'remote', 'remove', _versionCheckRemote]);
184  }
185
186  static FlutterVersion get instance => context.get<FlutterVersion>();
187
188  /// Return a short string for the version (e.g. `master/0.0.59-pre.92`, `scroll_refactor/a76bc8e22b`).
189  String getVersionString({ bool redactUnknownBranches = false }) {
190    if (frameworkVersion != 'unknown')
191      return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkVersion';
192    return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkRevisionShort';
193  }
194
195  /// Return the branch name.
196  ///
197  /// If [redactUnknownBranches] is true and the branch is unknown,
198  /// the branch name will be returned as `'[user-branch]'`.
199  String getBranchName({ bool redactUnknownBranches = false }) {
200    _branch ??= () {
201      final String branch = _runGit('git rev-parse --abbrev-ref HEAD');
202      return branch == 'HEAD' ? channel : branch;
203    }();
204    if (redactUnknownBranches || _branch.isEmpty) {
205      // Only return the branch names we know about; arbitrary branch names might contain PII.
206      if (!officialChannels.contains(_branch) && !obsoleteBranches.containsKey(_branch))
207        return '[user-branch]';
208    }
209    return _branch;
210  }
211
212  /// Returns true if `tentativeDescendantRevision` is a direct descendant to
213  /// the `tentativeAncestorRevision` revision on the Flutter framework repo
214  /// tree.
215  bool checkRevisionAncestry({
216    String tentativeDescendantRevision,
217    String tentativeAncestorRevision,
218  }) {
219    final ProcessResult result = processManager.runSync(
220      <String>['git', 'merge-base', '--is-ancestor', tentativeAncestorRevision, tentativeDescendantRevision],
221      workingDirectory: Cache.flutterRoot,
222    );
223    return result.exitCode == 0;
224  }
225
226  /// The amount of time we wait before pinging the server to check for the
227  /// availability of a newer version of Flutter.
228  @visibleForTesting
229  static const Duration checkAgeConsideredUpToDate = Duration(days: 3);
230
231  /// We warn the user if the age of their Flutter installation is greater than
232  /// this duration. The durations are slightly longer than the expected release
233  /// cadence for each channel, to give the user a grace period before they get
234  /// notified.
235  ///
236  /// For example, for the beta channel, this is set to five weeks because
237  /// beta releases happen approximately every month.
238  @visibleForTesting
239  static Duration versionAgeConsideredUpToDate(String channel) {
240    switch (channel) {
241      case 'stable':
242        return const Duration(days: 365 ~/ 2); // Six months
243      case 'beta':
244        return const Duration(days: 7 * 8); // Eight weeks
245      case 'dev':
246        return const Duration(days: 7 * 4); // Four weeks
247      default:
248        return const Duration(days: 7 * 3); // Three weeks
249    }
250  }
251
252  /// The amount of time we wait between issuing a warning.
253  ///
254  /// This is to avoid annoying users who are unable to upgrade right away.
255  @visibleForTesting
256  static const Duration maxTimeSinceLastWarning = Duration(days: 1);
257
258  /// The amount of time we pause for to let the user read the message about
259  /// outdated Flutter installation.
260  ///
261  /// This can be customized in tests to speed them up.
262  @visibleForTesting
263  static Duration timeToPauseToLetUserReadTheMessage = const Duration(seconds: 2);
264
265  /// Reset the version freshness information by removing the stamp file.
266  ///
267  /// New version freshness information will be regenerated when
268  /// [checkFlutterVersionFreshness] is called after this. This is typically
269  /// used when switching channels so that stale information from another
270  /// channel doesn't linger.
271  static Future<void> resetFlutterVersionFreshnessCheck() async {
272    try {
273      await Cache.instance.getStampFileFor(
274        VersionCheckStamp.flutterVersionCheckStampFile,
275      ).delete();
276    } on FileSystemException {
277      // Ignore, since we don't mind if the file didn't exist in the first place.
278    }
279  }
280
281  /// Checks if the currently installed version of Flutter is up-to-date, and
282  /// warns the user if it isn't.
283  ///
284  /// This function must run while [Cache.lock] is acquired because it reads and
285  /// writes shared cache files.
286  Future<void> checkFlutterVersionFreshness() async {
287    // Don't perform update checks if we're not on an official channel.
288    if (!officialChannels.contains(channel)) {
289      return;
290    }
291
292    final DateTime localFrameworkCommitDate = DateTime.parse(frameworkCommitDate);
293    final Duration frameworkAge = _clock.now().difference(localFrameworkCommitDate);
294    final bool installationSeemsOutdated = frameworkAge > versionAgeConsideredUpToDate(channel);
295
296    // Get whether there's a newer version on the remote. This only goes
297    // to the server if we haven't checked recently so won't happen on every
298    // command.
299    final DateTime latestFlutterCommitDate = await _getLatestAvailableFlutterDate();
300    final VersionCheckResult remoteVersionStatus =
301        latestFlutterCommitDate == null
302            ? VersionCheckResult.unknown
303            : latestFlutterCommitDate.isAfter(localFrameworkCommitDate)
304                ? VersionCheckResult.newVersionAvailable
305                : VersionCheckResult.versionIsCurrent;
306
307    // Do not load the stamp before the above server check as it may modify the stamp file.
308    final VersionCheckStamp stamp = await VersionCheckStamp.load();
309    final DateTime lastTimeWarningWasPrinted = stamp.lastTimeWarningWasPrinted ?? _clock.ago(maxTimeSinceLastWarning * 2);
310    final bool beenAWhileSinceWarningWasPrinted = _clock.now().difference(lastTimeWarningWasPrinted) > maxTimeSinceLastWarning;
311
312    // We show a warning if either we know there is a new remote version, or we couldn't tell but the local
313    // version is outdated.
314    final bool canShowWarning =
315        remoteVersionStatus == VersionCheckResult.newVersionAvailable ||
316            (remoteVersionStatus == VersionCheckResult.unknown &&
317                installationSeemsOutdated);
318
319    if (beenAWhileSinceWarningWasPrinted && canShowWarning) {
320      final String updateMessage =
321          remoteVersionStatus == VersionCheckResult.newVersionAvailable
322              ? newVersionAvailableMessage()
323              : versionOutOfDateMessage(frameworkAge);
324      printStatus(updateMessage, emphasis: true);
325      await Future.wait<void>(<Future<void>>[
326        stamp.store(
327          newTimeWarningWasPrinted: _clock.now(),
328        ),
329        Future<void>.delayed(timeToPauseToLetUserReadTheMessage),
330      ]);
331    }
332  }
333
334  @visibleForTesting
335  static String versionOutOfDateMessage(Duration frameworkAge) {
336    String warning = 'WARNING: your installation of Flutter is ${frameworkAge.inDays} days old.';
337    // Append enough spaces to match the message box width.
338    warning += ' ' * (74 - warning.length);
339
340    return '''
341  ╔════════════════════════════════════════════════════════════════════════════╗
342  ║ $warning ║
343  ║                                                                            ║
344  ║ To update to the latest version, run "flutter upgrade".                    ║
345  ╚════════════════════════════════════════════════════════════════════════════╝
346''';
347  }
348
349  @visibleForTesting
350  static String newVersionAvailableMessage() {
351    return '''
352  ╔════════════════════════════════════════════════════════════════════════════╗
353  ║ A new version of Flutter is available!                                     ║
354  ║                                                                            ║
355  ║ To update to the latest version, run "flutter upgrade".                    ║
356  ╚════════════════════════════════════════════════════════════════════════════╝
357''';
358  }
359
360  /// Gets the release date of the latest available Flutter version.
361  ///
362  /// This method sends a server request if it's been more than
363  /// [checkAgeConsideredUpToDate] since the last version check.
364  ///
365  /// Returns null if the cached version is out-of-date or missing, and we are
366  /// unable to reach the server to get the latest version.
367  Future<DateTime> _getLatestAvailableFlutterDate() async {
368    Cache.checkLockAcquired();
369    final VersionCheckStamp versionCheckStamp = await VersionCheckStamp.load();
370
371    if (versionCheckStamp.lastTimeVersionWasChecked != null) {
372      final Duration timeSinceLastCheck = _clock.now().difference(versionCheckStamp.lastTimeVersionWasChecked);
373
374      // Don't ping the server too often. Return cached value if it's fresh.
375      if (timeSinceLastCheck < checkAgeConsideredUpToDate)
376        return versionCheckStamp.lastKnownRemoteVersion;
377    }
378
379    // Cache is empty or it's been a while since the last server ping. Ping the server.
380    try {
381      final DateTime remoteFrameworkCommitDate = DateTime.parse(await FlutterVersion.fetchRemoteFrameworkCommitDate(channel));
382      await versionCheckStamp.store(
383        newTimeVersionWasChecked: _clock.now(),
384        newKnownRemoteVersion: remoteFrameworkCommitDate,
385      );
386      return remoteFrameworkCommitDate;
387    } on VersionCheckError catch (error) {
388      // This happens when any of the git commands fails, which can happen when
389      // there's no Internet connectivity. Remote version check is best effort
390      // only. We do not prevent the command from running when it fails.
391      printTrace('Failed to check Flutter version in the remote repository: $error');
392      // Still update the timestamp to avoid us hitting the server on every single
393      // command if for some reason we cannot connect (eg. we may be offline).
394      await versionCheckStamp.store(
395        newTimeVersionWasChecked: _clock.now(),
396      );
397      return null;
398    }
399  }
400}
401
402/// Contains data and load/save logic pertaining to Flutter version checks.
403@visibleForTesting
404class VersionCheckStamp {
405  const VersionCheckStamp({
406    this.lastTimeVersionWasChecked,
407    this.lastKnownRemoteVersion,
408    this.lastTimeWarningWasPrinted,
409  });
410
411  final DateTime lastTimeVersionWasChecked;
412  final DateTime lastKnownRemoteVersion;
413  final DateTime lastTimeWarningWasPrinted;
414
415  /// The prefix of the stamp file where we cache Flutter version check data.
416  @visibleForTesting
417  static const String flutterVersionCheckStampFile = 'flutter_version_check';
418
419  static Future<VersionCheckStamp> load() async {
420    final String versionCheckStamp = Cache.instance.getStampFor(flutterVersionCheckStampFile);
421
422    if (versionCheckStamp != null) {
423      // Attempt to parse stamp JSON.
424      try {
425        final dynamic jsonObject = json.decode(versionCheckStamp);
426        if (jsonObject is Map) {
427          return fromJson(jsonObject);
428        } else {
429          printTrace('Warning: expected version stamp to be a Map but found: $jsonObject');
430        }
431      } catch (error, stackTrace) {
432        // Do not crash if JSON is malformed.
433        printTrace('${error.runtimeType}: $error\n$stackTrace');
434      }
435    }
436
437    // Stamp is missing or is malformed.
438    return const VersionCheckStamp();
439  }
440
441  static VersionCheckStamp fromJson(Map<String, dynamic> jsonObject) {
442    DateTime readDateTime(String property) {
443      return jsonObject.containsKey(property)
444          ? DateTime.parse(jsonObject[property])
445          : null;
446    }
447
448    return VersionCheckStamp(
449      lastTimeVersionWasChecked: readDateTime('lastTimeVersionWasChecked'),
450      lastKnownRemoteVersion: readDateTime('lastKnownRemoteVersion'),
451      lastTimeWarningWasPrinted: readDateTime('lastTimeWarningWasPrinted'),
452    );
453  }
454
455  Future<void> store({
456    DateTime newTimeVersionWasChecked,
457    DateTime newKnownRemoteVersion,
458    DateTime newTimeWarningWasPrinted,
459  }) async {
460    final Map<String, String> jsonData = toJson();
461
462    if (newTimeVersionWasChecked != null)
463      jsonData['lastTimeVersionWasChecked'] = '$newTimeVersionWasChecked';
464
465    if (newKnownRemoteVersion != null)
466      jsonData['lastKnownRemoteVersion'] = '$newKnownRemoteVersion';
467
468    if (newTimeWarningWasPrinted != null)
469      jsonData['lastTimeWarningWasPrinted'] = '$newTimeWarningWasPrinted';
470
471    const JsonEncoder prettyJsonEncoder = JsonEncoder.withIndent('  ');
472    Cache.instance.setStampFor(flutterVersionCheckStampFile, prettyJsonEncoder.convert(jsonData));
473  }
474
475  Map<String, String> toJson({
476    DateTime updateTimeVersionWasChecked,
477    DateTime updateKnownRemoteVersion,
478    DateTime updateTimeWarningWasPrinted,
479  }) {
480    updateTimeVersionWasChecked = updateTimeVersionWasChecked ?? lastTimeVersionWasChecked;
481    updateKnownRemoteVersion = updateKnownRemoteVersion ?? lastKnownRemoteVersion;
482    updateTimeWarningWasPrinted = updateTimeWarningWasPrinted ?? lastTimeWarningWasPrinted;
483
484    final Map<String, String> jsonData = <String, String>{};
485
486    if (updateTimeVersionWasChecked != null)
487      jsonData['lastTimeVersionWasChecked'] = '$updateTimeVersionWasChecked';
488
489    if (updateKnownRemoteVersion != null)
490      jsonData['lastKnownRemoteVersion'] = '$updateKnownRemoteVersion';
491
492    if (updateTimeWarningWasPrinted != null)
493      jsonData['lastTimeWarningWasPrinted'] = '$updateTimeWarningWasPrinted';
494
495    return jsonData;
496  }
497}
498
499/// Thrown when we fail to check Flutter version.
500///
501/// This can happen when we attempt to `git fetch` but there is no network, or
502/// when the installation is not git-based (e.g. a user clones the repo but
503/// then removes .git).
504class VersionCheckError implements Exception {
505
506  VersionCheckError(this.message);
507
508  final String message;
509
510  @override
511  String toString() => '$VersionCheckError: $message';
512}
513
514/// Runs [command] and returns the standard output as a string.
515///
516/// If [lenient] is true and the command fails, returns an empty string.
517/// Otherwise, throws a [ToolExit] exception.
518String _runSync(List<String> command, { bool lenient = true }) {
519  final ProcessResult results = processManager.runSync(command, workingDirectory: Cache.flutterRoot);
520
521  if (results.exitCode == 0)
522    return results.stdout.trim();
523
524  if (!lenient) {
525    throw VersionCheckError(
526      'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
527      'Standard error: ${results.stderr}'
528    );
529  }
530
531  return '';
532}
533
534String _runGit(String command) {
535  return runSync(command.split(' '), workingDirectory: Cache.flutterRoot);
536}
537
538/// Runs [command] in the root of the Flutter installation and returns the
539/// standard output as a string.
540///
541/// If the command fails, throws a [ToolExit] exception.
542Future<String> _run(List<String> command) async {
543  final ProcessResult results = await processManager.run(command, workingDirectory: Cache.flutterRoot);
544
545  if (results.exitCode == 0)
546    return results.stdout.trim();
547
548  throw VersionCheckError(
549    'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
550    'Standard error: ${results.stderr}'
551  );
552}
553
554String _shortGitRevision(String revision) {
555  if (revision == null)
556    return '';
557  return revision.length > 10 ? revision.substring(0, 10) : revision;
558}
559
560class GitTagVersion {
561  const GitTagVersion(this.x, this.y, this.z, this.hotfix, this.commits, this.hash);
562  const GitTagVersion.unknown()
563    : x = null,
564      y = null,
565      z = null,
566      hotfix = null,
567      commits = 0,
568      hash = '';
569
570  /// The X in vX.Y.Z.
571  final int x;
572
573  /// The Y in vX.Y.Z.
574  final int y;
575
576  /// The Z in vX.Y.Z.
577  final int z;
578
579  /// the F in vX.Y.Z+hotfix.F
580  final int hotfix;
581
582  /// Number of commits since the vX.Y.Z tag.
583  final int commits;
584
585  /// The git hash (or an abbreviation thereof) for this commit.
586  final String hash;
587
588  static GitTagVersion determine() {
589    return parse(_runGit('git describe --match v*.*.* --first-parent --long --tags'));
590  }
591
592  static GitTagVersion parse(String version) {
593    final RegExp versionPattern = RegExp(r'^v([0-9]+)\.([0-9]+)\.([0-9]+)(?:\+hotfix\.([0-9]+))?-([0-9]+)-g([a-f0-9]+)$');
594    final List<String> parts = versionPattern.matchAsPrefix(version)?.groups(<int>[1, 2, 3, 4, 5, 6]);
595    if (parts == null) {
596      printTrace('Could not interpret results of "git describe": $version');
597      return const GitTagVersion.unknown();
598    }
599    final List<int> parsedParts = parts.take(5).map<int>((String source) => source == null ? null : int.tryParse(source)).toList();
600    return GitTagVersion(parsedParts[0], parsedParts[1], parsedParts[2], parsedParts[3], parsedParts[4], parts[5]);
601  }
602
603  String frameworkVersionFor(String revision) {
604    if (x == null || y == null || z == null || !revision.startsWith(hash))
605      return '0.0.0-unknown';
606    if (commits == 0) {
607      if (hotfix != null)
608        return '$x.$y.$z+hotfix.$hotfix';
609      return '$x.$y.$z';
610    }
611    if (hotfix != null)
612      return '$x.$y.$z+hotfix.${hotfix + 1}-pre.$commits';
613    return '$x.$y.${z + 1}-pre.$commits';
614  }
615}
616
617enum VersionCheckResult {
618  /// Unable to check whether a new version is available, possibly due to
619  /// a connectivity issue.
620  unknown,
621  /// The current version is up to date.
622  versionIsCurrent,
623  /// A newer version is available.
624  newVersionAvailable,
625}
626