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