Unverified Commit 52e19ef7 authored by Loïc Sharma's avatar Loïc Sharma Committed by GitHub

Refactor vswhere.exe integration (#104133)

`VisualStudio` calls `vswhere.exe` to find Visual Studio installations and determine if they satisfy Flutter's requirements. Previously, `VisualStudio` stored the JSON output from `vswhere.exe` as `Map`s, resulting in duplicated logic to read the JSON output (once to validate values, second to expose values). Also, `VisualStudio` stored two copies of the JSON output (the latest valid installation as well as the latest VS installation).

This change simplifies `VisualStudio` by introducing a new `VswhereDetails`. This type contains the logic to read `vswhere.exe`'s JSON output, and, understand whether an installation is usable by Flutter. In the future, this `VswhereDetails` type will be used to make Flutter doctor resilient to bad UTF-8 output from `vswhere.exe`.

Part of https://github.com/flutter/flutter/issues/102451.
parent 874b6c08
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:meta/meta.dart';
import 'package:process/process.dart'; import 'package:process/process.dart';
import '../base/common.dart'; import '../base/common.dart';
...@@ -33,7 +34,7 @@ class VisualStudio { ...@@ -33,7 +34,7 @@ class VisualStudio {
/// Versions older than 2017 Update 2 won't be detected, so error messages to /// Versions older than 2017 Update 2 won't be detected, so error messages to
/// users should take into account that [false] may mean that the user may /// users should take into account that [false] may mean that the user may
/// have an old version rather than no installation at all. /// have an old version rather than no installation at all.
bool get isInstalled => _bestVisualStudioDetails.isNotEmpty; bool get isInstalled => _bestVisualStudioDetails != null;
bool get isAtLeastMinimumVersion { bool get isAtLeastMinimumVersion {
final int? installedMajorVersion = _majorVersion; final int? installedMajorVersion = _majorVersion;
...@@ -42,30 +43,25 @@ class VisualStudio { ...@@ -42,30 +43,25 @@ class VisualStudio {
/// True if there is a version of Visual Studio with all the components /// True if there is a version of Visual Studio with all the components
/// necessary to build the project. /// necessary to build the project.
bool get hasNecessaryComponents => _usableVisualStudioDetails.isNotEmpty; bool get hasNecessaryComponents => _bestVisualStudioDetails?.isUsable ?? false;
/// The name of the Visual Studio install. /// The name of the Visual Studio install.
/// ///
/// For instance: "Visual Studio Community 2019". /// For instance: "Visual Studio Community 2019".
String? get displayName => _bestVisualStudioDetails[_displayNameKey] as String?; String? get displayName => _bestVisualStudioDetails?.displayName;
/// The user-friendly version number of the Visual Studio install. /// The user-friendly version number of the Visual Studio install.
/// ///
/// For instance: "15.4.0". /// For instance: "15.4.0".
String? get displayVersion { String? get displayVersion => _bestVisualStudioDetails?.catalogDisplayVersion;
if (_bestVisualStudioDetails[_catalogKey] == null) {
return null;
}
return (_bestVisualStudioDetails[_catalogKey] as Map<String, dynamic>)[_catalogDisplayVersionKey] as String?;
}
/// The directory where Visual Studio is installed. /// The directory where Visual Studio is installed.
String? get installLocation => _bestVisualStudioDetails[_installationPathKey] as String?; String? get installLocation => _bestVisualStudioDetails?.installationPath;
/// The full version of the Visual Studio install. /// The full version of the Visual Studio install.
/// ///
/// For instance: "15.4.27004.2002". /// For instance: "15.4.27004.2002".
String? get fullVersion => _bestVisualStudioDetails[_fullVersionKey] as String?; String? get fullVersion => _bestVisualStudioDetails?.fullVersion;
// Properties that determine the status of the installation. There might be // Properties that determine the status of the installation. There might be
// Visual Studio versions that don't include them, so default to a "valid" value to // Visual Studio versions that don't include them, so default to a "valid" value to
...@@ -75,27 +71,27 @@ class VisualStudio { ...@@ -75,27 +71,27 @@ class VisualStudio {
/// ///
/// False if installation is not found. /// False if installation is not found.
bool get isComplete { bool get isComplete {
if (_bestVisualStudioDetails.isEmpty) { if (_bestVisualStudioDetails == null) {
return false; return false;
} }
return _bestVisualStudioDetails[_isCompleteKey] as bool? ?? true; return _bestVisualStudioDetails!.isComplete ?? true;
} }
/// True if Visual Studio is launchable. /// True if Visual Studio is launchable.
/// ///
/// False if installation is not found. /// False if installation is not found.
bool get isLaunchable { bool get isLaunchable {
if (_bestVisualStudioDetails.isEmpty) { if (_bestVisualStudioDetails == null) {
return false; return false;
} }
return _bestVisualStudioDetails[_isLaunchableKey] as bool? ?? true; return _bestVisualStudioDetails!.isLaunchable ?? true;
} }
/// True if the Visual Studio installation is as pre-release version. /// True if the Visual Studio installation is a pre-release version.
bool get isPrerelease => _bestVisualStudioDetails[_isPrereleaseKey] as bool? ?? false; bool get isPrerelease => _bestVisualStudioDetails?.isPrerelease ?? false;
/// True if a reboot is required to complete the Visual Studio installation. /// True if a reboot is required to complete the Visual Studio installation.
bool get isRebootRequired => _bestVisualStudioDetails[_isRebootRequiredKey] as bool? ?? false; bool get isRebootRequired => _bestVisualStudioDetails?.isRebootRequired ?? false;
/// The name of the recommended Visual Studio installer workload. /// The name of the recommended Visual Studio installer workload.
String get workloadDescription => 'Desktop development with C++'; String get workloadDescription => 'Desktop development with C++';
...@@ -150,12 +146,13 @@ class VisualStudio { ...@@ -150,12 +146,13 @@ class VisualStudio {
/// The path to CMake, or null if no Visual Studio installation has /// The path to CMake, or null if no Visual Studio installation has
/// the components necessary to build. /// the components necessary to build.
String? get cmakePath { String? get cmakePath {
final Map<String, dynamic> details = _usableVisualStudioDetails; final VswhereDetails? details = _bestVisualStudioDetails;
if (details.isEmpty || _usableVisualStudioDetails[_installationPathKey] == null) { if (details == null || !details.isUsable || details.installationPath == null) {
return null; return null;
} }
return _fileSystem.path.joinAll(<String>[ return _fileSystem.path.joinAll(<String>[
_usableVisualStudioDetails[_installationPathKey] as String, details.installationPath!,
'Common7', 'Common7',
'IDE', 'IDE',
'CommonExtensions', 'CommonExtensions',
...@@ -253,33 +250,6 @@ class VisualStudio { ...@@ -253,33 +250,6 @@ class VisualStudio {
/// vswhere argument to allow prerelease versions. /// vswhere argument to allow prerelease versions.
static const String _vswherePrereleaseArgument = '-prerelease'; static const String _vswherePrereleaseArgument = '-prerelease';
// Keys in a VS details dictionary returned from vswhere.
/// The root directory of the Visual Studio installation.
static const String _installationPathKey = 'installationPath';
/// The user-friendly name of the installation.
static const String _displayNameKey = 'displayName';
/// The complete version.
static const String _fullVersionKey = 'installationVersion';
/// Keys for the status of the installation.
static const String _isCompleteKey = 'isComplete';
static const String _isLaunchableKey = 'isLaunchable';
static const String _isRebootRequiredKey = 'isRebootRequired';
/// The 'catalog' entry containing more details.
static const String _catalogKey = 'catalog';
/// The key for a pre-release version.
static const String _isPrereleaseKey = 'isPrerelease';
/// The user-friendly version.
///
/// This key is under the 'catalog' entry.
static const String _catalogDisplayVersionKey = 'productDisplayVersion';
/// The registry path for Windows 10 SDK installation details. /// The registry path for Windows 10 SDK installation details.
static const String _windows10SdkRegistryPath = r'HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0'; static const String _windows10SdkRegistryPath = r'HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0';
...@@ -287,10 +257,11 @@ class VisualStudio { ...@@ -287,10 +257,11 @@ class VisualStudio {
/// SDKs are installed. /// SDKs are installed.
static const String _windows10SdkRegistryKey = 'InstallationFolder'; static const String _windows10SdkRegistryKey = 'InstallationFolder';
/// Returns the details dictionary for the newest version of Visual Studio. /// Returns the details of the newest version of Visual Studio.
///
/// If [validateRequirements] is set, the search will be limited to versions /// If [validateRequirements] is set, the search will be limited to versions
/// that have all of the required workloads and components. /// that have all of the required workloads and components.
Map<String, dynamic>? _visualStudioDetails({ VswhereDetails? _visualStudioDetails({
bool validateRequirements = false, bool validateRequirements = false,
List<String>? additionalArguments, List<String>? additionalArguments,
String? requiredWorkload String? requiredWorkload
...@@ -321,7 +292,7 @@ class VisualStudio { ...@@ -321,7 +292,7 @@ class VisualStudio {
final List<Map<String, dynamic>> installations = final List<Map<String, dynamic>> installations =
(json.decode(whereResult.stdout) as List<dynamic>).cast<Map<String, dynamic>>(); (json.decode(whereResult.stdout) as List<dynamic>).cast<Map<String, dynamic>>();
if (installations.isNotEmpty) { if (installations.isNotEmpty) {
return installations[0]; return VswhereDetails.fromJson(validateRequirements, installations[0]);
} }
} }
} on ArgumentError { } on ArgumentError {
...@@ -334,90 +305,39 @@ class VisualStudio { ...@@ -334,90 +305,39 @@ class VisualStudio {
return null; return null;
} }
/// Checks if the given installation has issues that the user must resolve. /// Returns the details of the best available version of Visual Studio.
///
/// Returns false if the required information is missing since older versions
/// of Visual Studio might not include them.
bool installationHasIssues(Map<String, dynamic>installationDetails) {
assert(installationDetails != null);
if (installationDetails[_isCompleteKey] != null && !(installationDetails[_isCompleteKey] as bool)) {
return true;
}
if (installationDetails[_isLaunchableKey] != null && !(installationDetails[_isLaunchableKey] as bool)) {
return true;
}
if (installationDetails[_isRebootRequiredKey] != null && installationDetails[_isRebootRequiredKey] as bool) {
return true;
}
return false;
}
/// Returns the details dictionary for the latest version of Visual Studio
/// that has all required components and is a supported version, or {} if
/// there is no such installation.
/// ///
/// If no installation is found, the cached VS details are set to an empty map /// If there's a version that has all the required components, that
/// to avoid repeating vswhere queries that have already not found an installation. /// will be returned, otherwise returns the latest installed version regardless
late final Map<String, dynamic> _usableVisualStudioDetails = (){ /// of components and version, or null if no such installation is found.
late final VswhereDetails? _bestVisualStudioDetails = () {
// First, attempt to find the latest version of Visual Studio that satifies
// both the minimum supported version and the required workloads.
// Check in the order of stable VS, stable BT, pre-release VS, pre-release BT.
final List<String> minimumVersionArguments = <String>[ final List<String> minimumVersionArguments = <String>[
_vswhereMinVersionArgument, _vswhereMinVersionArgument,
_minimumSupportedVersion.toString(), _minimumSupportedVersion.toString(),
]; ];
Map<String, dynamic>? visualStudioDetails;
// Check in the order of stable VS, stable BT, pre-release VS, pre-release BT
for (final bool checkForPrerelease in <bool>[false, true]) { for (final bool checkForPrerelease in <bool>[false, true]) {
for (final String requiredWorkload in _requiredWorkloads) { for (final String requiredWorkload in _requiredWorkloads) {
visualStudioDetails ??= _visualStudioDetails( final VswhereDetails? result = _visualStudioDetails(
validateRequirements: true, validateRequirements: true,
additionalArguments: checkForPrerelease additionalArguments: checkForPrerelease
? <String>[...minimumVersionArguments, _vswherePrereleaseArgument] ? <String>[...minimumVersionArguments, _vswherePrereleaseArgument]
: minimumVersionArguments, : minimumVersionArguments,
requiredWorkload: requiredWorkload); requiredWorkload: requiredWorkload);
}
}
Map<String, dynamic>? usableVisualStudioDetails; if (result != null) {
if (visualStudioDetails != null) { return result;
if (installationHasIssues(visualStudioDetails)) { }
_cachedAnyVisualStudioDetails = visualStudioDetails;
} else {
usableVisualStudioDetails = visualStudioDetails;
} }
} }
return usableVisualStudioDetails ?? <String, dynamic>{};
}();
/// Returns the details dictionary of the latest version of Visual Studio, // An installation that satifies requirements could not be found.
/// regardless of components and version, or {} if no such installation is // Fallback to the latest Visual Studio installation.
/// found. return _visualStudioDetails(
///
/// If no installation is found, the cached VS details are set to an empty map
/// to avoid repeating vswhere queries that have already not found an
/// installation.
Map<String, dynamic>? _cachedAnyVisualStudioDetails;
Map<String, dynamic> get _anyVisualStudioDetails {
// Search for all types of installations.
_cachedAnyVisualStudioDetails ??= _visualStudioDetails(
additionalArguments: <String>[_vswherePrereleaseArgument, '-all']); additionalArguments: <String>[_vswherePrereleaseArgument, '-all']);
// Add a sentinel empty value to avoid querying vswhere again. }();
_cachedAnyVisualStudioDetails ??= <String, dynamic>{};
return _cachedAnyVisualStudioDetails!;
}
/// Returns the details dictionary of the best available version of Visual
/// Studio.
///
/// If there's a version that has all the required components, that
/// will be returned, otherwise returns the latest installed version (if any).
Map<String, dynamic> get _bestVisualStudioDetails {
if (_usableVisualStudioDetails.isNotEmpty) {
return _usableVisualStudioDetails;
}
return _anyVisualStudioDetails;
}
/// Returns the installation location of the Windows 10 SDKs, or null if the /// Returns the installation location of the Windows 10 SDKs, or null if the
/// registry doesn't contain that information. /// registry doesn't contain that information.
...@@ -471,3 +391,87 @@ class VisualStudio { ...@@ -471,3 +391,87 @@ class VisualStudio {
return highestVersion == null ? null : '10.$highestVersion'; return highestVersion == null ? null : '10.$highestVersion';
} }
} }
/// The details of a Visual Studio installation according to vswhere.
@visibleForTesting
class VswhereDetails {
const VswhereDetails({
required this.meetsRequirements,
required this.installationPath,
required this.displayName,
required this.fullVersion,
required this.isComplete,
required this.isLaunchable,
required this.isRebootRequired,
required this.isPrerelease,
required this.catalogDisplayVersion,
});
/// Create a `VswhereDetails` from the JSON output of vswhere.exe.
factory VswhereDetails.fromJson(
bool meetsRequirements,
Map<String, dynamic> details
) {
final Map<String, dynamic>? catalog = details['catalog'] as Map<String, dynamic>?;
return VswhereDetails(
meetsRequirements: meetsRequirements,
installationPath: details['installationPath'] as String?,
displayName: details['displayName'] as String?,
fullVersion: details['installationVersion'] as String?,
isComplete: details['isComplete'] as bool?,
isLaunchable: details['isLaunchable'] as bool?,
isRebootRequired: details['isRebootRequired'] as bool?,
isPrerelease: details['isPrerelease'] as bool?,
catalogDisplayVersion: catalog == null ? null : catalog['productDisplayVersion'] as String?,
);
}
/// Whether the installation satisfies the required workloads and minimum version.
final bool meetsRequirements;
/// The root directory of the Visual Studio installation.
final String? installationPath;
/// The user-friendly name of the installation.
final String? displayName;
/// The complete version.
final String? fullVersion;
/// Keys for the status of the installation.
final bool? isComplete;
final bool? isLaunchable;
final bool? isRebootRequired;
/// The key for a pre-release version.
final bool? isPrerelease;
/// The user-friendly version.
final String? catalogDisplayVersion;
/// Checks if the Visual Studio installation can be used by Flutter.
///
/// Returns false if the installation has issues the user must resolve.
/// This may return true even if required information is missing as older
/// versions of Visual Studio might not include them.
bool get isUsable {
if (!meetsRequirements) {
return false;
}
if (!(isComplete ?? true)) {
return false;
}
if (!(isLaunchable ?? true)) {
return false;
}
if (isRebootRequired ?? false) {
return false;
}
return true;
}
}
...@@ -863,6 +863,98 @@ void main() { ...@@ -863,6 +863,98 @@ void main() {
expect(visualStudio.getWindows10SDKVersion(), null); expect(visualStudio.getWindows10SDKVersion(), null);
}); });
}); });
group(VswhereDetails, () {
test('Accepts empty JSON', () {
const bool meetsRequirements = true;
final Map<String, dynamic> json = <String, dynamic>{};
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json);
expect(result.installationPath, null);
expect(result.displayName, null);
expect(result.fullVersion, null);
expect(result.isComplete, null);
expect(result.isLaunchable, null);
expect(result.isRebootRequired, null);
expect(result.isPrerelease, null);
expect(result.catalogDisplayVersion, null);
expect(result.isUsable, isTrue);
});
test('Ignores unknown JSON properties', () {
const bool meetsRequirements = true;
final Map<String, dynamic> json = <String, dynamic>{
'hello': 'world',
};
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json);
expect(result.installationPath, null);
expect(result.displayName, null);
expect(result.fullVersion, null);
expect(result.isComplete, null);
expect(result.isLaunchable, null);
expect(result.isRebootRequired, null);
expect(result.isPrerelease, null);
expect(result.catalogDisplayVersion, null);
expect(result.isUsable, isTrue);
});
test('Accepts JSON', () {
const bool meetsRequirements = true;
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, _defaultResponse);
expect(result.installationPath, visualStudioPath);
expect(result.displayName, 'Visual Studio Community 2019');
expect(result.fullVersion, '16.2.29306.81');
expect(result.isComplete, true);
expect(result.isLaunchable, true);
expect(result.isRebootRequired, false);
expect(result.isPrerelease, false);
expect(result.catalogDisplayVersion, '16.2.5');
expect(result.isUsable, isTrue);
});
test('Installation that does not satisfy requirements is not usable', () {
const bool meetsRequirements = false;
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, _defaultResponse);
expect(result.isUsable, isFalse);
});
test('Incomplete installation is not usable', () {
const bool meetsRequirements = true;
final Map<String, dynamic> json = Map<String, dynamic>.of(_defaultResponse)
..['isComplete'] = false;
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json);
expect(result.isUsable, isFalse);
});
test('Unlaunchable installation is not usable', () {
const bool meetsRequirements = true;
final Map<String, dynamic> json = Map<String, dynamic>.of(_defaultResponse)
..['isLaunchable'] = false;
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json);
expect(result.isUsable, isFalse);
});
test('Installation that requires reboot is not usable', () {
const bool meetsRequirements = true;
final Map<String, dynamic> json = Map<String, dynamic>.of(_defaultResponse)
..['isRebootRequired'] = true;
final VswhereDetails result = VswhereDetails.fromJson(meetsRequirements, json);
expect(result.isUsable, isFalse);
});
});
} }
class VisualStudioFixture { class VisualStudioFixture {
......
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